#!/usr/bin/env python3
#
# gcdemu: Gtk/AppIndicator CDEmu GUI
# Copyright (C) 2006-2026 Rok Mandeljc
#
# This program is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along
# with this program; if not, write to the Free Software Foundation, Inc.,
# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.

import argparse
import datetime
import gettext
import os
import signal
import subprocess
import sys
import weakref

import gi

# Put under if block to avoid triggering E402 warnings in subsequent imports...
if True:
    gi.require_version('GLib', '2.0')
    gi.require_version('GObject', '2.0')
    gi.require_version('Gio', '2.0')
    gi.require_version('Gtk', '3.0')
    gi.require_version('GdkPixbuf', '2.0')
    gi.require_version('Notify', '0.7')

from gi.repository import GLib, GObject, Gio
from gi.repository import Gtk, GdkPixbuf
from gi.repository import Notify

# AppIndicator
try:
    gi.require_version('AppIndicator3', '0.1')

    from gi.repository import AppIndicator3 as AppIndicator
    have_app_indicator = True
except Exception:
    try:
        gi.require_version('AyatanaAppIndicator3', '0.1')

        from gi.repository import AyatanaAppIndicator3 as AppIndicator
        have_app_indicator = True
    except Exception:
        have_app_indicator = False


# *** Globals ***
app_name = "gcdemu"
app_version = "3.3.0"
supported_daemon_interface_version = (7, 0)

# I18n
gettext.install(app_name)

# Set process name
if sys.platform == "linux":
    try:
        import ctypes
        libc = ctypes.CDLL("libc.so.6")
        libc.prctl(15, app_name.encode('utf-8'), 0, 0, 0)  # 15 = PR_SET_NAME
    except Exception:
        pass


########################################################################
#               CDEmuDaemonProxy: Daemon proxy object                  #
########################################################################
class CDEmuDaemonProxy(GObject.GObject):
    _name = 'net.sf.cdemu.CDEmuDaemon'
    _object_path = '/Daemon'

    def __init__(self):
        super().__init__()

        self.watcher_id = -1
        self.proxy_handler_ids = []
        self.proxy = None

    def cleanup(self):
        # Cancel watcher
        if self.watcher_id != -1:
            Gio.bus_unwatch_name(self.watcher_id)
            self.watcher_id = -1

        # Clean up the proxy
        if self.proxy:
            for handler in self.proxy_handler_ids:
                self.proxy.disconnect(handler)
            self.proxy_handler_ids = []
            self.proxy = None

    def connect_to_bus(self, use_system, autostart_daemon):
        # Cleanup
        self.cleanup()

        # Get the bus
        bus = Gio.bus_get_sync(Gio.BusType.SYSTEM if use_system else Gio.BusType.SESSION, None)

        # Create proxy on our interface
        self.proxy = Gio.DBusProxy.new_sync(
            bus,
            0,
            None,
            self._name,
            self._object_path,
            'net.sf.cdemu.CDEmuDaemon',
            None,
        )

        # Connect signal: g-signal
        handler_id = self.proxy.connect("g-signal", self.on_g_signal)
        self.proxy_handler_ids.append(handler_id)

        # Set up name watch; we do not try to autostart the daemon here
        self.watcher_id = Gio.bus_watch_name_on_connection(
            bus,
            self._name,
            0,
            lambda c, n, no: self.emit("DaemonStarted"),
            lambda c, n: self.emit("DaemonStopped"),
        )

        # Try to autostart daemon by pinging it
        if autostart_daemon:
            bus.call_sync(
                self._name,
                self._object_path,
                "org.freedesktop.DBus.Peer",
                "Ping",
                None,
                None,
                0,
                -1,
                None,
            )

    def on_g_signal(self, proxy, sender, signal, params):
        if signal == "DeviceStatusChanged":
            self.emit(signal, params[0])
        elif signal == "DeviceOptionChanged":
            self.emit(signal, params[0], params[1])
        elif signal == "DeviceMappingReady":
            self.emit(signal, params[0])
        elif signal == "DeviceAdded":
            self.emit(signal)
        elif signal == "DeviceRemoved":
            self.emit(signal)
        else:
            print(f"Unknown signal: sender={sender!r}, signal={signal!r}, params={params!r}")

    def GetDaemonVersion(self):
        return self.proxy.GetDaemonVersion()

    def GetLibraryVersion(self):
        return self.proxy.GetLibraryVersion()

    def GetDaemonInterfaceVersion2(self):
        return self.proxy.GetDaemonInterfaceVersion2()

    def EnumDaemonDebugMasks(self):
        return self.proxy.EnumDaemonDebugMasks()

    def EnumLibraryDebugMasks(self):
        return self.proxy.EnumLibraryDebugMasks()

    def EnumSupportedParsers(self):
        return self.proxy.EnumSupportedParsers()

    def EnumSupportedWriters(self):
        return self.proxy.EnumSupportedWriters()

    def EnumSupportedFilterStreams(self):
        return self.proxy.EnumSupportedFilterStreams()

    def EnumWriterParameters(self, writer_id):
        return self.proxy.EnumWriterParameters('(s)', writer_id)

    def GetNumberOfDevices(self):
        return self.proxy.GetNumberOfDevices()

    def DeviceGetMapping(self, device_number):
        return self.proxy.DeviceGetMapping('(i)', device_number)

    def DeviceGetStatus(self, device_number):
        return self.proxy.DeviceGetStatus('(i)', device_number)

    def DeviceLoad(self, device_number, filenames, parameters):
        return self.proxy.DeviceLoad('(iasa{sv})', device_number, filenames, parameters)

    def DeviceCreateBlank(self, device_number, filename, parameters):
        return self.proxy.DeviceCreateBlank('(isa{sv})', device_number, filename, parameters)

    def DeviceUnload(self, device_number):
        return self.proxy.DeviceUnload('(i)', device_number)

    def DeviceGetOption(self, device_number, option_name):
        return self.proxy.DeviceGetOption('(is)', device_number, option_name)

    def DeviceSetOption(self, device_number, option_name, option_value):
        return self.proxy.DeviceSetOption('(isv)', device_number, option_name, option_value)

    def AddDevice(self):
        return self.proxy.AddDevice()

    def RemoveDevice(self):
        return self.proxy.RemoveDevice()


GObject.type_register(CDEmuDaemonProxy)

GObject.signal_new(
    "DaemonStarted",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "DaemonStopped",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "DeviceStatusChanged",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_INT, ),
)

GObject.signal_new(
    "DeviceOptionChanged",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_INT, GObject.TYPE_STRING, ),
)

GObject.signal_new(
    "DeviceMappingReady",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_INT, ),
)

GObject.signal_new(
    "DeviceAdded",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "DeviceRemoved",
    CDEmuDaemonProxy,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)


########################################################################
#                   CDEmu: D-BUS interface object                      #
########################################################################
class CDEmu(GObject.GObject):
    def __init__(self):
        super().__init__()

        self.daemon_proxy = CDEmuDaemonProxy()
        self.daemon_proxy.connect("DaemonStarted", lambda o: self.on_daemon_started())
        self.daemon_proxy.connect("DaemonStopped", lambda o: self.on_daemon_stopped())

        self.daemon_proxy.connect("DeviceAdded", lambda o: self.on_device_added())
        self.daemon_proxy.connect("DeviceRemoved", lambda o: self.on_device_removed())

        self.initial = True
        self.devices = []

    def cleanup(self):
        # Cleanup the devices
        self.devices = []

    def on_daemon_started(self):
        if not self.initial:
            self.emit("daemon-started")

        # Establish connection to daemon
        self.establish_connection()

        # Reset the initial run flag
        self.initial = False

    def on_daemon_stopped(self):
        if not self.initial:
            self.emit("daemon-stopped")

        # Cleanup
        self.emit("connection-lost")
        self.cleanup()

        # Reset the initial run flag
        self.initial = False

    def connect_to_bus(self, use_system, autostart_daemon):
        # Disconnect
        self.emit("connection-lost")

        # Cleanup
        self.cleanup()

        # Connect
        self.initial = True
        try:
            self.daemon_proxy.connect_to_bus(use_system, autostart_daemon)
        except GLib.Error as e:
            self.emit(
                "error",
                _("Daemon autostart error"),
                _("Daemon autostart failed. Error:\n%s") % (e),
            )

    def establish_connection(self):
        # Get the daemon interface version
        self.interface_version = self.daemon_proxy.GetDaemonInterfaceVersion2()

        # Validate the daemon interface version
        if (
            self.interface_version[0] != supported_daemon_interface_version[0] or
            self.interface_version[1] < supported_daemon_interface_version[1]
        ):
            self.emit(
                "error",
                _("Incompatible daemon interface"),
                _("CDEmu daemon interface version %i.%i detected, but version %i.%i is required!") %
                (
                    self.interface_version[0],
                    self.interface_version[1],
                    supported_daemon_interface_version[0],
                    supported_daemon_interface_version[1],
                ),
            )

            # Cleanup
            self.cleanup()
            return

        # Get daemon version
        self.daemon_version = self.daemon_proxy.GetDaemonVersion()

        # Get library version
        self.library_version = self.daemon_proxy.GetLibraryVersion()

        # Get daemon debug masks
        self.daemon_debug_masks = self.daemon_proxy.EnumDaemonDebugMasks()

        # Get library debug masks
        self.library_debug_masks = self.daemon_proxy.EnumLibraryDebugMasks()

        # Get supported parsers
        self.supported_parsers = self.daemon_proxy.EnumSupportedParsers()

        # Get supported writers
        self.supported_writers = []
        writers = self.daemon_proxy.EnumSupportedWriters()
        for writer in writers:
            writer_id = writer[0]
            writer_name = writer[1]
            parameter_sheet = self.daemon_proxy.EnumWriterParameters(writer_id)
            self.supported_writers.append((writer_id, writer_name, parameter_sheet))

        # Get supported filter streams
        self.supported_filter_streams = self.daemon_proxy.EnumSupportedFilterStreams()

        # Get number of devices
        num_devices = self.daemon_proxy.GetNumberOfDevices()

        # Create devices
        for i in range(num_devices):
            self.devices.append(CDEmuDevice(i, self.daemon_proxy))

        # Emit signal
        self.emit("connection-established", num_devices)

    def on_device_added(self):
        # Create device object and append it to list
        num = len(self.devices)
        self.devices.append(CDEmuDevice(num, self.daemon_proxy))
        self.emit("device-added", num)

    def on_device_removed(self):
        # Remove from list
        self.devices.pop()
        self.emit("device-removed", len(self.devices))

    def add_device(self):
        try:
            self.daemon_proxy.AddDevice()
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to add device"),
                _("Failed to add new device. Error:\n%s") % (e),
            )

    def remove_device(self):
        try:
            self.daemon_proxy.RemoveDevice()
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to remove device"),
                _("Failed to remove device. Error:\n%s") % (e),
            )


GObject.type_register(CDEmu)

GObject.signal_new(
    "error",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_STRING, GObject.TYPE_STRING, ),
)

GObject.signal_new(
    "daemon-started",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "daemon-stopped",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "connection-established",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_INT, ),
)

GObject.signal_new(
    "connection-lost",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "device-added",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_INT, ),
)

GObject.signal_new(
    "device-removed",
    CDEmu,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_INT, ),
)


########################################################################
#                 CDEmuDevice: device representation                   #
########################################################################
class CDEmuNeedPassword(Exception):
    pass


class CDEmuDevice(GObject.GObject):
    def __init__(self, device_number, proxy):
        GObject.GObject.__init__(self)

        # Device number
        self.number = device_number

        # Daemon proxy
        self.proxy = proxy

        # Device status
        self.loaded, self.filenames = self.proxy.DeviceGetStatus(self.number)

        # Device mapping
        self.sr_path, self.sg_path = self.proxy.DeviceGetMapping(self.number)

        # Get device options
        self.dpm_emulation = self.proxy.DeviceGetOption(self.number, "dpm-emulation")
        self.tr_emulation = self.proxy.DeviceGetOption(self.number, "tr-emulation")
        self.bad_sector_emulation = self.proxy.DeviceGetOption(self.number, "bad-sector-emulation")
        self.dvd_report_css = self.proxy.DeviceGetOption(self.number, "dvd-report-css")
        self.device_id = self.proxy.DeviceGetOption(self.number, "device-id")
        self.daemon_debug_mask = self.proxy.DeviceGetOption(self.number, "daemon-debug-mask")
        self.library_debug_mask = self.proxy.DeviceGetOption(self.number, "library-debug-mask")

        # Note: we connect the following using weakref, otherwise we get circular references
        # and devices never get destroyed
        self.proxy.connect(
            "DeviceStatusChanged",
            lambda p, d, obj=weakref.ref(self): obj().on_status_changed(d) if obj() else None,
        )
        self.proxy.connect(
            "DeviceOptionChanged",
            lambda p, d, o, obj=weakref.ref(self): obj().on_option_changed(d, o) if obj() else None,
        )
        self.proxy.connect(
            "DeviceMappingReady",
            lambda p, d, obj=weakref.ref(self): obj().on_mapping_ready(d) if obj() else None,
        )

    def on_status_changed(self, device_number):
        if device_number != self.number:
            return

        # We may fail to retrieve status when a device was removed
        try:
            self.loaded, self.filenames = self.proxy.DeviceGetStatus(self.number)
            self.emit("status-changed")
        except Exception:
            pass

    def on_option_changed(self, device_number, option):
        if device_number != self.number:
            return

        if option == "device-id":
            self.device_id = self.proxy.DeviceGetOption(self.number, "device-id")
            self.emit("device-id-changed")
        elif option == "dpm-emulation":
            self.dpm_emulation = self.proxy.DeviceGetOption(self.number, "dpm-emulation")
            self.emit("dpm-emulation-changed")
        elif option == "tr-emulation":
            self.tr_emulation = self.proxy.DeviceGetOption(self.number, "tr-emulation")
            self.emit("tr-emulation-changed")
        elif option == "bad-sector-emulation":
            self.bad_sector_emulation = self.proxy.DeviceGetOption(self.number, "bad-sector-emulation")
            self.emit("bad-sector-emulation-changed")
        elif option == "dvd-report-css":
            self.dvd_report_css = self.proxy.DeviceGetOption(self.number, "dvd-report-css")
            self.emit("dvd-report-css-changed")
        elif option == "daemon-debug-mask":
            self.daemon_debug_mask = self.proxy.DeviceGetOption(self.number, "daemon-debug-mask")
            self.emit("daemon-debug-mask-changed")
        elif option == "library-debug-mask":
            self.library_debug_mask = self.proxy.DeviceGetOption(self.number, "library-debug-mask")
            self.emit("library-debug-mask-changed")
        else:
            print(f"Unknown option: {option}!")

    def on_mapping_ready(self, device_number):
        if device_number != self.number:
            return

        # Check if mappings really changed
        changed = False
        sr_path, sg_path = self.proxy.DeviceGetMapping(self.number)

        if sr_path != self.sr_path:
            self.sr_path = sr_path
            changed = True

        if sg_path != self.sg_path:
            self.sg_path = sg_path
            changed = True

        if changed:
            self.emit("mapping-changed")

    def unload_device(self):
        try:
            self.proxy.DeviceUnload(self.number)
        except GLib.Error as e:
            self.emit("error", _("Failed to unload device #%02d:\n%s") % (self.number, e))

    def load_device(self, filenames, params):
        try:
            self.proxy.DeviceLoad(self.number, filenames, params)
        except GLib.Error as e:
            if "net.sf.cdemu.CDEmuDaemon.errorMirage.EncryptedImage" in str(e):
                # Need password, raise proper exception...
                raise CDEmuNeedPassword()
            else:
                self.emit(
                    "error",
                    _("Failed to load image %s to device #%02d:\n%s") %
                    (";".join(filenames), self.number, e),
                )

    def create_blank_disc(self, filename, params):
        try:
            self.proxy.DeviceCreateBlank(self.number, filename, params)
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to create blank disc on device #%02d:\n%s") %
                (self.number, e),
            )

    def set_device_id(self, value):
        if self.device_id == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "device-id", GLib.Variant('(ssss)', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set device ID for device #%02d to %s:\n%s") %
                (self.number, value, e),
            )

    def set_dpm_emulation(self, value):
        if self.dpm_emulation == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "dpm-emulation", GLib.Variant('b', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set DPM emulation for device #%02d to %i:\n%s") %
                (self.number, value, e),
            )

    def set_tr_emulation(self, value):
        if self.tr_emulation == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "tr-emulation", GLib.Variant('b', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set TR emulation for device #%02d to %i:\n%s") %
                (self.number, value, e),
            )

    def set_bad_sector_emulation(self, value):
        if self.bad_sector_emulation == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "bad-sector-emulation", GLib.Variant('b', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set bad sector emulation for device #%02d to %i:\n%s") %
                (self.number, value, e),
            )

    def set_dvd_report_css(self, value):
        if self.dvd_report_css == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "dvd-report-css", GLib.Variant('b', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set DVD report CSS/CPPM for device #%02d to %i:\n%s") %
                (self.number, value, e),
            )

    def set_daemon_debug_mask(self, value):
        if self.daemon_debug_mask == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "daemon-debug-mask", GLib.Variant('i', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set daemon debug mask for device #%02d to 0x%X:\n%s") %
                (self.number, value, e),
            )

    def set_library_debug_mask(self, value):
        if self.library_debug_mask == value:
            return

        try:
            self.proxy.DeviceSetOption(self.number, "library-debug-mask", GLib.Variant('i', value))
        except GLib.Error as e:
            self.emit(
                "error",
                _("Failed to set library debug mask for device #%02d to 0x%X:\n%s") %
                (self.number, value, e),
            )


GObject.type_register(CDEmuDevice)

GObject.signal_new(
    "mapping-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "status-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "device-id-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "dpm-emulation-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "tr-emulation-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "bad-sector-emulation-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "dvd-report-css-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "daemon-debug-mask-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "library-debug-mask-changed",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "error",
    CDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_STRING, ),
)


########################################################################
#             gCDEmuDeviceStatusPage: device status page               #
########################################################################
class gCDEmuDeviceStatusPage(Gtk.Grid):
    def __init__(self):
        super().__init__()
        self.set_orientation(Gtk.Orientation.VERTICAL)
        self.set_row_spacing(5)
        self.set_column_spacing(5)

        # *** Frame: status ***
        frame = Gtk.Frame.new(_("Status"))
        frame.set_label_align(0.50, 0.50)
        frame.set_border_width(2)
        self.add(frame)

        grid = Gtk.Grid.new()
        grid.set_border_width(5)
        grid.set_column_spacing(5)
        grid.set_row_spacing(5)

        frame.add(grid)

        # Loaded
        label = Gtk.Label.new(_("Loaded: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 0, 1, 1)

        label2 = Gtk.Label.new()
        label2.set_hexpand(True)
        grid.attach_next_to(label2, label, Gtk.PositionType.RIGHT, 1, 1)
        self.label_loaded = label2

        # Filename(s)
        label = Gtk.Label.new(_("File name(s): "))
        label.set_hexpand(True)
        grid.attach(label, 0, 1, 1, 1)

        label2 = Gtk.Label.new()
        label2.set_justify(Gtk.Justification.CENTER)
        label2.set_hexpand(True)
        grid.attach_next_to(label2, label, Gtk.PositionType.RIGHT, 1, 1)
        self.label_filename = label2

        # Separator
        separator = Gtk.Separator.new(Gtk.Orientation.HORIZONTAL)
        separator.set_hexpand(True)
        grid.attach(separator, 0, 2, 2, 1)

        # Load/create blank buttons
        button = Gtk.Button.new_with_label(_("Load"))
        button.set_hexpand(True)
        grid.attach(button, 0, 3, 1, 1)
        button.connect("clicked", lambda b: self.emit("load-unload-device"))
        self.button_load = button

        button = Gtk.Button.new_with_label(_("Create blank"))
        button.set_hexpand(True)
        grid.attach_next_to(button, self.button_load, Gtk.PositionType.RIGHT, 1, 1)
        button.connect("clicked", lambda b: self.emit("create-blank-disc"))
        self.button_create_blank = button

        # *** Frame: mapping ***
        frame = Gtk.Frame.new(_("Device mapping"))
        frame.set_label_align(0.50, 0.50)
        frame.set_border_width(2)
        self.add(frame)

        grid = Gtk.Grid.new()
        frame.add(grid)
        grid.set_border_width(5)
        grid.set_column_spacing(5)
        grid.set_row_spacing(2)

        # SCSI CD-ROM device
        label = Gtk.Label.new(_("SCSI CD-ROM device: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 0, 1, 1)

        label2 = Gtk.Label.new()
        label2.set_hexpand(True)
        grid.attach_next_to(label2, label, Gtk.PositionType.RIGHT, 1, 1)
        self.label_dev_sr = label2

        # SCSI generic device
        label = Gtk.Label.new(_("SCSI generic device: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 1, 1, 1)

        label2 = Gtk.Label.new()
        grid.attach_next_to(label2, label, Gtk.PositionType.RIGHT, 1, 1)
        self.label_dev_sg = label2

        # "Remove device" button
        # Center horizontally, put 5 pixels above the bottom (and make
        # sure it expands vertically)
        button = Gtk.Button.new_with_label(_("Remove device"))
        self.add(button)
        button.set_vexpand(True)
        button.set_valign(Gtk.Align.END)
        button.set_halign(Gtk.Align.CENTER)
        button.set_margin_bottom(5)
        button.connect("clicked", lambda b: self.emit("remove-device"))
        self.button_remove = button

        self.show_all()

    def set_device_mapping(self, sg_path, sr_path):
        self.label_dev_sr.set_text(sr_path)
        self.label_dev_sg.set_text(sg_path)

    def set_status(self, loaded, filenames):
        if loaded:
            text = "\n".join(os.path.basename(filename) for filename in filenames)

            self.label_loaded.set_label(_("Yes"))
            self.label_filename.set_label(text)
            self.button_load.set_label(_("Unload"))

            self.button_create_blank.set_sensitive(False)
        else:
            self.label_loaded.set_label(_("No"))
            self.label_filename.set_label("")
            self.button_load.set_label(_("Load"))

            self.button_create_blank.set_sensitive(True)

    def set_removable(self, value):
        self.button_remove.set_sensitive(value)


GObject.type_register(gCDEmuDeviceStatusPage)

GObject.signal_new(
    "load-unload-device",
    gCDEmuDeviceStatusPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "create-blank-disc",
    gCDEmuDeviceStatusPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)

GObject.signal_new(
    "remove-device",
    gCDEmuDeviceStatusPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)


########################################################################
#            gCDEmuDeviceOptionsPage: device options page              #
########################################################################
class gCDEmuDeviceOptionsPage(Gtk.Grid):
    def __init__(self):
        super().__init__()
        self.set_orientation(Gtk.Orientation.VERTICAL)
        self.set_row_spacing(5)

        # *** Device ID ***
        # Device ID
        frame = Gtk.Frame.new(_("Device ID"))
        frame.set_label_align(0.50, 0.50)
        frame.set_border_width(2)
        self.add(frame)

        grid = Gtk.Grid.new()
        grid.set_border_width(5)
        grid.set_column_spacing(5)
        grid.set_row_spacing(5)

        frame.add(grid)

        # Vendor ID
        label = Gtk.Label.new(_("Vendor ID: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 0, 1, 1)

        entry = Gtk.Entry.new()
        entry.set_max_length(8)
        entry.set_hexpand(True)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        self.entry_vendor_id = entry

        # Product ID
        label = Gtk.Label.new(_("Product ID: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 1, 1, 1)

        entry = Gtk.Entry.new()
        entry.set_max_length(16)
        entry.set_hexpand(True)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        self.entry_product_id = entry

        # Revision
        label = Gtk.Label.new(_("Revision: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 2, 1, 1)

        entry = Gtk.Entry.new()
        entry.set_max_length(4)
        entry.set_hexpand(True)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        self.entry_revision = entry

        # Vendor-specific
        label = Gtk.Label.new(_("Vendor-specific: "))
        label.set_hexpand(True)
        grid.attach(label, 0, 3, 1, 1)

        entry = Gtk.Entry.new()
        entry.set_max_length(20)
        entry.set_hexpand(True)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        self.entry_vendor_specific = entry

        # Separator
        separator = Gtk.Separator.new(Gtk.Orientation.HORIZONTAL)
        grid.attach(separator, 0, 4, 2, 1)

        # Button
        button = Gtk.Button.new_with_label(_("Set device ID"))
        button.set_hexpand(True)
        grid.attach(button, 0, 5, 2, 1)
        button.connect("clicked", self.on_set_device_id_clicked)

        # *** DPM emulation ***
        checkbutton = Gtk.CheckButton.new_with_label(_("DPM emulation"))
        checkbutton.connect("toggled", lambda t: self.emit("dpm-emulation-changed", t.get_active()))
        self.add(checkbutton)
        self.checkbutton_dpm = checkbutton

        # *** Transfer rate emulation ***
        checkbutton = Gtk.CheckButton.new_with_label(_("Transfer rate emulation"))
        checkbutton.connect("toggled", lambda t: self.emit("tr-emulation-changed", t.get_active()))
        self.add(checkbutton)
        self.checkbutton_tr = checkbutton

        # *** Bad sector emulation ***
        checkbutton = Gtk.CheckButton.new_with_label(_("Bad sector emulation"))
        checkbutton.connect("toggled", lambda t: self.emit("bad-sector-emulation-changed", t.get_active()))
        self.add(checkbutton)
        self.checkbutton_bad_sector = checkbutton

        # *** DVD report CSS ***
        checkbutton = Gtk.CheckButton.new_with_label(_("DVD report CSS/CPPM"))
        checkbutton.connect("toggled", lambda t: self.emit("dvd-report-css-changed", t.get_active()))
        self.add(checkbutton)
        self.checkbutton_dvd_report_css = checkbutton

        self.show_all()

    def set_device_id(self, device_id):
        self.entry_vendor_id.set_text(device_id[0])
        self.entry_product_id.set_text(device_id[1])
        self.entry_revision.set_text(device_id[2])
        self.entry_vendor_specific.set_text(device_id[3])

    def on_set_device_id_clicked(self, button):
        identity = (
            self.entry_vendor_id.get_text(),
            self.entry_product_id.get_text(),
            self.entry_revision.get_text(),
            self.entry_vendor_specific.get_text()
        )
        self.emit("device-id-changed", identity)

    def set_dpm_emulation(self, value):
        self.checkbutton_dpm.set_active(value)

    def set_tr_emulation(self, value):
        self.checkbutton_tr.set_active(value)

    def set_bad_sector_emulation(self, value):
        self.checkbutton_bad_sector.set_active(value)

    def set_dvd_report_css(self, value):
        self.checkbutton_dvd_report_css.set_active(value)


GObject.type_register(gCDEmuDeviceOptionsPage)

GObject.signal_new(
    "device-id-changed",
    gCDEmuDeviceOptionsPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_PYOBJECT, ),
)

GObject.signal_new(
    "dpm-emulation-changed",
    gCDEmuDeviceOptionsPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_BOOLEAN, ),
)

GObject.signal_new(
    "tr-emulation-changed",
    gCDEmuDeviceOptionsPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_BOOLEAN, ),
)

GObject.signal_new(
    "bad-sector-emulation-changed",
    gCDEmuDeviceOptionsPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_BOOLEAN, ),
)

GObject.signal_new(
    "dvd-report-css-changed",
    gCDEmuDeviceOptionsPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_BOOLEAN, ),
)


########################################################################
#             gCDEmuDeviceDebugMaskPage: debug mask page               #
########################################################################
class gCDEmuDeviceDebugMaskPage(Gtk.Frame):
    def __init__(self, name, masks_list):
        super().__init__()
        self.set_label_align(0.50, 0.50)

        self.set_label(name)
        self.entries = []

        grid = Gtk.Grid.new()
        grid.set_orientation(Gtk.Orientation.VERTICAL)
        grid.set_border_width(5)
        grid.set_row_spacing(2)
        self.add(grid)

        # Create checkboxes
        for mask in masks_list:
            checkbutton = Gtk.CheckButton.new_with_label(mask[0])
            checkbutton.weight = mask[1]
            checkbutton.set_hexpand(True)
            grid.add(checkbutton)

            self.entries.append(checkbutton)

        # Separator
        separator = Gtk.Separator.new(Gtk.Orientation.HORIZONTAL)
        separator.set_hexpand(True)
        separator.set_property("margin-bottom", 5)
        separator.set_property("margin-top", 5)
        grid.add(separator)

        # Button
        button = Gtk.Button.new_with_label(_("Set debug mask"))
        button.set_hexpand(True)
        grid.add(button)
        button.connect("clicked", self.on_set_debug_mask_clicked)

        self.show_all()

    def set_debug_mask(self, value):
        for entry in self.entries:
            entry.set_active(value & entry.weight)

    def on_set_debug_mask_clicked(self, button):
        # Get value
        value = 0
        for entry in self.entries:
            value |= entry.weight * entry.get_active()

        self.emit("debug-mask-changed", value)


GObject.type_register(gCDEmuDeviceDebugMaskPage)

GObject.signal_new(
    "debug-mask-changed",
    gCDEmuDeviceDebugMaskPage,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_UINT, ),
)


########################################################################
#           gCDEMuFileChooserDialog: file chooser dialog               #
########################################################################
class gCDEMuFileChooserDialog(Gtk.FileChooserDialog):
    def __init__(self, parsers, filter_streams):
        super().__init__(title=_("Open file"), action=Gtk.FileChooserAction.OPEN)
        self.add_buttons(
            _("Cancel"), Gtk.ResponseType.CANCEL,
            _("Open"), Gtk.ResponseType.ACCEPT,
        )

        self.set_select_multiple(True)
        self.set_local_only(False)

        # Set filters based on supported parser information
        filter_entry = Gtk.FileFilter.new()
        filter_entry.set_name(_("All files"))
        filter_entry.add_pattern("*")
        self.add_filter(filter_entry)

        all_images = Gtk.FileFilter.new()
        all_images.set_name(_("All image files"))
        self.add_filter(all_images)

        for info in parsers:
            for description, mime_type in info[2]:
                filter_entry = Gtk.FileFilter.new()

                filter_entry.set_name(description)
                filter_entry.add_mime_type(mime_type)

                all_images.add_mime_type(mime_type)

                self.add_filter(filter_entry)

        for info in filter_streams:
            for description, mime_type in info[3]:
                filter_entry = Gtk.FileFilter.new()

                filter_entry.set_name(description)
                filter_entry.add_mime_type(mime_type)

                all_images.add_mime_type(mime_type)

                self.add_filter(filter_entry)

        # Extra options
        widget = self.create_extra_options_widget()
        self.set_extra_widget(widget)

    def run(self):
        # Clear parameters
        self.combobox_encoding.set_active(0)
        self.entry_password.set_text("")

        # Run the dialog
        return super().run()

    def get_parameters(self):
        parameters = {}

        # Encoding
        i = self.combobox_encoding.get_active_iter()
        encoding = self.combobox_encoding.get_model().get_value(i, 1)
        if encoding:
            parameters["encoding"] = GLib.Variant("s", encoding)

        # Password
        password = self.entry_password.get_text()
        if password:
            parameters["password"] = GLib.Variant("s", password)

        return parameters

    def create_extra_options_widget(self):
        # Expander with grid
        expander = Gtk.Expander.new(_("Extra Options"))

        grid = Gtk.Grid.new()
        grid.set_column_spacing(5)
        grid.set_row_spacing(5)
        expander.add(grid)

        # *** Encoding ***
        # Label
        label = Gtk.Label.new(_("Encoding: "))
        grid.attach(label, 0, 0, 1, 1)

        # Combo box
        liststore = self.get_encodings_list()
        combobox = Gtk.ComboBox.new_with_model_and_entry(liststore)
        combobox.set_entry_text_column(0)
        grid.attach_next_to(combobox, label, Gtk.PositionType.RIGHT, 1, 1)
        self.combobox_encoding = combobox

        # Autocompletion
        completion = Gtk.EntryCompletion.new()
        completion.set_model(liststore)
        completion.set_text_column(0)
        combobox.get_child().set_completion(completion)

        # *** Password ***
        # Label
        label = Gtk.Label.new(_("Password: "))
        grid.attach(label, 0, 1, 1, 1)

        # Entry
        entry = Gtk.Entry.new()
        entry.set_visibility(False)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        self.entry_password = entry

        expander.show_all()

        return expander

    def get_encodings_list(self):
        # Get list of encodings supported by iconv (since that is what
        # GLib and consequently CDEmu/libMirage uses)
        encodings = []
        try:
            iconv_lines = subprocess.run(
                ['iconv', '--list'],
                env={'LANG': 'C'},
                stdout=subprocess.PIPE,
                stdin=subprocess.DEVNULL,
                stderr=subprocess.DEVNULL,
                text=True,
                check=True,
            ).stdout
            encodings = [
                line.strip('/').lower()
                for line in iconv_lines.splitlines()
            ]
        except Exception:
            # Probably due to iconv missing; not a big deal, we just won't
            # display encodings list
            pass

        # Create ListStore
        liststore = Gtk.ListStore.new([GObject.TYPE_STRING, GObject.TYPE_STRING])
        liststore.append(("Autodetect", ""))
        for encoding in encodings:
            liststore.append((encoding, encoding))

        return liststore


########################################################################
#               gCDEMuPasswordDialog: password dialog                  #
########################################################################
class gCDEMuPasswordDialog(Gtk.Dialog):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.set_title(_("Enter password"))
        self.add_buttons(
            _("OK"), Gtk.ResponseType.OK,
            _("Cancel"), Gtk.ResponseType.REJECT,
        )
        self.set_default_response(Gtk.ResponseType.OK)

        # Grid
        grid = Gtk.Grid.new()
        grid.set_border_width(5)
        grid.set_row_spacing(5)
        grid.set_column_spacing(5)
        self.vbox.add(grid)

        # Label
        label = Gtk.Label.new(_("The image you are trying to load is encrypted."))
        grid.attach(label, 0, 0, 2, 1)

        # Label & text entry
        label = Gtk.Label.new(_("Password: "))
        grid.attach(label, 0, 1, 1, 1)

        entry = Gtk.Entry.new()
        entry.set_hexpand(True)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        entry.set_visibility(False)
        entry.set_activates_default(True)  # Activate default action (= accept) on ENTER key
        self.entry = entry

        self.vbox.show_all()

    def get_password(self):
        return self.entry.get_text()

    def run(self):
        # Clear the password field
        self.entry.set_text("")

        # Chain to parent
        return super().run()


########################################################################
#   gCDEMuCreateBlankImageDialog: blank disc image creation dialog     #
########################################################################
class gCDEMuCreateBlankImageDialog(Gtk.Dialog):
    class ValidationError(Exception):
        pass

    def __init__(self, writers):
        super().__init__()
        self.set_title(_("Create blank disc image"))
        self.add_buttons(
            _("OK"), Gtk.ResponseType.ACCEPT,
            _("Cancel"), Gtk.ResponseType.REJECT,
        )

        self.writers = writers

        # Frame: image settings
        frame = Gtk.Frame.new(_("Image"))
        frame.set_label_align(0.50, 0.50)
        frame.set_border_width(2)
        self.vbox.add(frame)

        grid = Gtk.Grid.new()
        grid.set_border_width(5)
        grid.set_column_spacing(5)
        grid.set_row_spacing(5)
        frame.add(grid)

        # Filename
        label = Gtk.Label.new(_("Filename: "))
        grid.attach(label, 0, 0, 1, 1)

        entry = Gtk.Entry.new()
        entry.set_hexpand(True)
        grid.attach_next_to(entry, label, Gtk.PositionType.RIGHT, 1, 1)
        self.entry_filename = entry

        button = Gtk.Button.new_with_label(_("Choose"))
        grid.attach_next_to(button, entry, Gtk.PositionType.RIGHT, 1, 1)
        button.connect("clicked", lambda b: self.choose_file())

        # Medium type
        label = Gtk.Label.new(_("Medium type: "))
        grid.attach(label, 0, 1, 1, 1)

        liststore = Gtk.ListStore.new([GObject.TYPE_STRING, GObject.TYPE_STRING])
        liststore.append(("CD-R 74 min (650 MB)", "cdr74"))
        liststore.append(("CD-R 80 min (700 MB)", "cdr80"))
        liststore.append(("CD-R 90 min (800 MB)", "cdr90"))
        liststore.append(("CD-R 99 min (900 MB)", "cdr99"))
        liststore.append(("DVD+R SL", "dvd+r"))
        liststore.append(("BD-R SL", "bdr"))

        combobox = Gtk.ComboBox.new_with_model_and_entry(liststore)
        combobox.set_entry_text_column(0)
        combobox.set_active(0)
        combobox.set_hexpand(True)
        grid.attach_next_to(combobox, label, Gtk.PositionType.RIGHT, 2, 1)
        self.combobox_medium = combobox

        completion = Gtk.EntryCompletion.new()
        completion.set_model(liststore)
        completion.set_text_column(0)
        combobox.get_child().set_completion(completion)

        # Writer
        label = Gtk.Label.new(_("Writer: "))
        grid.attach(label, 0, 2, 1, 1)

        liststore = Gtk.ListStore.new([GObject.TYPE_STRING, GObject.TYPE_INT])
        for i, writer in enumerate(self.writers):
            liststore.append((writer[0], i))

        combobox = Gtk.ComboBox.new_with_model_and_entry(liststore)
        combobox.set_entry_text_column(0)
        combobox.set_hexpand(True)
        grid.attach_next_to(combobox, label, Gtk.PositionType.RIGHT, 2, 1)
        combobox.connect("changed", lambda c: self.build_writer_options())
        self.combobox_writer = combobox

        completion = Gtk.EntryCompletion.new()
        completion.set_model(liststore)
        completion.set_text_column(0)
        combobox.get_child().set_completion(completion)

        # Frame: writer options
        frame = Gtk.Frame.new(_("Writer options"))
        frame.set_label_align(0.50, 0.50)
        frame.set_border_width(2)
        self.vbox.add(frame)

        self.frame_writer = frame

        self.writer_options_ui = None
        self.writer_options_widgets = None

        self.vbox.show_all()
        self.frame_writer.hide()

    def choose_file(self):
        dialog = Gtk.FileChooserDialog(
            title=_("Choose file"),
            parent=self,
            action=Gtk.FileChooserAction.SAVE,
        )
        dialog.add_buttons(
            _("Cancel"), Gtk.ResponseType.CANCEL,
            _("Choose"), Gtk.ResponseType.ACCEPT,
        )
        dialog.set_local_only(False)
        dialog.set_do_overwrite_confirmation(True)

        if dialog.run() == Gtk.ResponseType.ACCEPT:
            self.entry_filename.set_text(dialog.get_filename())

        dialog.destroy()

    def build_writer_options(self):
        # Destroy old GUI and widgets map
        if self.writer_options_ui is not None:
            self.writer_options_ui.destroy()
            self.writer_options_ui = None

        if self.writer_options_widgets is not None:
            self.writer_options_widgets = None

        # Hide Writer frame
        self.frame_writer.hide()

        # Get selected writer
        i = self.combobox_writer.get_active_iter()
        if i is None:
            return
        writer_idx = self.combobox_writer.get_model().get_value(i, 1)
        writer = self.writers[writer_idx]

        parameter_sheet = writer[2]

        # Create GUI
        grid = Gtk.Grid.new()
        grid.set_border_width(5)
        grid.set_column_spacing(5)
        grid.set_row_spacing(5)
        self.frame_writer.add(grid)

        self.writer_options_ui = grid  # Store so we can destroy it
        self.writer_options_widgets = {}  # ID -> widget map

        row = 0

        for parameter_entry in parameter_sheet:
            parameter_id = parameter_entry[0]
            parameter_name = parameter_entry[1]
            parameter_description = parameter_entry[2]
            parameter_default = parameter_entry[3]
            parameter_enum = parameter_entry[4]

            needs_label = True

            if len(parameter_enum):
                # Enum; create a combo box
                # Store
                liststore = Gtk.ListStore.new([GObject.TYPE_STRING, GObject.TYPE_STRING])
                for enum_value in parameter_enum:
                    liststore.append((enum_value, enum_value))

                # Combo box
                widget = Gtk.ComboBox.new_with_model_and_entry(liststore)
                widget.set_entry_text_column(0)

                completion = Gtk.EntryCompletion.new()
                completion.set_model(liststore)
                completion.set_text_column(0)
                widget.get_child().set_completion(completion)

                # Default value
                for entry in liststore:
                    if entry[1] == parameter_default:
                        widget.set_active_iter(entry.iter)
                        break
            elif isinstance(parameter_default, str):
                # String; create a text entry
                widget = Gtk.Entry.new()
                # Default value
                widget.set_text(parameter_default)
            elif isinstance(parameter_default, bool):
                # Boolean; create a check button
                widget = Gtk.CheckButton.new_with_label(parameter_name)
                widget.set_tooltip_text(parameter_description)
                needs_label = False
                # Default value
                widget.set_active(parameter_default)
            elif isinstance(parameter_default, int):
                # Integer; create a pin button
                widget = Gtk.SpinButton.new()
                # Default value
                widget.set_value(parameter_default)
            else:
                continue

            if needs_label:
                label = Gtk.Label.new(f"{parameter_name:s}: ")
                label.set_tooltip_text(parameter_description)
                grid.attach(label, 0, row, 1, 1)

                widget.set_hexpand(True)
                grid.attach_next_to(widget, label, Gtk.PositionType.RIGHT, 1, 1)
            else:
                grid.attach(widget, 0, row, 2, 1)

            self.writer_options_widgets[parameter_id] = widget

            row += 1

        # Show Writer frame
        self.frame_writer.show_all()

    def get_filename(self):
        return self.entry_filename.get_text()

    def get_writer_parameters(self):
        parameters = {}

        # Writer ID
        i = self.combobox_writer.get_active_iter()
        writer_idx = self.combobox_writer.get_model().get_value(i, 1)
        parameters["writer-id"] = GLib.Variant("s", self.writers[writer_idx][0])

        # Medium type
        i = self.combobox_medium.get_active_iter()
        parameters["medium-type"] = GLib.Variant("s", self.combobox_medium.get_model().get_value(i, 1))

        # Writer parameters
        # NOTE: we do not need to wrap items() in list(), because we do
        # not modify the source dictionary during iteration
        for parameter_id, widget in self.writer_options_widgets.items():
            if isinstance(widget, Gtk.Entry):
                # Text entry
                value = GLib.Variant("s", widget.get_text())
            elif isinstance(widget, Gtk.SpinButton):
                # Spin button
                value = GLib.Variant("i", widget.get_value())
            elif isinstance(widget, Gtk.CheckButton):
                # Check button
                value = GLib.Variant("b", widget.get_active())
            elif isinstance(widget, Gtk.ComboBox):
                # Combo box
                i = widget.get_active_iter()
                value = GLib.Variant("s", widget.get_model().get_value(i, 1))
            else:
                continue

            parameters[parameter_id] = value

        return parameters

    def run(self):
        # Run in loop and validate when user clicks OK
        while True:
            # Chain to parent
            result = super().run()
            if result == Gtk.ResponseType.ACCEPT:
                # Validate
                try:
                    # Image filename must be set
                    if self.entry_filename.get_text() == "":
                        raise self.ValidationError(_("Image filename/basename not set!"))
                    # Writer must be chosen
                    if self.combobox_writer.get_active_iter() is None:
                        raise self.ValidationError(_("No image writer is chosen!"))
                    break
                except self.ValidationError as e:
                    # Show error dialog
                    message = Gtk.MessageDialog(
                        parent=self,
                        message_type=Gtk.MessageType.ERROR,
                        buttons=Gtk.ButtonsType.CLOSE,
                        text=e,
                    )
                    message.set_title(_("Invalid option"))
                    message.run()
                    message.destroy()
            else:
                break

        return result


########################################################################
#                            gCDEmuDevice                              #
########################################################################
class gCDEmuDevice(GObject.GObject):
    def connect_signal(self, obj, sig, callback):
        signal = obj.connect(sig, callback)
        self.signals.append((obj, signal))

    def __init__(self, cdemu, device_number):
        super().__init__()

        self.signals = []

        self.number = device_number
        self.device = cdemu.devices[self.number]

        # Create menu item
        self.menu_item = Gtk.MenuItem()
        self.menu_item.show()
        # Note: signal for menu item is connected in the child class,
        # because Gtk and AppIndicator versions handle different signals

        # Create file chooser dialog
        self.file_chooser = gCDEMuFileChooserDialog(
            cdemu.supported_parsers,
            cdemu.supported_filter_streams,
        )

        # Create blank image creation dialog
        self.blank_disc_creation_dialog = gCDEMuCreateBlankImageDialog(
            cdemu.supported_writers,
        )

        # Create password dialog
        self.password_dialog = gCDEMuPasswordDialog()

        # *** GUI ***
        # Create dialog
        self.dialog = Gtk.Dialog()
        self.dialog.set_title(_("Device #%02d: properties") % self.number)
        self.dialog.add_buttons(_("Close"), Gtk.ResponseType.CLOSE)
        self.dialog.set_border_width(5)
        self.dialog.vbox.set_border_width(5)
        # Hide the dialog when closed
        self.connect_signal(
            self.dialog,
            "delete-event",
            lambda w, e: w.hide() or True,
        )
        self.connect_signal(
            self.dialog,
            "response",
            lambda w, e: w.hide() or True,
        )

        # Label
        label = Gtk.Label(label="<b><big>" + (_("Device #%02d") % (self.number)) + "</big></b>")
        label.show()
        label.set_use_markup(True)
        self.dialog.vbox.add(label)

        # Notebook
        notebook = Gtk.Notebook()
        notebook.show()
        notebook.set_tab_pos(Gtk.PositionType.LEFT)
        notebook.set_border_width(5)
        self.dialog.vbox.add(notebook)

        # Page: status
        self.page_status = gCDEmuDeviceStatusPage()
        self.page_status.set_device_mapping(self.device.sg_path, self.device.sr_path)
        notebook.append_page(self.page_status, Gtk.Label(label=_("Status")))

        self.connect_signal(
            self.page_status,
            "load-unload-device",
            lambda w: self.load_unload_device(),
        )
        self.connect_signal(
            self.page_status,
            "create-blank-disc",
            lambda w: self.create_blank_disc(),
        )
        self.connect_signal(
            self.page_status,
            "remove-device",
            lambda w: cdemu.remove_device(),
        )

        # Page: options
        self.page_options = gCDEmuDeviceOptionsPage()
        notebook.append_page(self.page_options, Gtk.Label(label=_("Options")))
        self.connect_signal(
            self.page_options,
            "device-id-changed",
            lambda w, id: self.device.set_device_id(id),
        )
        self.connect_signal(
            self.page_options,
            "dpm-emulation-changed",
            lambda w, v: self.device.set_dpm_emulation(v),
        )
        self.connect_signal(
            self.page_options,
            "tr-emulation-changed",
            lambda w, v: self.device.set_tr_emulation(v),
        )
        self.connect_signal(
            self.page_options,
            "bad-sector-emulation-changed",
            lambda w, v: self.device.set_bad_sector_emulation(v),
        )
        self.connect_signal(
            self.page_options,
            "dvd-report-css-changed",
            lambda w, v: self.device.set_dvd_report_css(v),
        )

        # Page: daemon debug mask
        self.page_daemon = gCDEmuDeviceDebugMaskPage(_("Daemon debug mask"), cdemu.daemon_debug_masks)
        notebook.append_page(self.page_daemon, Gtk.Label(label=_("Daemon")))
        self.connect_signal(
            self.page_daemon,
            "debug-mask-changed",
            lambda w, v: self.device.set_daemon_debug_mask(v),
        )

        # Page: library debug mask
        self.page_library = gCDEmuDeviceDebugMaskPage(_("Library debug mask"), cdemu.library_debug_masks)
        notebook.append_page(self.page_library, Gtk.Label(label=_("Library")))
        self.connect_signal(
            self.page_library,
            "debug-mask-changed",
            lambda w, v: self.device.set_library_debug_mask(v),
        )

        # Connect device signals
        self.connect_signal(
            self.device,
            "mapping-changed",
            lambda w: self.page_status.set_device_mapping(self.device.sg_path, self.device.sr_path),
        )
        self.connect_signal(
            self.device,
            "status-changed",
            lambda w: self.update_status(True),
        )
        self.connect_signal(
            self.device,
            "device-id-changed",
            lambda w: self.update_device_id(True),
        )
        self.connect_signal(
            self.device,
            "dpm-emulation-changed",
            lambda w: self.update_dpm_emulation(True),
        )
        self.connect_signal(
            self.device,
            "tr-emulation-changed",
            lambda w: self.update_tr_emulation(True),
        )
        self.connect_signal(
            self.device,
            "bad-sector-emulation-changed",
            lambda w: self.update_bad_sector_emulation(True),
        )
        self.connect_signal(
            self.device,
            "dvd-report-css-changed",
            lambda w: self.update_dvd_report_css(True),
        )
        self.connect_signal(
            self.device,
            "daemon-debug-mask-changed",
            lambda w: self.update_daemon_debug_mask(True),
        )
        self.connect_signal(
            self.device,
            "library-debug-mask-changed",
            lambda w: self.update_library_debug_mask(True),
        )
        self.connect_signal(
            self.device,
            "error",
            lambda w, t: self.show_error(t),
        )

        # Some pages require refresh when they are shown...
        self.connect_signal(
            notebook,
            "switch-page",
            lambda n, p, pp: self.refresh_pages(),
        )
        self.connect_signal(
            self.dialog,
            "show",
            lambda w: self.refresh_pages(),
        )

        # Manually perform the initial update...
        self.update_status(False)
        self.update_device_id(False)
        self.update_dpm_emulation(False)
        self.update_tr_emulation(False)
        self.update_bad_sector_emulation(False)
        self.update_dvd_report_css(False)
        self.update_daemon_debug_mask(False)
        self.update_library_debug_mask(False)

    def cleanup(self):
        # Release reference to device
        self.device = None

        # Cleanup signal handlers
        for obj, handler in self.signals:
            obj.disconnect(handler)

        # Explicitly destroy dialogs, because they might still be running
        self.dialog.destroy()
        self.file_chooser.destroy()
        self.menu_item.destroy()
        self.password_dialog.destroy()

    def update_status(self, notify):
        # Menu item
        if self.device.loaded:
            images = os.path.basename(self.device.filenames[0])  # Make it short
            if len(self.device.filenames) > 1:
                images += ", ..."  # Indicate there's more than one file
            self.menu_item.set_label("%s #%02d: %s" % (_("Device"), self.device.number, images))
        else:
            self.menu_item.set_label("%s #%02d: %s" % (_("Device"), self.device.number, _("Empty")))

        # Status page in the device dialog
        self.page_status.set_status(self.device.loaded, self.device.filenames)

        # Notification
        if notify:
            if self.device.loaded:
                self.emit(
                    "device-notification",
                    _("Device status change"),
                    _("Device #%02d has been loaded.") % (self.device.number),
                )
            else:
                self.emit(
                    "device-notification",
                    _("Device status change"),
                    _("Device #%02d has been emptied.") % (self.device.number),
                )

    def update_device_id(self, notify):
        self.page_options.set_device_id(self.device.device_id)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its device ID changed:\n  Vendor ID: '%s'\n  Product ID: '%s'\n  Revision: '%s'\n  Vendor-specific: '%s'") %  # noqa: E501
                (
                    self.device.number,
                    self.device.device_id[0],
                    self.device.device_id[1],
                    self.device.device_id[2],
                    self.device.device_id[3],
                ),
            )

    def update_dpm_emulation(self, notify):
        self.page_options.set_dpm_emulation(self.device.dpm_emulation)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its DPM emulation option changed. New value: %s") %
                (self.device.number, self.device.dpm_emulation),
            )

    def update_tr_emulation(self, notify):
        self.page_options.set_tr_emulation(self.device.tr_emulation)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its TR emulation option changed. New value: %s") %
                (self.device.number, self.device.tr_emulation),
            )

    def update_bad_sector_emulation(self, notify):
        self.page_options.set_bad_sector_emulation(self.device.bad_sector_emulation)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its bad sector emulation option changed. New value: %s") %
                (self.device.number, self.device.bad_sector_emulation),
            )

    def update_dvd_report_css(self, notify):
        self.page_options.set_dvd_report_css(self.device.dvd_report_css)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its DVD report CSS/CPPM option changed. New value: %s") %
                (self.device.number, self.device.dvd_report_css),
            )

    def update_daemon_debug_mask(self, notify):
        self.page_daemon.set_debug_mask(self.device.daemon_debug_mask)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its daemon debug mask changed. New value: 0x%X") %
                (self.device.number, self.device.daemon_debug_mask),
            )

    def update_library_debug_mask(self, notify):
        self.page_library.set_debug_mask(self.device.library_debug_mask)
        if notify:
            self.emit(
                "device-notification",
                _("Device option change"),
                _("Device #%02d has had its library debug mask changed. New value: 0x%X") %
                (self.device.number, self.device.library_debug_mask),
            )

    def load_unload_device(self):
        if self.device.loaded:
            self.device.unload_device()
        else:
            result = self.file_chooser.run()
            self.file_chooser.hide()
            if result == Gtk.ResponseType.ACCEPT:
                # Get filename and parameters
                filenames = self.file_chooser.get_filenames()
                parameters = self.file_chooser.get_parameters()

                # Try loading
                try:
                    self.device.load_device(filenames, parameters)
                except CDEmuNeedPassword:
                    # Get password
                    result = self.password_dialog.run()
                    self.password_dialog.hide()
                    if result == Gtk.ResponseType.OK:
                        password = self.password_dialog.get_password()
                        # Try loading with overriden password
                        parameters["password"] = GLib.Variant("s", password)
                        self.device.load_device(filenames, parameters)

    def create_blank_disc(self):
        result = self.blank_disc_creation_dialog.run()
        self.blank_disc_creation_dialog.hide()
        if result == Gtk.ResponseType.ACCEPT:
            filename = self.blank_disc_creation_dialog.get_filename()
            writer_parameters = self.blank_disc_creation_dialog.get_writer_parameters()
            self.device.create_blank_disc(filename, writer_parameters)

    def refresh_pages(self):
        # Some pages have fields that can be altered without applying the
        # change. We want to reset those changes when a page is changed
        # or when the dialog is shown...
        self.update_device_id(False)
        self.update_daemon_debug_mask(False)
        self.update_library_debug_mask(False)

    def show_error(self, text):
        # Show error dialog
        message = Gtk.MessageDialog(
            parent=None,
            message_type=Gtk.MessageType.ERROR,
            buttons=Gtk.ButtonsType.CLOSE,
            text=text,
        )
        message.set_title(_("Device error"))
        message.run()
        message.destroy()

    def set_removable(self, value):
        # Forward to status page
        self.page_status.set_removable(value)


GObject.type_register(gCDEmuDevice)

GObject.signal_new(
    "device-notification",
    gCDEmuDevice,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (GObject.TYPE_STRING, GObject.TYPE_STRING, ),
)


class gCDEmuDevice_Gtk(gCDEmuDevice):
    def __init__(self, cdemu, device_number):
        super().__init__(cdemu, device_number)

        self.connect_signal(
            self.menu_item,
            "button-press-event",
            self.on_menu_item_button_press_event,
        )

    def on_menu_item_button_press_event(self, widget, event):
        if event.button == 1:
            # Left click: device dialog
            self.dialog.present()  # Make sure the window is always on top
            # Don't "run" the dialog, because we want it to be non-modal
            # self.dialog.run()
            # self.dialog.hide()
        else:
            # Right click: quick load/unload
            self.load_unload_device()

    def update_status(self, notify):
        gCDEmuDevice.update_status(self, notify)

        if self.device.loaded:
            self.menu_item.set_tooltip_text(_("Left click for Property Dialog, right click to unload."))
        else:
            self.menu_item.set_tooltip_text(_("Left click for Property Dialog, right click to load."))


class gCDEmuDevice_Indicator(gCDEmuDevice):
    def __init__(self, cdemu, device_number):
        super().__init__(cdemu, device_number)

        self.menu_item.set_tooltip_text(_("Click for Property Dialog"))

        self.connect_signal(
            self.menu_item,
            "activate",
            self.on_menu_item_activate,
        )

    def on_menu_item_activate(self, widget):
        # Left click: device dialog
        self.dialog.present()  # Make sure the window is always on top
        # Don't "run" the dialog, because we want it to be non-modal
        # self.dialog.run()
        # self.dialog.hide()


########################################################################
#                               gCDEmu                                 #
########################################################################
class gCDEmuTray(GObject.GObject):
    def load_logo(self):
        # Lookup "icon" in standard paths
        icon_name = "gcdemu"
        icon_info = Gtk.IconTheme().lookup_icon(icon_name, 0, 0)
        if icon_info is None:
            print(f"Pixmap {icon_name!r} not found in standard pixmap paths!")
            self.logo = None
            return
        logo_filename = icon_info.get_filename()
        if logo_filename is None:
            print("Failed to get icon filename; Do you have the librsvg2 package?")
            self.logo = None
            return

        # Load
        self.logo = GdkPixbuf.Pixbuf.new_from_file_at_size(logo_filename, 156, 156)

    def cleanup(self):
        # Default state: disconnected
        self.connected = False
        self.update_icon()

        self.item_new_device.set_sensitive(False)

        # Remove devices
        num_devices = len(self.devices)
        for i in reversed(range(num_devices)):
            self.remove_device(i)

    def connect_to_daemon(self):
        # Cleanup
        self.cleanup()

        # Establish connection
        self.cdemu.connect_to_bus(self.use_system_bus, self.daemon_autostart)

    def __init__(self):
        super().__init__()

        self.connected = False
        self.devices = []

        # *** Configuration ***
        self.settings = Gio.Settings.new("net.sf.cdemu.gcdemu")

        # Watch over our settings
        self.settings.connect("changed", lambda s, k: self.on_settings_changed(k))

        # Notifications
        if Notify.init(app_name):
            self.show_notifications = self.settings.get_boolean("show-notifications")
        else:
            # Disable because pynotify initialization failed
            self.show_notifications = False

        # Which bus to use
        self.use_system_bus = self.settings.get_boolean("use-system-bus")

        # Daemon autostart
        self.daemon_autostart = self.settings.get_boolean("daemon-autostart")

        # Icon policy
        self.icon_policy = self.settings.get_string("icon-policy")

        # Icon names
        self.icon_connected = self.settings.get_string("icon-connected")
        self.icon_disconnected = self.settings.get_string("icon-disconnected")

        # Load logo
        self.load_logo()

        # *** The About dialog ***
        self.about = Gtk.AboutDialog()
        self.about.set_name(app_name)
        self.about.set_version(app_version)
        self.about.set_copyright("Copyright (C) 2006-%d Rok Mandeljc" % (datetime.date.today().year))
        self.about.set_comments(_("A GUI for controlling CDEmu devices."))
        self.about.set_website("http://cdemu.sf.net")
        self.about.set_website_label(_("The CDEmu project website"))
        self.about.set_authors(["Rok Mandeljc <rok.mandeljc@gmail.com>"])
        self.about.set_artists(["Rômulo Fernandes <abra185@gmail.com>"])
        self.about.set_translator_credits(_("translator-credits"))
        self.about.set_logo(self.logo)

        # *** Menu ***
        self.menu = Gtk.Menu()

        # Devices
        item = Gtk.MenuItem.new_with_label(_("Devices"))
        item.set_sensitive(False)
        self.menu.append(item)

        item = Gtk.MenuItem.new_with_label(_("New device..."))
        item.set_sensitive(False)
        item.connect("activate", self.on_new_device_activate)
        self.menu.append(item)
        self.item_new_device = item

        # Separator
        self.menu.append(Gtk.SeparatorMenuItem())

        # Use system bus
        if False:
            item = Gtk.CheckMenuItem.new_with_mnemonic(_("Use _system bus"))
            item.set_active(self.use_system_bus)
            item.connect("toggled", self.on_use_system_bus_toggled)
            self.menu.append(item)
            self.item_use_system_bus = item

        # Show notifications
        item = Gtk.CheckMenuItem.new_with_mnemonic(_("Show _notifications"))
        item.set_active(self.show_notifications)
        item.connect("toggled", self.on_show_notifications_toggled)
        self.menu.append(item)
        self.item_show_notifications = item

        # Separator
        self.menu.append(Gtk.SeparatorMenuItem())

        # About
        item = Gtk.MenuItem.new_with_label(_("About"))
        item.connect("activate", self.on_about_activated)
        self.menu.append(item)

        # Separator
        self.menu.append(Gtk.SeparatorMenuItem())

        # Quit
        item = Gtk.MenuItem.new_with_label(_("Quit"))
        item.connect("activate", self.on_quit_activated)
        self.menu.append(item)

        self.menu.show_all()

        # *** Create the CDEmu object ***
        self.cdemu = CDEmu()
        self.cdemu.connect("daemon-started", self.on_daemon_started)
        self.cdemu.connect("daemon-stopped", self.on_daemon_stopped)
        self.cdemu.connect("connection-established", self.on_connection_established)
        self.cdemu.connect("connection-lost", self.on_connection_lost)
        self.cdemu.connect("error", lambda c, t, m: self.show_error(t, m))
        self.cdemu.connect("device-added", lambda c, d: self.on_device_added(d))
        self.cdemu.connect("device-removed", lambda c, d: self.on_device_removed(d))

    def on_settings_changed(self, key):
        if key == "use-system-bus":
            self.use_system_bus = self.settings.get_boolean(key)
            print(f"New setting: use system bus: {self.use_system_bus}")
            self.item_use_system_bus.set_active(self.use_system_bus)
            self.cdemu.connect_to_bus(self.use_system_bus, self.daemon_autostart)
        elif key == "show-notifications":
            self.show_notifications = self.settings.get_boolean(key)
            self.item_show_notifications.set_active(self.show_notifications)
            print(f"New setting: show notifications: {self.show_notifications}")
        elif key == "icon-connected":
            self.icon_connected = self.settings.get_string(key)
            self.update_icon()
        elif key == "icon-disconnected":
            self.icon_disconnected = self.settings.get_string(key)
            self.update_icon()
        elif key == "daemon-autostart":
            self.daemon_autostart = self.settings.get_boolean(key)
            print(f"New setting: daemon autostart: {self.daemon_autostart}")
        elif key == "icon-policy":
            self.icon_policy = self.settings.get_string(key)
            self.update_icon()
        else:
            print(f"Unknown setting key: {key}!")

    def on_about_activated(self, menuitem):
        self.about.run()
        self.about.hide()

    def on_quit_activated(self, menuitem):
        self.emit("quit")

    def on_use_system_bus_toggled(self, checkmenuitem):
        self.settings.set_boolean("use-system-bus", checkmenuitem.get_active())

    def on_show_notifications_toggled(self, checkmenuitem):
        self.settings.set_boolean("show-notifications", checkmenuitem.get_active())

    def on_connection_established(self, cdemu, num_devices):
        self.connected = True
        self.update_icon()

        self.item_new_device.set_sensitive(True)

        # Create devices GUI
        for i in range(num_devices):
            # Add device
            self.add_device(i)

    def on_connection_lost(self, cdemu):
        self.cleanup()

    def on_daemon_started(self, cdemu):
        self.show_notification(
            _("Daemon started"),
            _("CDEmu daemon has been started."),
            "dialog-information",
        )

    def on_daemon_stopped(self, cdemu):
        self.show_notification(
            _("Daemon stopped"),
            _("CDEmu daemon has been stopped."),
            "dialog-information",
        )

    def on_device_notification(self, device, summary, body):
        self.show_notification(
            summary,
            body,
            "dialog-information",
        )

    def show_notification(self, summary, body, icon):
        if self.show_notifications:
            notification = Notify.Notification.new(summary, body, icon)
            notification.show()

    def show_error(self, title, text):
        # Show error dialog
        message = Gtk.MessageDialog(
            parent=None,
            message_type=Gtk.MessageType.ERROR,
            buttons=Gtk.ButtonsType.CLOSE,
            text=text,
        )
        message.set_title(title)
        message.run()
        message.destroy()

    def on_new_device_activate(self, menuitem):
        self.cdemu.add_device()

    def on_device_added(self, device_number):
        self.add_device(device_number)
        self.show_notification(
            _("Device added"),
            _("Device #%02d has been created.") % device_number,
            "dialog-information",
        )

    def on_device_removed(self, device_number):
        self.remove_device(device_number)
        self.show_notification(
            _("Device removed"),
            _("Device #%02d has been removed.") % device_number,
            "dialog-information",
        )

    def add_device(self, device_number):
        # Create device dialog
        device = self.create_device(self.cdemu, device_number)
        self.devices.append(device)

        # Add device's menu item to the devices menu
        self.menu.insert(device.menu_item, 1 + device_number)
        # Connect signal
        device.connect("device-notification", self.on_device_notification)

        # Set the just-added device as "removable", and mark the one
        # before it as "non-removable"...
        self.devices[-1].set_removable(True)
        if len(self.devices) > 1:
            self.devices[-2].set_removable(False)

    def remove_device(self, device_number):
        # Remove from list
        device = self.devices.pop(device_number)
        # Cleanup the device
        device.cleanup()

        # Mark the last device in the list as "removable"
        if len(self.devices) > 0:
            self.devices[-1].set_removable(True)


GObject.type_register(gCDEmuTray)

GObject.signal_new(
    "quit",
    gCDEmuTray,
    GObject.SignalFlags.RUN_FIRST,
    None,
    (),
)


class gCDEmuTray_Gtk(gCDEmuTray):
    def __init__(self):
        super().__init__()

        # *** The status icon ***
        self.tray_icon = Gtk.StatusIcon()

        self.tray_icon.set_visible(True)
        self.tray_icon.connect('activate', self.on_activate)
        self.tray_icon.connect('popup-menu', self.on_popup_menu)

        # Update icon
        self.update_icon()

    def create_device(self, cdemu, device_number):
        return gCDEmuDevice_Gtk(cdemu, device_number)

    def on_activate(self, status_icon):
        # Popup the devices menu (simulate the 1st button click)
        self.menu.popup(
            None,
            None,
            status_icon.position_menu,
            self.tray_icon,
            1,
            Gtk.get_current_event_time(),
        )

    def on_popup_menu(self, status_icon, button, activate_time):
        # Popup the menu
        self.menu.popup(
            None,
            None,
            status_icon.position_menu,
            self.tray_icon,
            button,
            activate_time,
        )

    def update_icon(self):
        # Show/hide icon
        if self.icon_policy == "always":
            visible = True
        elif self.icon_policy == "never":
            visible = False
        elif self.icon_policy in {"when_connected", "when-connected"}:
            if self.connected:
                visible = True
            else:
                visible = False
        else:
            # Fix invalid setting...
            print(f"Unknown icon policy {self.icon_policy!r}! Using 'always' by default!")
            self.icon_policy = "always"
            visible = True
        self.tray_icon.set_visible(visible)

        # Set icon
        if self.connected:
            icon_name = self.icon_connected
            tooltip_text = _('gCDEmu - connected')
        else:
            icon_name = self.icon_disconnected
            tooltip_text = _('gCDEmu - disconnected')

        if icon_name is None:
            icon_name = "image-missing"  # Fallback icon

        self.tray_icon.set_from_icon_name(icon_name)
        self.tray_icon.set_tooltip_text(tooltip_text)


class gCDEmuTray_Indicator(gCDEmuTray):
    def __init__(self):
        super().__init__()

        # AppIndicator
        self.indicator = AppIndicator.Indicator.new(
            app_name,
            "gcdemu",
            AppIndicator.IndicatorCategory.HARDWARE,
        )
        self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
        self.indicator.set_menu(self.menu)

        # Update icon
        self.update_icon()

    def create_device(self, cdemu, device_number):
        return gCDEmuDevice_Indicator(cdemu, device_number)

    def update_icon(self):
        # Set icons
        self.indicator.set_icon_full(self.icon_connected, "Connected")
        self.indicator.set_attention_icon_full(self.icon_disconnected, "Disconnected")

        # Show/hide icon
        if self.icon_policy == "always":
            visible = True
        elif self.icon_policy == "never":
            visible = False
        elif self.icon_policy in {"when_connected", "when-connected"}:
            if self.connected:
                visible = True
            else:
                visible = False
        else:
            # Fix invalid setting...
            print(f"Unknown icon policy {self.icon_policy!r}! Using 'always' by default!")
            self.icon_policy = "always"
            visible = True

        # Set the status (change icon)
        if visible:
            if self.connected:
                self.indicator.set_status(AppIndicator.IndicatorStatus.ACTIVE)
            else:
                self.indicator.set_status(AppIndicator.IndicatorStatus.ATTENTION)
        else:
            self.indicator.set_status(AppIndicator.IndicatorStatus.PASSIVE)


########################################################################
#                                Main                                  #
########################################################################
class gCDEmuApplication(Gtk.Application):
    def __init__(self):
        super().__init__(
            application_id="net.sf.cdemu.GCDEmu",
            flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
        )

        self.arguments = None
        self.tray = None

    def do_activate(self):
        if self.tray:
            # If tray already exists, do nothing...
            return

        # Create hidden window to keep GtkApplication happy
        window = Gtk.Window(type=Gtk.WindowType.TOPLEVEL)
        window.set_title("gCDEmu")
        self.add_window(window)

        # Create tray
        if self.args.tray_mode == "gtk":
            # Gtk tray icon
            self.tray = gCDEmuTray_Gtk()
        elif self.args.tray_mode == "indicator":
            # AppIndicator
            if not have_app_indicator:
                message = Gtk.MessageDialog(
                    parent=None,
                    message_type=Gtk.MessageType.ERROR,
                    buttons=Gtk.ButtonsType.CLOSE,
                    text=_("Failed to load AppIndicator library!"),
                )
                message.set_title(_("AppIndicator not available"))
                message.run()
                message.destroy()
                sys.exit(-1)

            self.tray = gCDEmuTray_Indicator()
        else:
            # Autodetect: if AppIndicator is available, prefer it over
            # Gtk tray
            if have_app_indicator:
                print("AppIndicator tray icon mode")
                self.tray = gCDEmuTray_Indicator()
            else:
                print("Gtk tray icon mode")
                self.tray = gCDEmuTray_Gtk()

        self.tray.connect("quit", lambda o: self.quit())

        # Connect to daemon
        self.tray.connect_to_daemon()

    def do_command_line(self, args):
        # Default command-line handler
        Gtk.Application.do_command_line(self, args)

        # We parse arguments only if they are local, as it would be very
        # inconvenient for command-line parser to blow up in our face for
        # a secondary-instance (which does nothing in our case, anyway)
        # and bring down the primary instance as well. Now, in an ideal
        # world, we would take care of this in do_local_command_line(),
        # however as of GIO 2.38, overriding that one causes a segfault...
        if not args.get_is_remote():
            # Command-line parser
            parser = argparse.ArgumentParser(
                prog="gcdemu",
                formatter_class=argparse.ArgumentDefaultsHelpFormatter,
            )
            parser.add_argument(
                "--tray-mode",
                type=str,
                nargs="?",
                choices=["gtk", "indicator", "auto"],
                default="auto",
                help=_("gCDEmu tray mode"),
            )

            self.args = parser.parse_args(args.get_arguments()[1:])

            # Activate the app
            self.do_activate()

        return False


if __name__ == "__main__":
    app = gCDEmuApplication()
    signal.signal(signal.SIGINT, signal.SIG_DFL)  # Make Ctrl+C work
    status = app.run(sys.argv)
    sys.exit(status)
