示例#1
0
 def __init__(self, message, button_texts):
     super().__init__()
     self.setupUi(self)
     self.status = None
     self.dialog = None
     self.label_message.setText(message)
     for text in button_texts:
         b = Button(text)
         b.clicked_self.connect(self._on_button_clicked)
         b.setCursor(Qt.PointingHandCursor)
         self.layout_buttons.insertWidget(1, b)
示例#2
0
    def __init__(self,
                 message,
                 button_texts,
                 radio_mode=False,
                 oriented_question=False,
                 dangerous_yes=False):
        super().__init__()
        self.setupUi(self)
        self.status = None
        self.dialog = None
        self.label_message.setText(message)

        if oriented_question:
            yes_text, no_text = button_texts
            assert not radio_mode

            # Add "no" button
            b = Button(no_text)
            b.clicked_self.connect(self._on_button_clicked)
            b.setCursor(Qt.PointingHandCursor)
            b.setStyleSheet(
                "QPushButton {background-color: darkgrey;} QPushButton:hover {background-color: grey;}"
            )
            self.layout_buttons.addWidget(b)

            # Add "yes" button
            b = Button(yes_text)
            b.clicked_self.connect(self._on_button_clicked)
            b.setCursor(Qt.PointingHandCursor)
            if dangerous_yes:
                b.setStyleSheet(
                    "QPushButton {background-color: red;} QPushButton:hover {background-color: darkred;}"
                )
            self.layout_buttons.addWidget(b)

        else:
            assert not dangerous_yes
            for text in button_texts:
                b = Button(text)
                b.clicked_self.connect(self._on_button_clicked)
                b.setCursor(Qt.PointingHandCursor)
                if radio_mode:
                    self.layout_radios.addWidget(b)
                else:
                    self.layout_buttons.insertWidget(1, b)
示例#3
0
class MainWindow(QMainWindow, Ui_MainWindow):
    foreground_needed = pyqtSignal()
    new_instance_needed = pyqtSignal(object)
    systray_notification = pyqtSignal(str, str, int)

    TAB_NOTIFICATION_COLOR = QColor(46, 146, 208)
    TAB_NOT_SELECTED_COLOR = QColor(123, 132, 163)
    TAB_SELECTED_COLOR = QColor(12, 65, 159)

    def __init__(self,
                 jobs_ctx,
                 event_bus,
                 config,
                 minimize_on_close: bool = False,
                 **kwargs):
        super().__init__(**kwargs)
        self.setupUi(self)

        self.setMenuBar(None)
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.config = config
        self.minimize_on_close = minimize_on_close
        # Explain only once that the app stays in background
        self.minimize_on_close_notif_already_send = False
        self.force_close = False
        self.need_close = False
        self.event_bus.connect(CoreEvent.GUI_CONFIG_CHANGED,
                               self.on_config_updated)
        self.setWindowTitle(
            _("TEXT_PARSEC_WINDOW_TITLE_version").format(
                version=PARSEC_VERSION))
        self.foreground_needed.connect(self._on_foreground_needed)
        self.new_instance_needed.connect(self._on_new_instance_needed)
        self.tab_center.tabCloseRequested.connect(self.close_tab)

        self.menu_button = Button()
        self.menu_button.setCursor(Qt.PointingHandCursor)
        self.menu_button.setIcon(QIcon(":/icons/images/material/menu.svg"))
        self.menu_button.setIconSize(QSize(24, 24))
        self.menu_button.setText(_("ACTION_MAIN_MENU_SHOW"))
        self.menu_button.setObjectName("MenuButton")
        self.menu_button.setProperty("color", QColor(0x00, 0x92, 0xFF))
        self.menu_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD))
        self.menu_button.setStyleSheet(
            "#MenuButton {background: none; border: none; color: #0092FF;}"
            "#MenuButton:hover {color: #0070DD;}")
        self.menu_button.apply_style()
        self.menu_button.clicked.connect(self._show_menu)
        self.tab_center.setCornerWidget(self.menu_button, Qt.TopRightCorner)

        self.add_tab_button = Button()
        self.add_tab_button.setCursor(Qt.PointingHandCursor)
        self.add_tab_button.setIcon(QIcon(":/icons/images/material/add.svg"))
        self.add_tab_button.setIconSize(QSize(24, 24))
        self.add_tab_button.setProperty("color", QColor(0x00, 0x92, 0xFF))
        self.add_tab_button.setProperty("hover_color",
                                        QColor(0x00, 0x70, 0xDD))
        self.add_tab_button.setStyleSheet("background: none; border: none;")
        self.add_tab_button.apply_style()
        self.add_tab_button.clicked.connect(self._on_add_instance_clicked)
        self.tab_center.setCornerWidget(self.add_tab_button, Qt.TopLeftCorner)

        self.tab_center.currentChanged.connect(self.on_current_tab_changed)
        self._define_shortcuts()
        self.ensurePolished()

    def _define_shortcuts(self):
        self.shortcut_close = QShortcut(QKeySequence(QKeySequence.Close), self)
        self.shortcut_close.activated.connect(
            self._shortcut_proxy(self.close_current_tab))
        self.shortcut_new_tab = QShortcut(QKeySequence(QKeySequence.AddTab),
                                          self)
        self.shortcut_new_tab.activated.connect(
            self._shortcut_proxy(self._on_add_instance_clicked))
        self.shortcut_settings = QShortcut(QKeySequence(_("Ctrl+K")), self)
        self.shortcut_settings.activated.connect(
            self._shortcut_proxy(self._show_settings))
        self.shortcut_menu = QShortcut(QKeySequence(_("Alt+E")), self)
        self.shortcut_menu.activated.connect(
            self._shortcut_proxy(self._show_menu))
        self.shortcut_help = QShortcut(QKeySequence(QKeySequence.HelpContents),
                                       self)
        self.shortcut_help.activated.connect(
            self._shortcut_proxy(self._on_show_doc_clicked))
        self.shortcut_quit = QShortcut(QKeySequence(QKeySequence.Quit), self)
        self.shortcut_quit.activated.connect(
            self._shortcut_proxy(self.close_app))
        self.shortcut_create_org = QShortcut(QKeySequence(QKeySequence.New),
                                             self)
        self.shortcut_create_org.activated.connect(
            self._shortcut_proxy(self._on_create_org_clicked))
        self.shortcut_join_org = QShortcut(QKeySequence(QKeySequence.Open),
                                           self)
        self.shortcut_join_org.activated.connect(
            self._shortcut_proxy(self._on_join_org_clicked))
        shortcut = QShortcut(QKeySequence(QKeySequence.NextChild), self)
        shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(1)))
        shortcut = QShortcut(QKeySequence(QKeySequence.PreviousChild), self)
        shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(-1)))

    def _shortcut_proxy(self, funct):
        def _inner_proxy():
            if ParsecApp.has_active_modal():
                return
            funct()

        return _inner_proxy

    def _cycle_tabs(self, offset):
        def _inner_cycle_tabs():
            idx = self.tab_center.currentIndex()
            idx += offset
            if idx >= self.tab_center.count():
                idx = 0
            if idx < 0:
                idx = self.tab_center.count() - 1
            self.switch_to_tab(idx)

        return _inner_cycle_tabs

    def _toggle_add_tab_button(self):
        if self._get_login_tab_index() == -1:
            self.add_tab_button.setDisabled(False)
        else:
            self.add_tab_button.setDisabled(True)

    def resizeEvent(self, event):
        super().resizeEvent(event)
        for win in self.children():
            if win.objectName() == "GreyedDialog":
                win.resize(event.size())
                win.move(0, 0)

    def _show_menu(self):
        menu = QMenu(self)
        menu.setObjectName("MainMenu")
        action = None

        idx = self._get_login_tab_index()
        action = menu.addAction(_("ACTION_MAIN_MENU_ADD_INSTANCE"))
        action.triggered.connect(self._on_add_instance_clicked)
        action.setShortcut(self.shortcut_new_tab.key())
        action.setShortcutVisibleInContextMenu(True)

        if idx != -1:
            action.setDisabled(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_CREATE_ORGANIZATION"))
        action.triggered.connect(self._on_create_org_clicked)
        action.setShortcut(self.shortcut_create_org.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_JOIN_ORGANIZATION"))
        action.triggered.connect(self._on_join_org_clicked)
        action.setShortcut(self.shortcut_join_org.key())
        action.setShortcutVisibleInContextMenu(True)

        menu.addSeparator()

        action = menu.addAction(_("ACTION_MAIN_MENU_SETTINGS"))
        action.triggered.connect(self._show_settings)
        action.setShortcut(self.shortcut_settings.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION"))
        action.triggered.connect(self._on_show_doc_clicked)
        action.setShortcut(self.shortcut_help.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_ABOUT"))
        action.triggered.connect(self._show_about)
        action = menu.addAction(_("ACTION_MAIN_MENU_CHANGELOG"))
        action.triggered.connect(self._show_changelog)
        action = menu.addAction(_("ACTION_MAIN_MENU_LICENSE"))
        action.triggered.connect(self._show_license)
        action = menu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND"))
        action.triggered.connect(self._on_send_feedback_clicked)
        menu.addSeparator()
        action = menu.addAction(_("ACTION_MAIN_MENU_QUIT_PARSEC"))
        action.triggered.connect(self.close_app)
        action.setShortcut(self.shortcut_quit.key())
        action.setShortcutVisibleInContextMenu(True)

        pos = self.menu_button.pos()
        pos.setY(pos.y() + self.menu_button.size().height())
        pos = self.mapToGlobal(pos)
        menu.exec_(pos)
        menu.setParent(None)

    def _show_about(self):
        w = AboutWidget()
        d = GreyedDialog(w, title="", parent=self, width=1000)
        d.exec_()

    def _show_license(self):
        w = LicenseWidget()
        d = GreyedDialog(w,
                         title=_("TEXT_LICENSE_TITLE"),
                         parent=self,
                         width=1000)
        d.exec_()

    def _show_changelog(self):
        w = ChangelogWidget()
        d = GreyedDialog(w,
                         title=_("TEXT_CHANGELOG_TITLE"),
                         parent=self,
                         width=1000)
        d.exec_()

    def _show_settings(self):
        w = SettingsWidget(self.config, self.jobs_ctx, self.event_bus)
        d = GreyedDialog(w,
                         title=_("TEXT_SETTINGS_TITLE"),
                         parent=self,
                         width=1000)
        d.exec_()

    def _on_show_doc_clicked(self):
        desktop.open_doc_link()

    def _on_send_feedback_clicked(self):
        desktop.open_feedback_link()

    def _on_add_instance_clicked(self):
        self.add_instance()

    def _on_create_org_clicked(self, addr=None):
        def _on_finished(ret):
            if ret is None:
                return
            self.reload_login_devices()
            self.try_login(ret[0], ret[1])

        CreateOrgWidget.show_modal(self.jobs_ctx,
                                   self.config,
                                   self,
                                   on_finished=_on_finished,
                                   start_addr=addr)

    def _on_join_org_clicked(self):
        url = get_text_input(
            parent=self,
            title=_("TEXT_JOIN_ORG_URL_TITLE"),
            message=_("TEXT_JOIN_ORG_URL_INSTRUCTIONS"),
            placeholder=_("TEXT_JOIN_ORG_URL_PLACEHOLDER"),
        )
        if url is None:
            return
        elif url == "":
            show_error(self, _("TEXT_JOIN_ORG_INVALID_URL"))
            return

        action_addr = None
        try:
            action_addr = BackendActionAddr.from_url(url)
        except ValueError as exc:
            show_error(self, _("TEXT_INVALID_URL"), exception=exc)
            return

        if isinstance(action_addr, BackendOrganizationBootstrapAddr):
            self._on_create_org_clicked(action_addr)
        elif isinstance(action_addr, BackendInvitationAddr):
            if action_addr.invitation_type == InvitationType.USER:
                self._on_claim_user_clicked(action_addr)
            elif action_addr.invitation_type == InvitationType.DEVICE:
                self._on_claim_device_clicked(action_addr)
            else:
                show_error(self, _("TEXT_INVALID_URL"))
                return
        else:
            show_error(self, _("TEXT_INVALID_URL"))
            return

    def _on_claim_user_clicked(self, action_addr):
        widget = None

        def _on_finished():
            nonlocal widget
            if not widget.status:
                return
            login, password = widget.status
            self.reload_login_devices()
            self.try_login(login, password)

        widget = ClaimUserWidget.show_modal(
            jobs_ctx=self.jobs_ctx,
            config=self.config,
            addr=action_addr,
            parent=self,
            on_finished=_on_finished,
        )

    def _on_claim_device_clicked(self, action_addr):
        widget = None

        def _on_finished():
            nonlocal widget
            if not widget.status:
                return
            login, password = widget.status
            self.reload_login_devices()
            self.try_login(login, password)

        widget = ClaimDeviceWidget.show_modal(
            jobs_ctx=self.jobs_ctx,
            config=self.config,
            addr=action_addr,
            parent=self,
            on_finished=_on_finished,
        )

    def try_login(self, device, password):
        idx = self._get_login_tab_index()
        tab = None
        if idx == -1:
            tab = self.add_new_tab()
        else:
            tab = self.tab_center.widget(idx)
        kf = get_key_file(self.config.config_dir, device)
        tab.login_with_password(kf, password)

    def reload_login_devices(self):
        idx = self._get_login_tab_index()
        if idx == -1:
            return
        w = self.tab_center.widget(idx)
        if not w:
            return
        w.show_login_widget()

    def on_current_tab_changed(self, index):
        for i in range(self.tab_center.tabBar().count()):
            if i != index:
                if self.tab_center.tabBar().tabTextColor(
                        i) != MainWindow.TAB_NOTIFICATION_COLOR:
                    self.tab_center.tabBar().setTabTextColor(
                        i, MainWindow.TAB_NOT_SELECTED_COLOR)
            else:
                self.tab_center.tabBar().setTabTextColor(
                    i, MainWindow.TAB_SELECTED_COLOR)

    def _on_foreground_needed(self):
        self.show_top()

    def _on_new_instance_needed(self, start_arg):
        self.add_instance(start_arg)
        self.show_top()

    def on_config_updated(self, event, **kwargs):
        self.config = self.config.evolve(**kwargs)
        save_config(self.config)
        telemetry.init(self.config)

    def show_window(self, skip_dialogs=False, invitation_link=""):
        try:
            if not self.restoreGeometry(self.config.gui_geometry):
                self.showMaximized()
        except TypeError:
            self.showMaximized()

        QCoreApplication.processEvents()

        # Used with the --diagnose option
        if skip_dialogs:
            return

        # At the very first launch
        if self.config.gui_first_launch:
            r = ask_question(
                self,
                _("TEXT_ERROR_REPORTING_TITLE"),
                _("TEXT_ERROR_REPORTING_INSTRUCTIONS"),
                [_("ACTION_ERROR_REPORTING_ACCEPT"),
                 _("ACTION_NO")],
            )

            # Acknowledge the changes
            self.event_bus.send(
                CoreEvent.GUI_CONFIG_CHANGED,
                gui_first_launch=False,
                gui_last_version=PARSEC_VERSION,
                telemetry_enabled=r == _("ACTION_ERROR_REPORTING_ACCEPT"),
            )

        # For each parsec update
        if self.config.gui_last_version and self.config.gui_last_version != PARSEC_VERSION:

            # Update from parsec `<1.14` to `>=1.14`
            if LooseVersion(self.config.gui_last_version) < "1.14":

                # Revert the acrobat reader workaround
                if (platform.system() == "Windows"
                        and win_registry.is_acrobat_reader_dc_present() and
                        not win_registry.get_acrobat_app_container_enabled()):
                    win_registry.del_acrobat_app_container_enabled()

            # Acknowledge the changes
            self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED,
                                gui_last_version=PARSEC_VERSION)

        telemetry.init(self.config)

        devices = list_available_devices(self.config.config_dir)
        if not len(devices) and not invitation_link:
            r = ask_question(
                self,
                _("TEXT_KICKSTART_PARSEC_WHAT_TO_DO_TITLE"),
                _("TEXT_KICKSTART_PARSEC_WHAT_TO_DO_INSTRUCTIONS"),
                [
                    _("ACTION_NO_DEVICE_CREATE_ORGANIZATION"),
                    _("ACTION_NO_DEVICE_JOIN_ORGANIZATION"),
                ],
                radio_mode=True,
            )
            if r == _("ACTION_NO_DEVICE_JOIN_ORGANIZATION"):
                self._on_join_org_clicked()
            elif r == _("ACTION_NO_DEVICE_CREATE_ORGANIZATION"):
                self._on_create_org_clicked()

    def show_top(self):
        self.activateWindow()
        self.setWindowState((self.windowState() & ~Qt.WindowMinimized)
                            | Qt.WindowActive)
        self.raise_()
        self.show()

    def on_tab_state_changed(self, tab, state):
        idx = self.tab_center.indexOf(tab)
        if idx == -1:
            return
        if state == "login":
            if self._get_login_tab_index() != -1:
                self.tab_center.removeTab(idx)
            else:
                self.tab_center.setTabToolTip(
                    idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN"))
                self.tab_center.setTabText(idx,
                                           _("TEXT_TAB_TITLE_LOG_IN_SCREEN"))
        elif state == "logout":
            self.tab_center.removeTab(idx)
            idx = self._get_login_tab_index()
            if idx == -1:
                self.add_instance()
            else:
                tab_widget = self.tab_center.widget(idx)
                log_widget = None if not tab_widget else tab_widget.get_login_widget(
                )
                if log_widget:
                    log_widget.reload_devices()
        elif state == "connected":
            device = tab.current_device
            tab_name = (
                f"{device.organization_id} - {device.short_user_display} - {device.device_display}"
            )
            self.tab_center.setTabToolTip(idx, tab_name)
            self.tab_center.setTabText(
                idx,
                ensure_string_size(tab_name, 150,
                                   self.tab_center.tabBar().font()))
        if self.tab_center.count() == 1:
            self.tab_center.setTabsClosable(False)
        self._toggle_add_tab_button()

    def on_tab_notification(self, widget, event):
        idx = self.tab_center.indexOf(widget)
        if idx == -1 or idx == self.tab_center.currentIndex():
            return
        if event == CoreEvent.SHARING_UPDATED:
            self.tab_center.tabBar().setTabTextColor(
                idx, MainWindow.TAB_NOTIFICATION_COLOR)

    def _get_login_tab_index(self):
        for idx in range(self.tab_center.count()):
            if self.tab_center.tabText(idx) == _(
                    "TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                return idx
        return -1

    def add_new_tab(self):
        tab = InstanceWidget(self.jobs_ctx, self.event_bus, self.config,
                             self.systray_notification)
        tab.join_organization_clicked.connect(self._on_join_org_clicked)
        tab.create_organization_clicked.connect(self._on_create_org_clicked)
        idx = self.tab_center.addTab(tab, "")
        tab.state_changed.connect(self.on_tab_state_changed)
        self.tab_center.setCurrentIndex(idx)
        if self.tab_center.count() > 1:
            self.tab_center.setTabsClosable(True)
        else:
            self.tab_center.setTabsClosable(False)
        return tab

    def switch_to_tab(self, idx):
        if not ParsecApp.has_active_modal():
            self.tab_center.setCurrentIndex(idx)

    def _find_device_from_addr(self, action_addr, display_error=False):
        device = None
        for available_device in list_available_devices(self.config.config_dir):
            if available_device.organization_id == action_addr.organization_id:
                device = available_device
                break
        if device is None:
            show_error(
                self,
                _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format(
                    organization=action_addr.organization_id),
            )
        return device

    def switch_to_login_tab(self, action_addr=None):
        idx = self._get_login_tab_index()
        if idx != -1:
            self.switch_to_tab(idx)
        else:
            tab = self.add_new_tab()
            tab.show_login_widget()
            self.on_tab_state_changed(tab, "login")
            idx = self.tab_center.count() - 1
            self.switch_to_tab(idx)

        if action_addr is not None:
            device = self._find_device_from_addr(action_addr,
                                                 display_error=True)
            instance_widget = self.tab_center.widget(idx)
            instance_widget.set_workspace_path(action_addr)
            login_w = self.tab_center.widget(idx).get_login_widget()
            login_w._on_account_clicked(device)

    def go_to_file_link(self, action_addr):
        found_org = self._find_device_from_addr(action_addr,
                                                display_error=True) is not None
        if not found_org:
            self.switch_to_login_tab()
            return

        for idx in range(self.tab_center.count()):
            if self.tab_center.tabText(idx) == _(
                    "TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                continue
            w = self.tab_center.widget(idx)
            if (not w or not w.core
                    or w.core.device.organization_addr.organization_id !=
                    action_addr.organization_id):
                continue
            user_manifest = w.core.user_fs.get_user_manifest()
            found_workspace = False
            for wk in user_manifest.workspaces:
                if not wk.role:
                    continue
                if wk.id == action_addr.workspace_id:
                    found_workspace = True
                    central_widget = w.get_central_widget()
                    try:
                        central_widget.go_to_file_link(wk.id, action_addr.path)
                        self.switch_to_tab(idx)
                    except AttributeError:
                        logger.exception("Central widget is not available")
                    return
            if not found_workspace:
                show_error(
                    self,
                    _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization").
                    format(organization=action_addr.organization_id),
                )
                return
        show_info(
            self,
            _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format(
                organization=action_addr.organization_id),
        )
        self.switch_to_login_tab(action_addr)

    def show_create_org_widget(self, action_addr):
        self.switch_to_login_tab()
        self._on_create_org_clicked(action_addr)

    def show_claim_user_widget(self, action_addr):
        self.switch_to_login_tab()
        self._on_claim_user_clicked(action_addr)

    def show_claim_device_widget(self, action_addr):
        self.switch_to_login_tab()
        self._on_claim_device_clicked(action_addr)

    def add_instance(self, start_arg: Optional[str] = None):
        action_addr = None
        if start_arg:
            try:
                action_addr = BackendActionAddr.from_url(start_arg)
            except ValueError as exc:
                show_error(self, _("TEXT_INVALID_URL"), exception=exc)

        self.show_top()
        if not action_addr:
            self.switch_to_login_tab()
        elif isinstance(action_addr, BackendOrganizationFileLinkAddr):
            self.go_to_file_link(action_addr)
        elif isinstance(action_addr, BackendOrganizationBootstrapAddr):
            self.show_create_org_widget(action_addr)
        elif (isinstance(action_addr, BackendInvitationAddr)
              and action_addr.invitation_type == InvitationType.USER):
            self.show_claim_user_widget(action_addr)
        elif (isinstance(action_addr, BackendInvitationAddr)
              and action_addr.invitation_type == InvitationType.DEVICE):
            self.show_claim_device_widget(action_addr)
        else:
            show_error(self, _("TEXT_INVALID_URL"))

    def close_current_tab(self, force=False):
        if self.tab_center.count() == 1:
            self.close_app()
        else:
            idx = self.tab_center.currentIndex()
            self.close_tab(idx, force=force)

    def close_app(self, force=False):
        self.show_top()
        self.need_close = True
        self.force_close = force
        self.close()

    def close_all_tabs(self):
        for idx in range(self.tab_center.count()):
            self.close_tab(idx, force=True)

    def close_tab(self, index, force=False):
        tab = self.tab_center.widget(index)
        if not force:
            r = _("ACTION_TAB_CLOSE_CONFIRM")
            if tab and tab.is_logged_in:
                r = ask_question(
                    self,
                    _("TEXT_TAB_CLOSE_TITLE"),
                    _("TEXT_TAB_CLOSE_INSTRUCTIONS_device").format(
                        device=
                        f"{tab.core.device.short_user_display} - {tab.core.device.device_display}"
                    ),
                    [_("ACTION_TAB_CLOSE_CONFIRM"),
                     _("ACTION_CANCEL")],
                )
            if r != _("ACTION_TAB_CLOSE_CONFIRM"):
                return
        self.tab_center.removeTab(index)
        if not tab:
            return
        tab.logout()
        self.reload_login_devices()
        if self.tab_center.count() == 1:
            self.tab_center.setTabsClosable(False)
        self._toggle_add_tab_button()

    def closeEvent(self, event):
        if self.minimize_on_close and not self.need_close:
            self.hide()
            event.ignore()
            if not self.minimize_on_close_notif_already_send:
                self.minimize_on_close_notif_already_send = True
                self.systray_notification.emit(
                    "Parsec", _("TEXT_TRAY_PARSEC_STILL_RUNNING_MESSAGE"),
                    2000)
        else:
            if self.config.gui_confirmation_before_close and not self.force_close:
                result = ask_question(
                    self if self.isVisible() else None,
                    _("TEXT_PARSEC_QUIT_TITLE"),
                    _("TEXT_PARSEC_QUIT_INSTRUCTIONS"),
                    [_("ACTION_PARSEC_QUIT_CONFIRM"),
                     _("ACTION_CANCEL")],
                )
                if result != _("ACTION_PARSEC_QUIT_CONFIRM"):
                    event.ignore()
                    self.force_close = False
                    self.need_close = False
                    return

            state = self.saveGeometry()
            self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED,
                                gui_geometry=state)
            self.close_all_tabs()
            event.accept()
            QApplication.quit()
示例#4
0
class MainWindow(QMainWindow, Ui_MainWindow):  # type: ignore[misc]
    foreground_needed = pyqtSignal()
    new_instance_needed = pyqtSignal(object)
    systray_notification = pyqtSignal(str, str, int)

    TAB_NOTIFICATION_COLOR = QColor(46, 146, 208)
    TAB_NOT_SELECTED_COLOR = QColor(123, 132, 163)
    TAB_SELECTED_COLOR = QColor(12, 65, 159)

    def __init__(
        self,
        jobs_ctx: QtToTrioJobScheduler,
        quit_callback: Callable[[], None],
        event_bus: EventBus,
        config: CoreConfig,
        minimize_on_close: bool = False,
        parent: Optional[QWidget] = None,
    ):
        super().__init__(parent=parent)
        self.setupUi(self)

        self.setMenuBar(None)
        self.jobs_ctx = jobs_ctx
        self.quit_callback = quit_callback
        self.event_bus = event_bus
        self.config = config
        self.minimize_on_close = minimize_on_close
        # Explain only once that the app stays in background
        self.minimize_on_close_notif_already_send = False
        self.force_close = False
        self.need_close = False
        self.event_bus.connect(
            CoreEvent.GUI_CONFIG_CHANGED, cast(EventCallback, self.on_config_updated)
        )
        self.setWindowTitle(_("TEXT_PARSEC_WINDOW_TITLE_version").format(version=PARSEC_VERSION))
        self.foreground_needed.connect(self._on_foreground_needed)
        self.new_instance_needed.connect(self._on_new_instance_needed)
        self.tab_center.tabCloseRequested.connect(self.close_tab)

        self.menu_button = Button()
        self.menu_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self.menu_button.setIcon(QIcon(":/icons/images/material/menu.svg"))
        self.menu_button.setIconSize(QSize(24, 24))
        self.menu_button.setText(_("ACTION_MAIN_MENU_SHOW"))
        self.menu_button.setObjectName("MenuButton")
        self.menu_button.setProperty("color", QColor(0x00, 0x92, 0xFF))
        self.menu_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD))
        self.menu_button.setStyleSheet(
            "#MenuButton {background: none; border: none; color: #0092FF;}"
            "#MenuButton:hover {color: #0070DD;}"
        )
        self.menu_button.apply_style()
        self.menu_button.clicked.connect(self._show_menu)
        self.tab_center.setCornerWidget(self.menu_button, Qt.Corner.TopRightCorner)

        self.add_tab_button = Button()
        self.add_tab_button.setCursor(Qt.CursorShape.PointingHandCursor)
        self.add_tab_button.setIcon(QIcon(":/icons/images/material/add.svg"))
        self.add_tab_button.setIconSize(QSize(24, 24))
        self.add_tab_button.setProperty("color", QColor(0x00, 0x92, 0xFF))
        self.add_tab_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD))
        self.add_tab_button.setStyleSheet("background: none; border: none;")
        self.add_tab_button.apply_style()
        self.add_tab_button.clicked.connect(self._on_add_instance_clicked)
        self.tab_center.setCornerWidget(self.add_tab_button, Qt.Corner.TopLeftCorner)

        self.tab_center.currentChanged.connect(self.on_current_tab_changed)
        self._define_shortcuts()
        self.ensurePolished()

        if sys.platform == "darwin":
            # Native menu bar on MacOS
            self._create_mac_menu_bar()

    def _define_shortcuts(self) -> None:
        self.shortcut_close = QShortcut(QKeySequence(QKeySequence.Close), self)
        self.shortcut_close.activated.connect(self._shortcut_proxy(self.close_current_tab))
        self.shortcut_new_tab = QShortcut(QKeySequence(QKeySequence.AddTab), self)
        self.shortcut_new_tab.activated.connect(self._shortcut_proxy(self._on_add_instance_clicked))
        self.shortcut_settings = QShortcut(QKeySequence(_("Ctrl+K")), self)
        self.shortcut_settings.activated.connect(self._shortcut_proxy(self._show_settings))
        self.shortcut_recovery = QShortcut(QKeySequence(_("Ctrl+I")), self)
        self.shortcut_recovery.activated.connect(self._shortcut_proxy(self._on_manage_keys))
        self.shortcut_menu = QShortcut(QKeySequence(_("Alt+E")), self)
        self.shortcut_menu.activated.connect(self._shortcut_proxy(self._show_menu))
        self.shortcut_help = QShortcut(QKeySequence(QKeySequence.HelpContents), self)
        self.shortcut_help.activated.connect(self._shortcut_proxy(self._on_show_doc_clicked))
        self.shortcut_quit = QShortcut(QKeySequence(QKeySequence.Quit), self)
        self.shortcut_quit.activated.connect(self._shortcut_proxy(self.close_app))
        self.shortcut_create_org = QShortcut(QKeySequence(QKeySequence.New), self)
        self.shortcut_create_org.activated.connect(
            self._shortcut_proxy(self._on_create_org_clicked)
        )
        self.shortcut_join_org = QShortcut(QKeySequence(QKeySequence.Open), self)
        self.shortcut_join_org.activated.connect(self._shortcut_proxy(self._on_join_org_clicked))
        shortcut = QShortcut(QKeySequence(QKeySequence.NextChild), self)
        shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(1)))
        shortcut = QShortcut(QKeySequence(QKeySequence.PreviousChild), self)
        shortcut.activated.connect(self._shortcut_proxy(self._cycle_tabs(-1)))

    def _shortcut_proxy(self, funct: Callable[[], None]) -> Callable[[], None]:
        def _inner_proxy() -> None:
            if ParsecApp.has_active_modal():
                return
            funct()

        return _inner_proxy

    def _cycle_tabs(self, offset: int) -> Callable[[], None]:
        def _inner_cycle_tabs() -> None:
            idx = self.tab_center.currentIndex()
            idx += offset
            if idx >= self.tab_center.count():
                idx = 0
            if idx < 0:
                idx = self.tab_center.count() - 1
            self.switch_to_tab(idx)

        return _inner_cycle_tabs

    def _toggle_add_tab_button(self) -> None:
        if self._get_login_tab_index() == -1:
            self.add_tab_button.setDisabled(False)
        else:
            self.add_tab_button.setDisabled(True)

    def resizeEvent(self, event: QResizeEvent) -> None:
        super().resizeEvent(event)
        for win in self.children():
            if isinstance(win, GreyedDialog):
                win.resize(event.size())
                win.move(0, 0)

    def _create_mac_menu_bar(self) -> None:
        menuBar = QMenuBar()

        fileMenu = QMenu(_("TEXT_MENU_FILE"), self)
        menuBar.addMenu(fileMenu)

        # 'settings' and 'about' are key words processed by Qt to make standard
        # MacOS submenus associated with standard key bindings. 'quit' links
        # cmd+Q to the close confirmation widget, and leaves the red X to its
        # standard behaviour depending on the `minimize_on_close` option.

        action = fileMenu.addAction("about")
        action.triggered.connect(self._show_about)
        action = fileMenu.addAction("settings")
        action.triggered.connect(self._show_settings)
        action = fileMenu.addAction("quit")
        action.triggered.connect(self.close_app)

        action = fileMenu.addAction(_("ACTION_MAIN_MENU_CREATE_ORGANIZATION"))
        action.triggered.connect(self._on_create_org_clicked)
        action.setShortcut(self.shortcut_create_org.key())
        action = fileMenu.addAction(_("ACTION_MAIN_MENU_JOIN_ORGANIZATION"))
        action.triggered.connect(self._on_join_org_clicked)
        action.setShortcut(self.shortcut_join_org.key())

        deviceMenu = QMenu(_("TEXT_MENU_DEVICE"), self)
        menuBar.addMenu(deviceMenu)

        action = deviceMenu.addAction(_("ACTION_MAIN_MENU_MANAGE_KEYS"))
        action.triggered.connect(self._on_manage_keys)
        action.setShortcut(self.shortcut_recovery.key())

        helpMenu = QMenu(_("TEXT_MENU_HELP"), self)
        menuBar.addMenu(helpMenu)

        action = helpMenu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION"))
        action.triggered.connect(self._on_show_doc_clicked)
        action = helpMenu.addAction(_("ACTION_MAIN_MENU_CHANGELOG"))
        action.triggered.connect(self._show_changelog)

        helpMenu.addSeparator()

        action = helpMenu.addAction(_("ACTION_MAIN_MENU_LICENSE"))
        action.triggered.connect(self._show_license)

        helpMenu.addSeparator()

        action = helpMenu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND"))
        action.triggered.connect(self._on_send_feedback_clicked)

        self.setMenuBar(menuBar)

    def _show_menu(self) -> None:
        menu = QMenu(self)
        menu.setObjectName("MainMenu")
        action = None

        idx = self._get_login_tab_index()
        action = menu.addAction(_("ACTION_MAIN_MENU_ADD_INSTANCE"))
        action.triggered.connect(self._on_add_instance_clicked)
        action.setShortcut(self.shortcut_new_tab.key())
        action.setShortcutVisibleInContextMenu(True)

        if idx != -1:
            action.setDisabled(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_CREATE_ORGANIZATION"))
        action.triggered.connect(self._on_create_org_clicked)
        action.setShortcut(self.shortcut_create_org.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_JOIN_ORGANIZATION"))
        action.triggered.connect(self._on_join_org_clicked)
        action.setShortcut(self.shortcut_join_org.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_MANAGE_KEYS"))
        action.triggered.connect(self._on_manage_keys)
        action.setShortcut(self.shortcut_recovery.key())
        action.setShortcutVisibleInContextMenu(True)

        menu.addSeparator()

        action = menu.addAction(_("ACTION_MAIN_MENU_SETTINGS"))
        action.triggered.connect(self._show_settings)
        action.setShortcut(self.shortcut_settings.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION"))
        action.triggered.connect(self._on_show_doc_clicked)
        action.setShortcut(self.shortcut_help.key())
        action.setShortcutVisibleInContextMenu(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_ABOUT"))
        action.triggered.connect(self._show_about)
        action = menu.addAction(_("ACTION_MAIN_MENU_CHANGELOG"))
        action.triggered.connect(self._show_changelog)
        action = menu.addAction(_("ACTION_MAIN_MENU_LICENSE"))
        action.triggered.connect(self._show_license)
        action = menu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND"))
        action.triggered.connect(self._on_send_feedback_clicked)
        menu.addSeparator()
        action = menu.addAction(_("ACTION_MAIN_MENU_QUIT_PARSEC"))
        action.triggered.connect(self.close_app)
        action.setShortcut(self.shortcut_quit.key())
        action.setShortcutVisibleInContextMenu(True)

        pos = self.menu_button.pos()
        pos.setY(pos.y() + self.menu_button.size().height())
        pos = self.mapToGlobal(pos)
        menu.exec_(pos)
        menu.setParent(None)

    def _show_about(self) -> None:
        w = AboutWidget()
        d = GreyedDialog(w, title="", parent=self, width=1000)
        d.exec_()

    def _show_license(self) -> None:
        w = LicenseWidget()
        d = GreyedDialog(w, title=_("TEXT_LICENSE_TITLE"), parent=self, width=1000)
        d.exec_()

    def _show_changelog(self) -> None:
        w = ChangelogWidget()
        d = GreyedDialog(w, title=_("TEXT_CHANGELOG_TITLE"), parent=self, width=1000)
        d.exec_()

    def _show_settings(self) -> None:
        w = SettingsWidget(self.config, self.jobs_ctx, self.event_bus)
        d = GreyedDialog(w, title=_("TEXT_SETTINGS_TITLE"), parent=self, width=1000)
        d.exec_()

    def _on_manage_keys(self) -> None:
        devices = [device for device in list_available_devices(self.config.config_dir)]
        options = [_("ACTION_CANCEL"), _("ACTION_RECOVER_DEVICE")]
        if len(devices):
            options.append(_("ACTION_CREATE_RECOVERY_DEVICE"))
        result = ask_question(
            self, _("TEXT_DEVICE_RECOVERY_TITLE"), _("TEXT_DEVICE_RECOVERY_QUESTION"), options
        )
        if result == _("ACTION_RECOVER_DEVICE"):
            DeviceRecoveryImportWidget.show_modal(
                self.config, self.jobs_ctx, parent=self, on_finished=self.reload_login_devices
            )
        elif result == _("ACTION_CREATE_RECOVERY_DEVICE"):
            DeviceRecoveryExportWidget.show_modal(self.config, self.jobs_ctx, devices, parent=self)

    def _on_show_doc_clicked(self) -> None:
        desktop.open_doc_link()

    def _on_send_feedback_clicked(self) -> None:
        desktop.open_feedback_link()

    def _on_add_instance_clicked(self) -> None:
        self.add_instance()

    def _bind_async_callback(
        self, callback: Callable[[], Awaitable[None]]
    ) -> Callable[[], Awaitable[None]]:
        """Async callbacks need to be bound to the MainWindow instance
        in order to be able to be scheduled in the job context.
        """

        async def wrapper(instance: "MainWindow") -> None:
            return await callback()

        return wrapper.__get__(self)  # type: ignore[attr-defined]

    def _on_create_org_clicked(
        self, addr: Optional[BackendOrganizationBootstrapAddr] = None
    ) -> None:
        widget: CreateOrgWidget

        @self._bind_async_callback
        async def _on_finished() -> None:
            nonlocal widget
            # It's safe to access the widget status here since this does not perform a Qt call.
            # But the underlying C++ widget might already be deleted so we should make sure not
            # not do anything Qt related with this widget.
            if widget.status is None:
                return
            self.reload_login_devices()
            device, auth_method, password = widget.status
            await self.try_login(device, auth_method, password)
            answer = ask_question(
                self,
                _("TEXT_BOOTSTRAP_ORG_SUCCESS_TITLE"),
                _("TEXT_BOOTSTRAP_ORG_SUCCESS_organization").format(
                    organization=device.organization_id
                ),
                [_("ACTION_CREATE_RECOVERY_DEVICE"), _("ACTION_NO")],
                oriented_question=True,
            )
            if answer == _("ACTION_CREATE_RECOVERY_DEVICE"):
                DeviceRecoveryExportWidget.show_modal(
                    self.config, self.jobs_ctx, [device], parent=self
                )

        widget = CreateOrgWidget.show_modal(
            self.jobs_ctx, self.config, self, on_finished=_on_finished, start_addr=addr
        )

    def _on_join_org_clicked(self) -> None:
        url = get_text_input(
            parent=self,
            title=_("TEXT_JOIN_ORG_URL_TITLE"),
            message=_("TEXT_JOIN_ORG_URL_INSTRUCTIONS"),
            placeholder=_("TEXT_JOIN_ORG_URL_PLACEHOLDER"),
            validator=validators.BackendActionAddrValidator(),
        )
        if url is None:
            return
        elif url == "":
            show_error(self, _("TEXT_JOIN_ORG_INVALID_URL"))
            return

        action_addr = None
        try:
            action_addr = BackendActionAddr.from_url(url, allow_http_redirection=True)
        except ValueError as exc:
            show_error(self, _("TEXT_INVALID_URL"), exception=exc)
            return

        if isinstance(action_addr, BackendOrganizationBootstrapAddr):
            self._on_create_org_clicked(action_addr)
        elif isinstance(action_addr, BackendInvitationAddr):
            if action_addr.invitation_type == InvitationType.USER:
                self._on_claim_user_clicked(action_addr)
            elif action_addr.invitation_type == InvitationType.DEVICE:
                self._on_claim_device_clicked(action_addr)
        elif isinstance(action_addr, BackendPkiEnrollmentAddr):
            if not is_pki_enrollment_available():
                show_error(self, _("TEXT_PKI_ENROLLMENT_NOT_AVAILABLE"))
                return
            self._on_claim_pki_clicked(action_addr)
        else:
            show_error(self, _("TEXT_INVALID_URL"))
            return

    def _on_recover_device_clicked(self) -> None:
        DeviceRecoveryImportWidget.show_modal(
            self.config, self.jobs_ctx, parent=self, on_finished=self.reload_login_devices
        )

    def _on_claim_pki_clicked(self, action_addr: BackendPkiEnrollmentAddr) -> None:
        widget: EnrollmentQueryWidget

        def _on_finished() -> None:
            nonlocal widget
            # It's safe to access the widget status here since this does not perform a Qt call.
            # But the underlying C++ widget might already be deleted so we should make sure not
            # not do anything Qt related with this widget.
            if not widget.status:
                return
            show_info(self, _("TEXT_ENROLLMENT_QUERY_SUCCEEDED"))
            self.reload_login_devices()

        widget = EnrollmentQueryWidget.show_modal(
            jobs_ctx=self.jobs_ctx,
            config=self.config,
            addr=action_addr,
            parent=self,
            on_finished=_on_finished,
        )

    def _on_claim_user_clicked(self, action_addr: BackendInvitationAddr) -> None:
        widget: ClaimUserWidget

        @self._bind_async_callback
        async def _on_finished() -> None:
            nonlocal widget
            # It's safe to access the widget status here since this does not perform a Qt call.
            # But the underlying C++ widget might already be deleted so we should make sure not
            # not do anything Qt related with this widget.
            if not widget.status:
                return
            device, auth_method, password = widget.status
            self.reload_login_devices()
            await self.try_login(device, auth_method, password)
            answer = ask_question(
                self,
                _("TEXT_CLAIM_USER_SUCCESSFUL_TITLE"),
                _("TEXT_CLAIM_USER_SUCCESSFUL"),
                [_("ACTION_CREATE_RECOVERY_DEVICE"), _("ACTION_NO")],
                oriented_question=True,
            )
            if answer == _("ACTION_CREATE_RECOVERY_DEVICE"):
                DeviceRecoveryExportWidget.show_modal(self.config, self.jobs_ctx, [device], self)

        widget = ClaimUserWidget.show_modal(
            jobs_ctx=self.jobs_ctx,
            config=self.config,
            addr=action_addr,
            parent=self,
            on_finished=_on_finished,
        )

    def _on_claim_device_clicked(self, action_addr: BackendInvitationAddr) -> None:
        widget: ClaimDeviceWidget

        @self._bind_async_callback
        async def _on_finished() -> None:
            nonlocal widget
            # It's safe to access the widget status here since this does not perform a Qt call.
            # But the underlying C++ widget might already be deleted so we should make sure not
            # not do anything Qt related with this widget.
            if not widget.status:
                return
            device, auth_method, password = widget.status
            self.reload_login_devices()
            await self.try_login(device, auth_method, password)

        widget = ClaimDeviceWidget.show_modal(
            jobs_ctx=self.jobs_ctx,
            config=self.config,
            addr=action_addr,
            parent=self,
            on_finished=_on_finished,
        )

    async def try_login(
        self, device: LocalDevice, auth_method: DeviceFileType, password: str
    ) -> None:
        idx = self._get_login_tab_index()
        if idx == -1:
            tab = self.add_new_tab()
        else:
            tab = self.tab_center.widget(idx)
        kf = get_key_file(self.config.config_dir, device)
        if auth_method == DeviceFileType.PASSWORD:
            tab.login_with_password(kf, password)
        elif auth_method == DeviceFileType.SMARTCARD:
            await tab.login_with_smartcard(kf)

    def reload_login_devices(self) -> None:
        idx = self._get_login_tab_index()
        if idx == -1:
            return
        w = self.tab_center.widget(idx)
        if not w:
            return
        w.show_login_widget()

    def on_current_tab_changed(self, index: int) -> None:
        for i in range(self.tab_center.tabBar().count()):
            if i != index:
                if self.tab_center.tabBar().tabTextColor(i) != MainWindow.TAB_NOTIFICATION_COLOR:
                    self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_NOT_SELECTED_COLOR)
            else:
                self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_SELECTED_COLOR)

    def _on_foreground_needed(self) -> None:
        self.show_top()

    def _on_new_instance_needed(self, start_arg: Optional[str]) -> None:
        self.add_instance(start_arg)
        self.show_top()

    def on_config_updated(self, event: CoreEvent, **kwargs: object) -> None:
        self.config = self.config.evolve(**kwargs)
        save_config(self.config)
        telemetry.init(self.config)

    def show_window(self, skip_dialogs: bool = False) -> None:
        try:
            if not self.restoreGeometry(self.config.gui_geometry):
                self.showMaximized()
        except TypeError:
            self.showMaximized()

        QCoreApplication.processEvents()

        # Used with the --diagnose option
        if skip_dialogs:
            return

        # At the very first launch
        if self.config.gui_first_launch:
            r = ask_question(
                self,
                _("TEXT_ENABLE_TELEMETRY_TITLE"),
                _("TEXT_ENABLE_TELEMETRY_INSTRUCTIONS"),
                [_("ACTION_ENABLE_TELEMETRY_ACCEPT"), _("ACTION_ENABLE_TELEMETRY_REFUSE")],
                oriented_question=True,
            )

            # Acknowledge the changes
            self.event_bus.send(
                CoreEvent.GUI_CONFIG_CHANGED,
                gui_first_launch=False,
                gui_last_version=PARSEC_VERSION,
                telemetry_enabled=r == _("ACTION_ENABLE_TELEMETRY_ACCEPT"),
            )

        # For each parsec update
        if self.config.gui_last_version and self.config.gui_last_version != PARSEC_VERSION:

            # Update from parsec `<1.14` to `>=1.14`
            if LooseVersion(self.config.gui_last_version) < "1.14":

                # Revert the acrobat reader workaround
                if (
                    sys.platform == "win32"
                    and win_registry.is_acrobat_reader_dc_present()
                    and not win_registry.get_acrobat_app_container_enabled()
                ):
                    win_registry.del_acrobat_app_container_enabled()

            # Acknowledge the changes
            self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED, gui_last_version=PARSEC_VERSION)

        telemetry.init(self.config)

    def show_top(self) -> None:
        self.activateWindow()
        state: Qt.WindowState = (
            self.windowState() & ~Qt.WindowState.WindowMinimized
        ) | Qt.WindowState.WindowActive
        self.setWindowState(state)
        self.raise_()
        self.show()

    def on_tab_state_changed(self, tab: InstanceWidget, state: str) -> None:
        idx = self.tab_center.indexOf(tab)
        if idx == -1:
            if state == "logout":
                self.reload_login_devices()
            return
        if state == "login":
            if self._get_login_tab_index() != -1:
                self.tab_center.removeTab(idx)
            else:
                self.tab_center.setTabToolTip(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN"))
                self.tab_center.setTabText(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN"))
        elif state == "logout":
            self.tab_center.removeTab(idx)
            idx = self._get_login_tab_index()
            if idx == -1:
                self.add_instance()
            else:
                tab_widget = self.tab_center.widget(idx)
                log_widget = None if not tab_widget else tab_widget.get_login_widget()
                if log_widget:
                    log_widget.reload_devices()
        elif state == "connected":
            device = tab.current_device
            tab_name = (
                f"{device.organization_id} - {device.short_user_display} - {device.device_display}"
            )
            self.tab_center.setTabToolTip(idx, tab_name)
            self.tab_center.setTabText(
                idx, ensure_string_size(tab_name, 150, self.tab_center.tabBar().font())
            )
        if self.tab_center.count() == 1:
            self.tab_center.setTabsClosable(False)
        self._toggle_add_tab_button()

    def on_tab_notification(self, tab: InstanceWidget, event: CoreEvent) -> None:
        idx = self.tab_center.indexOf(tab)
        if idx == -1 or idx == self.tab_center.currentIndex():
            return
        if event == CoreEvent.SHARING_UPDATED:
            self.tab_center.tabBar().setTabTextColor(idx, MainWindow.TAB_NOTIFICATION_COLOR)

    def _get_login_tab_index(self) -> int:
        for idx in range(self.tab_center.count()):
            if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                return idx
        return -1

    def add_new_tab(self) -> InstanceWidget:
        tab = InstanceWidget(self.jobs_ctx, self.event_bus, self.config, self.systray_notification)
        tab.join_organization_clicked.connect(self._on_join_org_clicked)
        tab.create_organization_clicked.connect(self._on_create_org_clicked)
        tab.recover_device_clicked.connect(self._on_recover_device_clicked)
        idx = self.tab_center.addTab(tab, "")
        tab.state_changed.connect(self.on_tab_state_changed)
        self.tab_center.setCurrentIndex(idx)
        if self.tab_center.count() > 1:
            self.tab_center.setTabsClosable(True)
        else:
            self.tab_center.setTabsClosable(False)
        return tab

    def switch_to_tab(self, idx: int) -> None:
        if not ParsecApp.has_active_modal():
            self.tab_center.setCurrentIndex(idx)

    def switch_to_login_tab(
        self, file_link_addr: Optional[BackendOrganizationFileLinkAddr] = None
    ) -> None:
        # Retrieve the login tab
        idx = self._get_login_tab_index()
        if idx != -1:
            self.switch_to_tab(idx)
        else:
            # No loging tab, create one
            tab = self.add_new_tab()
            tab.show_login_widget()
            self.on_tab_state_changed(tab, "login")
            idx = self.tab_center.count() - 1
            self.switch_to_tab(idx)

        if not file_link_addr:
            # We're done here
            return

        # Find the device corresponding to the organization in the link
        for available_device in list_available_devices(self.config.config_dir):
            if available_device.organization_id == file_link_addr.organization_id:
                break

        else:
            # Cannot reach this organization with our available devices
            show_error(
                self,
                _("TEXT_FILE_LINK_NOT_IN_ORG_organization").format(
                    organization=file_link_addr.organization_id
                ),
            )
            return

        # Pre-select the corresponding device
        login_w = self.tab_center.widget(idx).get_login_widget()
        login_w._on_account_clicked(available_device)

        # Set the path
        instance_widget = self.tab_center.widget(idx)
        instance_widget.set_workspace_path(file_link_addr)

        # Prompt the user for the need to log in first
        SnackbarManager.inform(
            _("TEXT_FILE_LINK_PLEASE_LOG_IN_organization").format(
                organization=file_link_addr.organization_id
            )
        )

    def go_to_file_link(self, addr: BackendOrganizationFileLinkAddr) -> None:
        # Try to use the file link on the already logged in cores
        for idx in range(self.tab_center.count()):
            if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                continue

            w = self.tab_center.widget(idx)
            if (
                not w
                or not w.core
                or w.core.device.organization_addr.organization_id != addr.organization_id
            ):
                continue

            central_widget = w.get_central_widget()
            if not central_widget:
                continue

            try:
                central_widget.go_to_file_link(addr)

            except GoToFileLinkBadOrganizationIDError:
                continue
            except GoToFileLinkBadWorkspaceIDError:
                # Switch tab so user understand where the error comes from
                self.switch_to_tab(idx)
                show_error(
                    self,
                    _("TEXT_FILE_LINK_WORKSPACE_NOT_FOUND_organization").format(
                        organization=addr.organization_id
                    ),
                )
                return
            except GoToFileLinkPathDecryptionError:
                # Switch tab so user understand where the error comes from
                self.switch_to_tab(idx)
                show_error(self, _("TEXT_INVALID_URL"))
                return
            else:
                self.switch_to_tab(idx)
                return

        # The file link is from an organization we'r not currently logged in
        # or we don't have any device related to
        self.switch_to_login_tab(addr)

    def show_create_org_widget(self, action_addr: BackendOrganizationBootstrapAddr) -> None:
        self.switch_to_login_tab()
        self._on_create_org_clicked(action_addr)

    def show_claim_user_widget(self, action_addr: BackendInvitationAddr) -> None:
        self.switch_to_login_tab()
        self._on_claim_user_clicked(action_addr)

    def show_claim_device_widget(self, action_addr: BackendInvitationAddr) -> None:
        self.switch_to_login_tab()
        self._on_claim_device_clicked(action_addr)

    def show_claim_pki_widget(self, action_addr: BackendPkiEnrollmentAddr) -> None:
        self.switch_to_login_tab()
        self._on_claim_pki_clicked(action_addr)

    def add_instance(self, start_arg: Optional[str] = None) -> None:
        action_addr = None
        if start_arg:
            try:
                action_addr = BackendActionAddr.from_url(start_arg, allow_http_redirection=True)
            except ValueError as exc:
                show_error(self, _("TEXT_INVALID_URL"), exception=exc)

        self.show_top()
        if not action_addr:
            self.switch_to_login_tab()
        elif isinstance(action_addr, BackendOrganizationFileLinkAddr):
            self.go_to_file_link(action_addr)
        elif isinstance(action_addr, BackendOrganizationBootstrapAddr):
            self.show_create_org_widget(action_addr)
        elif (
            isinstance(action_addr, BackendInvitationAddr)
            and action_addr.invitation_type == InvitationType.USER
        ):
            self.show_claim_user_widget(action_addr)
        elif (
            isinstance(action_addr, BackendInvitationAddr)
            and action_addr.invitation_type == InvitationType.DEVICE
        ):
            self.show_claim_device_widget(action_addr)
        elif isinstance(action_addr, BackendPkiEnrollmentAddr):
            self.show_claim_pki_widget(action_addr)
        else:
            show_error(self, _("TEXT_INVALID_URL"))

    def close_current_tab(self, force: bool = False) -> None:
        if self.tab_center.count() == 1:
            self.close_app()
        else:
            idx = self.tab_center.currentIndex()
            self.close_tab(idx, force=force)

    def close_app(self, force: bool = False) -> None:
        self.show_top()
        self.need_close = True
        self.force_close = force
        self.close()

    def close_all_tabs(self) -> None:
        for idx in range(self.tab_center.count()):
            self.close_tab(idx, force=True)

    def close_tab(self, index: int, force: bool = False) -> None:
        tab = self.tab_center.widget(index)
        if not force:
            r = _("ACTION_TAB_CLOSE_CONFIRM")
            if tab and tab.is_logged_in:
                r = ask_question(
                    self,
                    _("TEXT_TAB_CLOSE_TITLE"),
                    _("TEXT_TAB_CLOSE_INSTRUCTIONS_device").format(
                        device=f"{tab.core.device.short_user_display} - {tab.core.device.device_display}"
                    ),
                    [_("ACTION_TAB_CLOSE_CONFIRM"), _("ACTION_CANCEL")],
                )
            if r != _("ACTION_TAB_CLOSE_CONFIRM"):
                return
        self.tab_center.removeTab(index)
        if not tab:
            return
        tab.logout()
        self.reload_login_devices()
        if self.tab_center.count() == 1:
            self.tab_center.setTabsClosable(False)
        self._toggle_add_tab_button()

    def closeEvent(self, event: QCloseEvent) -> None:
        if self.minimize_on_close and not self.need_close:
            self.hide()
            event.ignore()

            # This notification is disabled on Mac since minimizing apps on
            # close is the standard behaviour on this OS.
            if not self.minimize_on_close_notif_already_send and sys.platform != "darwin":
                self.minimize_on_close_notif_already_send = True
                self.systray_notification.emit(
                    "Parsec", _("TEXT_TRAY_PARSEC_STILL_RUNNING_MESSAGE"), 2000
                )
        else:
            if self.config.gui_confirmation_before_close and not self.force_close:
                result = ask_question(
                    self if self.isVisible() else None,
                    _("TEXT_PARSEC_QUIT_TITLE"),
                    _("TEXT_PARSEC_QUIT_INSTRUCTIONS"),
                    [_("ACTION_PARSEC_QUIT_CONFIRM"), _("ACTION_CANCEL")],
                )
                if result != _("ACTION_PARSEC_QUIT_CONFIRM"):
                    event.ignore()
                    self.force_close = False
                    self.need_close = False
                    return

            state = self.saveGeometry()
            self.event_bus.send(CoreEvent.GUI_CONFIG_CHANGED, gui_geometry=state)
            self.close_all_tabs()
            self.quit_callback()
            event.ignore()
示例#5
0
class MainWindow(QMainWindow, Ui_MainWindow):
    foreground_needed = pyqtSignal()
    new_instance_needed = pyqtSignal(object)
    systray_notification = pyqtSignal(str, str)

    TAB_NOTIFICATION_COLOR = QColor(46, 146, 208)
    TAB_NOT_SELECTED_COLOR = QColor(123, 132, 163)
    TAB_SELECTED_COLOR = QColor(12, 65, 159)

    def __init__(self, jobs_ctx, event_bus, config, minimize_on_close: bool = False, **kwargs):
        super().__init__(**kwargs)
        self.setupUi(self)

        self.setMenuBar(None)
        self.jobs_ctx = jobs_ctx
        self.event_bus = event_bus
        self.config = config
        self.minimize_on_close = minimize_on_close
        self.force_close = False
        self.need_close = False
        self.event_bus.connect("gui.config.changed", self.on_config_updated)
        self.setWindowTitle(_("TEXT_PARSEC_WINDOW_TITLE_version").format(version=PARSEC_VERSION))
        self.foreground_needed.connect(self._on_foreground_needed)
        self.new_instance_needed.connect(self._on_new_instance_needed)
        self.tab_center.tabCloseRequested.connect(self.close_tab)
        self.button_send_feedback = QPushButton(_("ACTION_FEEDBACK_SEND"))
        self.button_send_feedback.clicked.connect(self._on_send_feedback_clicked)
        self.button_send_feedback.setStyleSheet("border: 0; border-radius: 0px;")
        self.tab_center.setCornerWidget(self.button_send_feedback, Qt.TopRightCorner)
        self.menu_button = Button()
        self.menu_button.setCursor(Qt.PointingHandCursor)
        self.menu_button.setIcon(QIcon(":/icons/images/material/menu.svg"))
        self.menu_button.setIconSize(QSize(24, 24))
        self.menu_button.setProperty("color", QColor(0x00, 0x92, 0xFF))
        self.menu_button.setProperty("hover_color", QColor(0x00, 0x70, 0xDD))
        self.menu_button.setStyleSheet("background-color: none; border: none;")
        self.menu_button.apply_style()
        self.menu_button.clicked.connect(self._show_menu)
        self.tab_center.setCornerWidget(self.menu_button, Qt.TopLeftCorner)
        self.tab_center.currentChanged.connect(self.on_current_tab_changed)
        self.ensurePolished()

    def resizeEvent(self, event):
        super().resizeEvent(event)
        for win in self.children():
            if win.objectName() == "GreyedDialog":
                win.resize(event.size())
                win.move(0, 0)

    def _show_menu(self):
        menu = QMenu(self)
        menu.setObjectName("MainMenu")
        action = None

        idx = self._get_login_tab_index()
        action = menu.addAction(_("ACTION_MAIN_MENU_ADD_INSTANCE"))
        action.triggered.connect(self._on_add_instance_clicked)

        if idx != -1:
            action.setDisabled(True)

        action = menu.addAction(_("ACTION_MAIN_MENU_BOOTSTRAP_ORGANIZATION"))
        action.triggered.connect(self._on_bootstrap_org_clicked)
        action = menu.addAction(_("ACTION_MAIN_MENU_CLAIM_USER"))
        action.triggered.connect(self._on_claim_user_clicked)
        action = menu.addAction(_("ACTION_MAIN_MENU_CLAIM_DEVICE"))
        action.triggered.connect(self._on_claim_device_clicked)
        menu.addSeparator()

        action = menu.addAction(_("ACTION_MAIN_MENU_SETTINGS"))
        action.triggered.connect(self._show_settings)
        action = menu.addAction(_("ACTION_MAIN_MENU_OPEN_DOCUMENTATION"))
        action.triggered.connect(self._on_show_doc_clicked)
        action = menu.addAction(_("ACTION_MAIN_MENU_ABOUT"))
        action.triggered.connect(self._show_about)
        action = menu.addAction(_("ACTION_MAIN_MENU_CHANGELOG"))
        action.triggered.connect(self._show_changelog)
        action = menu.addAction(_("ACTION_MAIN_MENU_LICENSE"))
        action.triggered.connect(self._show_license)
        action = menu.addAction(_("ACTION_MAIN_MENU_FEEDBACK_SEND"))
        action.triggered.connect(self._on_send_feedback_clicked)
        menu.addSeparator()
        action = menu.addAction(_("ACTION_MAIN_MENU_QUIT_PARSEC"))
        action.triggered.connect(self.close_app)
        pos = self.menu_button.pos()
        pos.setY(pos.y() + self.menu_button.size().height())
        pos = self.mapToGlobal(pos)
        menu.exec_(pos)

    def _show_about(self):
        w = AboutWidget()
        d = GreyedDialog(w, title="", parent=self)
        d.exec_()

    def _show_license(self):
        w = LicenseWidget()
        d = GreyedDialog(w, title=_("TEXT_LICENSE_TITLE"), parent=self)
        d.exec_()

    def _show_changelog(self):
        w = ChangelogWidget()
        d = GreyedDialog(w, title=_("TEXT_CHANGELOG_TITLE"), parent=self)
        d.exec_()

    def _show_settings(self):
        w = SettingsWidget(self.config, self.jobs_ctx, self.event_bus)
        d = GreyedDialog(w, title=_("TEXT_SETTINGS_TITLE"), parent=self)
        d.exec_()

    def _on_show_doc_clicked(self):
        desktop.open_doc_link()

    def _on_send_feedback_clicked(self):
        desktop.open_feedback_link()

    def _on_add_instance_clicked(self):
        self.add_instance()

    def _on_bootstrap_org_clicked(self, action_addr=None):
        if not action_addr:
            url = get_text_input(
                parent=self,
                title=_("TEXT_BOOTSTRAP_ORG_URL_TITLE"),
                message=_("TEXT_BOOTSTRAP_ORG_URL_INSTRUCTIONS"),
                placeholder=_("TEXT_BOOTSTRAP_ORG_URL_PLACEHOLDER"),
            )
            if url is None:
                return
            elif url == "":
                show_error(self, _("TEXT_BOOTSTRAP_ORG_INVALID_URL"))
                return

            action_addr = None
            try:
                action_addr = BackendOrganizationBootstrapAddr.from_url(url)
            except ValueError as exc:
                show_error(self, _("TEXT_BOOTSTRAP_ORG_INVALID_URL"), exception=exc)
                return
        ret = BootstrapOrganizationWidget.exec_modal(
            jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self
        )
        if ret:
            self.reload_login_devices()

    def _on_claim_user_clicked(self, action_addr=None):
        if not action_addr:
            url = get_text_input(
                parent=self,
                title=_("TEXT_CLAIM_USER_URL_TITLE"),
                message=_("TEXT_CLAIM_USER_URL_INSTRUCTIONS"),
                placeholder=_("TEXT_CLAIM_USER_URL_PLACEHOLDER"),
            )
            if url is None:
                return
            elif url == "":
                show_error(self, _("TEXT_CLAIM_USER_INVALID_URL"))
                return

            action_addr = None
            try:
                action_addr = BackendOrganizationClaimUserAddr.from_url(url)
            except ValueError as exc:
                show_error(self, _("TEXT_CLAIM_USER_INVALID_URL"), exception=exc)
                return
        ret = ClaimUserWidget.exec_modal(
            jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self
        )
        if ret:
            self.reload_login_devices()

    def _on_claim_device_clicked(self, action_addr=None):
        if not action_addr:
            url = get_text_input(
                parent=self,
                title=_("TEXT_CLAIM_DEVICE_URL_TITLE"),
                message=_("TEXT_CLAIM_DEVICE_URL_INSTRUCTIONS"),
                placeholder=_("TEXT_CLAIM_DEVICE_URL_PLACEHOLDER"),
            )
            if url is None:
                return
            elif url == "":
                show_error(self, _("TEXT_CLAIM_DEVICE_INVALID_URL"))
                return

            action_addr = None
            try:
                action_addr = BackendOrganizationClaimDeviceAddr.from_url(url)
            except ValueError as exc:
                show_error(self, _("TEXT_CLAIM_DEVICE_INVALID_URL"), exception=exc)
                return
        ret = ClaimDeviceWidget.exec_modal(
            jobs_ctx=self.jobs_ctx, config=self.config, addr=action_addr, parent=self
        )
        if ret:
            self.reload_login_devices()

    def reload_login_devices(self):
        idx = self._get_login_tab_index()
        if idx == -1:
            return
        w = self.tab_center.widget(idx)
        if not w:
            return
        item = w.layout().itemAt(0)
        if not item:
            return
        login_w = item.widget()
        if not login_w:
            return
        login_w.reload_devices()

    def on_current_tab_changed(self, index):
        for i in range(self.tab_center.tabBar().count()):
            if i != index:
                if self.tab_center.tabBar().tabTextColor(i) != MainWindow.TAB_NOTIFICATION_COLOR:
                    self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_NOT_SELECTED_COLOR)
            else:
                self.tab_center.tabBar().setTabTextColor(i, MainWindow.TAB_SELECTED_COLOR)

    def _on_foreground_needed(self):
        self.show_top()

    def _on_new_instance_needed(self, start_arg):
        self.add_instance(start_arg)
        self.show_top()

    def on_config_updated(self, event, **kwargs):
        self.config = self.config.evolve(**kwargs)
        save_config(self.config)
        telemetry.init(self.config)

    def showMaximized(self, skip_dialogs=False):
        super().showMaximized()
        QCoreApplication.processEvents()

        # Used with the --diagnose option
        if skip_dialogs:
            return

        # At the very first launch
        if self.config.gui_first_launch:
            r = ask_question(
                self,
                _("TEXT_ERROR_REPORTING_TITLE"),
                _("TEXT_ERROR_REPORTING_INSTRUCTIONS"),
                [_("ACTION_ERROR_REPORTING_ACCEPT"), _("ACTION_NO")],
            )

            # Acknowledge the changes
            self.event_bus.send(
                "gui.config.changed",
                gui_first_launch=False,
                gui_last_version=PARSEC_VERSION,
                telemetry_enabled=r == _("ACTION_ERROR_REPORTING_ACCEPT"),
            )

        # For each parsec update
        if self.config.gui_last_version != PARSEC_VERSION:

            # Ask for acrobat reader workaround
            if (
                platform.system() == "Windows"
                and win_registry.is_acrobat_reader_dc_present()
                and win_registry.get_acrobat_app_container_enabled()
            ):
                r = ask_question(
                    self,
                    _("TEXT_ACROBAT_CONTAINERS_DISABLE_TITLE"),
                    _("TEXT_ACROBAT_CONTAINERS_DISABLE_INSTRUCTIONS"),
                    [_("ACTION_ACROBAT_CONTAINERS_DISABLE_ACCEPT"), _("ACTION_NO")],
                )
                if r == _("ACTION_ACROBAT_CONTAINERS_DISABLE_ACCEPT"):
                    win_registry.set_acrobat_app_container_enabled(False)

            # Acknowledge the changes
            self.event_bus.send("gui.config.changed", gui_last_version=PARSEC_VERSION)

        telemetry.init(self.config)

    def show_top(self):
        self.show()
        self.raise_()

    def on_tab_state_changed(self, tab, state):
        idx = self.tab_center.indexOf(tab)
        if idx == -1:
            return
        if state == "login":
            if self._get_login_tab_index() != -1:
                self.tab_center.removeTab(idx)
            else:
                self.tab_center.setTabToolTip(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN"))
                self.tab_center.setTabText(idx, _("TEXT_TAB_TITLE_LOG_IN_SCREEN"))
        elif state == "connected":
            device = tab.current_device
            tab_name = f"{device.organization_id}:{device.user_id}@{device.device_name}"
            self.tab_center.setTabToolTip(idx, tab_name)
            self.tab_center.setTabText(idx, tab_name)
        if self.tab_center.count() == 1:
            self.tab_center.setTabsClosable(False)

    def on_tab_notification(self, widget, event):
        idx = self.tab_center.indexOf(widget)
        if idx == -1 or idx == self.tab_center.currentIndex():
            return
        if event in ["sharing.updated"]:
            self.tab_center.tabBar().setTabTextColor(idx, MainWindow.TAB_NOTIFICATION_COLOR)

    def _get_login_tab_index(self):
        for idx in range(self.tab_center.count()):
            if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                return idx
        return -1

    def add_new_tab(self):
        tab = InstanceWidget(self.jobs_ctx, self.event_bus, self.config)
        self.tab_center.addTab(tab, "")
        tab.state_changed.connect(self.on_tab_state_changed)
        self.tab_center.setCurrentIndex(self.tab_center.count() - 1)
        if self.tab_center.count() > 1:
            self.tab_center.setTabsClosable(True)
        else:
            self.tab_center.setTabsClosable(False)
        return tab

    def switch_to_tab(self, idx):
        if not QApplication.activeModalWidget():
            self.tab_center.setCurrentIndex(idx)

    def go_to_file_link(self, action_addr):
        for idx in range(self.tab_center.count()):
            if self.tab_center.tabText(idx) == _("TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                continue
            w = self.tab_center.widget(idx)
            if (
                not w
                or not w.core
                or w.core.device.organization_addr.organization_id != action_addr.organization_id
            ):
                continue
            user_manifest = w.core.user_fs.get_user_manifest()
            for wk in user_manifest.workspaces:
                if not wk.role:
                    continue
                if wk.id != action_addr.workspace_id:
                    continue
                central_widget = w.get_central_widget()
                try:
                    central_widget.show_mount_widget()
                    central_widget.mount_widget.show_files_widget(
                        w.core.user_fs.get_workspace(wk.id), action_addr.path, selected=True
                    )
                    self.switch_to_tab(idx)
                except AttributeError:
                    logger.exception("Central widget is not available")
                return
        show_error(
            self,
            _("TEXT_FILE_LINK_NOT_FOUND_organization").format(
                organization=action_addr.organization_id
            ),
        )

    def add_instance(self, start_arg: Optional[str] = None):
        action_addr = None
        if start_arg:
            try:
                action_addr = BackendActionAddr.from_url(start_arg)
            except ValueError as exc:
                show_error(self, _("TEXT_INVALID_URL"), exception=exc)

        idx = self._get_login_tab_index()
        if idx != -1:
            self.switch_to_tab(idx)
        else:
            tab = self.add_new_tab()
            tab.show_login_widget()
            self.on_tab_state_changed(tab, "login")
            self.switch_to_tab(self.tab_center.count() - 1)
            idx = self.tab_center.count() - 1

        if action_addr and isinstance(action_addr, BackendOrganizationFileLinkAddr):
            self.go_to_file_link(action_addr)
            return
        elif action_addr:
            if isinstance(action_addr, BackendOrganizationBootstrapAddr):
                self._on_bootstrap_org_clicked(action_addr)
            elif isinstance(action_addr, BackendOrganizationClaimUserAddr):
                self._on_claim_user_clicked(action_addr)
            elif isinstance(action_addr, BackendOrganizationClaimDeviceAddr):
                self._on_claim_device_clicked(action_addr)

    def close_app(self, force=False):
        self.need_close = True
        self.force_close = force
        self.close()

    def close_all_tabs(self):
        for idx in range(self.tab_center.count()):
            self.close_tab(idx, force=True)

    def close_tab(self, index, force=False):
        tab = self.tab_center.widget(index)
        if not force:
            r = True
            if tab and tab.is_logged_in:
                r = ask_question(
                    self,
                    _("TEXT_TAB_CLOSE_TITLE"),
                    _("TEXT_TAB_CLOSE_INSTRUCTIONS"),
                    [_("ACTION_TAB_CLOSE_CONFIRM"), _("ACTION_CANCEL")],
                )
            elif self.tab_center.tabText(index) != _("TEXT_TAB_TITLE_LOG_IN_SCREEN"):
                r = ask_question(
                    self,
                    _("TEXT_TAB_CLOSE_TITLE"),
                    _("TEXT_TAB_CLOSE_INSTRUCTIONS"),
                    [_("ACTION_TAB_CLOSE_CONFIRM"), _("ACTION_CANCEL")],
                )
            if r != _("ACTION_TAB_CLOSE_CONFIRM"):
                return
        self.tab_center.removeTab(index)
        if not tab:
            return
        tab.logout()
        if self.tab_center.count() == 1:
            self.tab_center.setTabsClosable(False)

    def closeEvent(self, event):
        if self.minimize_on_close and not self.need_close:
            self.hide()
            event.ignore()
            self.systray_notification.emit("Parsec", _("TEXT_TRAY_PARSEC_STILL_RUNNING_MESSAGE"))
        else:
            if self.config.gui_confirmation_before_close and not self.force_close:
                result = ask_question(
                    self if self.isVisible() else None,
                    _("TEXT_PARSEC_QUIT_TITLE"),
                    _("TEXT_PARSEC_QUIT_INSTRUCTIONS"),
                    [_("ACTION_PARSEC_QUIT_CONFIRM"), _("ACTION_CANCEL")],
                )
                if result != _("ACTION_PARSEC_QUIT_CONFIRM"):
                    event.ignore()
                    self.force_close = False
                    self.need_close = False
                    return

            self.close_all_tabs()
            event.accept()
            QApplication.quit()