Beispiel #1
0
class Application(QApplication):
    """Main Nuxeo drive application controlled by a system tray icon + menu"""

    sync_thread = None

    def __init__(self, controller, options, argv=()):
        super(Application, self).__init__(list(argv))
        self.controller = controller
        self.options = options
        self.binding_info = {}

        # Put communication channel in place for intra and inter-thread
        # communication for UI change notifications
        self.communicator = Communicator()
        self.communicator.icon.connect(self.set_icon_state)
        self.communicator.stop.connect(self.handle_stop)
        self.communicator.change.connect(self.handle_change)
        self.communicator.invalid_credentials.connect(
            self.handle_invalid_credentials)
        self.communicator.update_check.connect(
            self.refresh_update_status)

        # Timer to spin the transferring icon
        self.icon_spin_timer = QtCore.QTimer()
        self.icon_spin_timer.timeout.connect(self.spin_transferring_icon)
        self.icon_spin_count = 0

        # Application update
        self.updater = None
        self.update_status = None
        self.update_version = None
        self.restart_updated_app = False

        # This is a windowless application mostly using the system tray
        self.setQuitOnLastWindowClosed(False)
        # Current state
        self.state = 'disabled'
        # Last state before suspend
        self.last_state = 'enabled'

        self.setup_systray()

        # Application update notification
        if self.controller.is_updated():
            notify_updated(self.controller.get_version())

        # Check if actions is required, separate method so it can be override
        self.init_checks()

    def init_checks(self):
        if self.controller.is_credentials_update_required():
            # Prompt for settings if needed (performs a check for application
            # update)
            self.settings()
        else:
            # Initial check for application update (then periodic checks will
            # be done by the synchronizer thread)
            self.refresh_update_status()
            # Start long running synchronization thread
            self.start_synchronization_thread()

    def get_systray_menu(self):
        return SystrayMenu(self, self.controller.list_server_bindings())

    def refresh_update_status(self):
        # TODO: first read update site URL from local configuration
        # See https://jira.nuxeo.com/browse/NXP-14403
        server_bindings = self.controller.list_server_bindings()
        if not server_bindings:
            log.warning("Found no server binding, thus no update site URL,"
                        " can't check for application update")
        elif self.state != 'paused':
            # Let's refresh_update_info of the first server binding
            sb = server_bindings[0]
            self.controller.refresh_update_info(sb.local_folder)
            # Use server binding's update site URL as a version finder to
            # build / update the application updater.
            update_url = sb.update_url
            server_version = sb.server_version
            if update_url is None or server_version is None:
                log.warning("Update site URL or server version unavailable,"
                            " as a consequence update features won't be"
                            " available")
                return
            if self.updater is None:
                # Build application updater if it doesn't exist
                try:
                    self.updater = AppUpdater(version_finder=update_url)
                except Exception as e:
                    log.warning(e)
                    return
            else:
                # If application updater exists, simply update its version
                # finder
                self.updater.set_version_finder(update_url)
            # Set update status and update version
            self.update_status, self.update_version = (
                        self.updater.get_update_status(
                            self.controller.get_version(), server_version))
            if self.update_status == UPDATE_STATUS_UNAVAILABLE_SITE:
                # Update site unavailable
                log.warning("Update site is unavailable, as a consequence"
                            " update features won't be available")
            elif self.update_status in [UPDATE_STATUS_MISSING_INFO,
                                      UPDATE_STATUS_MISSING_VERSION]:
                # Information or version missing in update site
                log.warning("Some information or version file is missing in"
                            " the update site, as a consequence update"
                            " features won't be available")
            else:
                # Update information successfully fetched
                log.info("Fetched information from update site %s: update"
                         " status = '%s', update version = '%s'",
                         self.updater.get_update_site(), self.update_status,
                         self.update_version)
                if self._is_update_required():
                    # Current client version not compatible with server
                    # version, upgrade or downgrade needed.
                    # Let's stop synchronization thread.
                    log.info("As current client version is not compatible with"
                             " server version, an upgrade or downgrade is"
                             " needed. Synchronization thread won't start"
                             " until then.")
                    self.stop_sync_thread()
                elif (self._is_update_available()
                      and self.controller.is_auto_update()):
                    # Update available and auto-update checked, let's process
                    # update
                    log.info("An application update is available and"
                             " auto-update is checked")
                    self.action_update(auto_update=True)
                    return
                elif (self._is_update_available()
                      and not self.controller.is_auto_update()):
                    # Update available and auto-update not checked, let's just
                    # update the systray icon and menu and let the user
                    # explicitly choose to  update
                    log.info("An update is available and auto-update is not"
                             " checked, let's just update the systray icon and"
                             " menu and let the user explicitly choose to"
                             " update")
                else:
                    # Application is up-to-date
                    log.info("Application is up-to-date")
            self.state = self._get_current_active_state()
            self.update_running_icon()
            self.communicator.menu.emit()

    def _is_update_required(self):
        return self.update_status in [UPDATE_STATUS_UPGRADE_NEEDED,
                                      UPDATE_STATUS_DOWNGRADE_NEEDED]

    def _is_update_available(self):
        return self.update_status == UPDATE_STATUS_UPDATE_AVAILABLE

    def set_icon_state(self, state):
        """Execute systray icon change operations triggered by state change

        The synchronization thread can update the state info but cannot
        directly call QtGui widget methods. This should be executed by the main
        thread event loop, hence the delegation to this method that is
        triggered by a signal to allow for message passing between the 2
        threads.

        Return True of the icon has changed state.

        """
        if self.get_icon_state() == state:
            # Nothing to update
            return False
        # Handle animated transferring icon
        if state == 'transferring':
            self.icon_spin_timer.start(150)
        else:
            self.icon_spin_timer.stop()
            icon = find_icon('nuxeo_drive_systray_icon_%s_18.png' % state)
            if icon is not None:
                self._tray_icon.setIcon(QtGui.QIcon(icon))
            else:
                log.warning('Icon not found: %s', icon)
        self._icon_state = state
        log.debug('Updated icon state to: %s', state)
        return True

    def get_icon_state(self):
        return getattr(self, '_icon_state', None)

    def spin_transferring_icon(self):
        icon = find_icon('nuxeo_drive_systray_icon_transferring_%s.png'
                         % (self.icon_spin_count + 1))
        self._tray_icon.setIcon(QtGui.QIcon(icon))
        self.icon_spin_count = (self.icon_spin_count + 1) % 10

    def suspend_resume(self):
        if self.state != 'paused':
            # Suspend sync
            if self._is_sync_thread_started():
                # A sync thread is active, first update last state, current
                # state, icon and menu.
                self.last_state = self.state
                # If sync thread is asleep (waiting for next sync batch) set
                # current state to 'paused' directly, else set current state
                # to 'suspending' waiting for feedback from sync thread.
                if self.state == 'asleep':
                    self.state = 'paused'
                else:
                    self.state = 'suspending'
                self.update_running_icon()
                self.communicator.menu.emit()
                # Suspend the synchronizer thread: it will call
                # notify_sync_suspended() then wait until it gets notified by
                # a call to resume().
                self.sync_thread.suspend()
            else:
                log.debug('No active synchronization thread, suspending sync'
                          ' has no effect, keeping current state: %s',
                          self.state)
        else:
            # Update state, icon and menu
            self.state = self.last_state
            self.update_running_icon()
            self.communicator.menu.emit()
            # Resume sync
            self.sync_thread.resume()

    def action_quit(self):
        self.quit_app_after_sync_stopped = True
        self.restart_updated_app = False
        self._stop()

    def action_update(self, auto_update=False):
        updated = False
        if auto_update:
            try:
                updated = self.updater.update(self.update_version)
            except Exception as e:
                log.error(e, exc_info=True)
                log.warning("An error occurred while trying to automatically"
                            " update Nuxeo Drive to version %s, setting"
                            " 'Auto update' to False", self.update_version)
                self.controller.set_auto_update(False)
        else:
            updated = prompt_update(self.controller,
                                    self._is_update_required(),
                                    self.controller.get_version(),
                                    self.update_version, self.updater)
        if updated:
            log.info("Will quit Nuxeo Drive and restart updated version %s",
                     self.update_version)
            self.quit_app_after_sync_stopped = True
            self.restart_updated_app = True
            self._stop()

    def stop_sync_thread(self):
        self.quit_app_after_sync_stopped = False
        self._stop()

    def _stop(self):
        if self._is_sync_thread_started():
            # A sync thread is active, first update state, icon and menu
            if self.quit_app_after_sync_stopped:
                self.state = 'stopping'
                self.update_running_icon()
                self.communicator.menu.emit()
            # Ask the controller to stop: the synchronization loop will break
            # and call notify_sync_stopped() which will finally emit a signal
            # to handle_stop() to quit the application.
            self.controller.stop()
            # Notify synchronization thread in case it was suspended
            self.sync_thread.resume()
        else:
            # Quit directly
            self.handle_stop()

    @QtCore.pyqtSlot()
    def handle_stop(self):
        if self.quit_app_after_sync_stopped:
            log.info('Quitting Nuxeo Drive')
            # Close thread-local Session
            log.debug("Calling Controller.dispose() from Qt Application to"
                      " close thread-local Session")
            self.controller.dispose()
            if self.restart_updated_app:
                # Restart application by loading updated executable into
                # current process
                log.debug("Exiting Qt application")
                self.quit()

                current_version = self.updater.get_active_version()
                updated_version = self.update_version
                log.info("Current application version: %s", current_version)
                log.info("Updated application version: %s", updated_version)

                executable = sys.executable
                # TODO NXP-13818: better handle this!
                if sys.platform == 'darwin':
                    executable = executable.replace('python',
                                                    self.get_mac_app())
                log.info("Current executable is: %s", executable)
                updated_executable = executable.replace(current_version,
                                                        updated_version)
                log.info("Updated executable is: %s", updated_executable)

                args = [updated_executable]
                args.extend(sys.argv[1:])
                log.info("Opening subprocess with args: %r", args)
                subprocess.Popen(args)
            else:
                self.quit()

    def get_mac_app(self):
        return 'ndrive'

    def update_running_icon(self):
        if self.state not in ['enabled', 'update_available', 'transferring']:
            self.communicator.icon.emit(self.state)
            return
        infos = self.binding_info.values()
        if len(infos) > 0 and any(i.online for i in infos):
            self.communicator.icon.emit(self.state)
        else:
            self.communicator.icon.emit('disabled')

    def notify_change(self, doc_pair, old_state):
        self.communicator.change.emit(doc_pair, old_state)

    def handle_change(self, doc_pair, old_state):
        pass

    def notify_local_folders(self, server_bindings):
        """Cleanup unbound server bindings if any"""
        local_folders = [sb.local_folder for sb in server_bindings]
        refresh = False
        for registered_folder in self.binding_info.keys():
            if registered_folder not in local_folders:
                del self.binding_info[registered_folder]
                refresh = True
        for sb in server_bindings:
            if sb.local_folder not in self.binding_info:
                self.binding_info[sb.local_folder] = BindingInfo(sb)
                refresh = True
        if refresh:
            log.debug(u'Detected changes in the list of local folders: %s',
                      u", ".join(local_folders))
            self.update_running_icon()
            self.communicator.menu.emit()

    def get_binding_info(self, server_binding):
        local_folder = server_binding.local_folder
        if local_folder not in self.binding_info:
            self.binding_info[local_folder] = BindingInfo(server_binding)
        return self.binding_info[local_folder]

    def notify_sync_started(self):
        log.debug('Synchronization started')
        # Update state, icon and menu
        self.state = self._get_current_active_state()
        self.update_running_icon()
        self.communicator.menu.emit()

    def notify_sync_stopped(self):
        log.debug('Synchronization stopped')
        self.sync_thread = None
        # Send stop signal
        self.communicator.stop.emit()

    def notify_sync_asleep(self):
        # Update state to 'asleep' when sync thread is going to sleep
        # (waiting for next sync batch)
        self.state = 'asleep'

    def notify_sync_woken_up(self):
        # Update state to current active state when sync thread is woken up and
        # was not suspended
        if self.state != 'paused':
            self.state = self._get_current_active_state()
        else:
            self.last_state = self._get_current_active_state()

    def notify_sync_suspended(self):
        log.debug('Synchronization suspended')
        # Update state, icon and menu
        self.state = 'paused'
        self.update_running_icon()
        self.communicator.menu.emit()

    def notify_online(self, server_binding):
        info = self.get_binding_info(server_binding)
        if not info.online:
            # Mark binding as offline and update UI
            log.debug('Switching to online mode for: %s',
                      server_binding.local_folder)
            info.online = True
            self.update_running_icon()
            self.communicator.menu.emit()

    def notify_offline(self, server_binding, exception):
        info = self.get_binding_info(server_binding)
        code = getattr(exception, 'code', None)
        if code is not None:
            reason = "Server returned HTTP code %r" % code
        else:
            reason = str(exception)
        local_folder = server_binding.local_folder
        if info.online:
            # Mark binding as offline and update UI
            log.debug('Switching to offline mode (reason: %s) for: %s',
                      reason, local_folder)
            info.online = False
            self.state = 'disabled'
            self.update_running_icon()
            self.communicator.menu.emit()

        if code == 401:
            log.debug('Detected invalid credentials for: %s', local_folder)
            self.communicator.invalid_credentials.emit(local_folder)

    def notify_pending(self, server_binding, n_pending, or_more=False):
        # Update icon
        if n_pending > 0:
            self.state = 'transferring'
        else:
            self.state = self._get_current_active_state()
        self.update_running_icon()

        if server_binding is not None:
            local_folder = server_binding.local_folder
            info = self.get_binding_info(server_binding)
            if n_pending != info.n_pending:
                log.debug("%d pending operations for: %s", n_pending,
                          local_folder)
                if n_pending == 0 and info.n_pending > 0:
                    current_time = time.time()
                    log.debug("Updating last ended synchronization date"
                              " to %s for: %s",
                              time.strftime(TIME_FORMAT_PATTERN,
                                            time.localtime(current_time)),
                              local_folder)
                    server_binding.last_ended_sync_date = current_time
                    self.controller.get_session().commit()
                self.communicator.menu.emit()
            # Update pending stats
            info.n_pending = n_pending
            info.has_more_pending = or_more

            if not info.online:
                log.debug("Switching to online mode for: %s", local_folder)
                # Mark binding as online and update UI
                info.online = True
                self.update_running_icon()
                self.communicator.menu.emit()

    def notify_check_update(self):
        log.debug('Checking for application update')
        self.communicator.update_check.emit()

    def _get_current_active_state(self):
        if self._is_update_available():
            return 'update_available'
        elif self._is_update_required():
            return 'disabled'
        elif self.state == 'paused':
            return 'paused'
        else:
            return 'enabled'

    def setup_systray(self):
        self._tray_icon = QtGui.QSystemTrayIcon()
        self._tray_icon.setToolTip('Nuxeo Drive')
        self.update_running_icon()
        self._tray_icon.show()
        self.tray_icon_menu = self.get_systray_menu()
        self._tray_icon.setContextMenu(self.tray_icon_menu)
        self.communicator.menu.connect(self.update_menu)

    def update_menu(self):
        self.tray_icon_menu.update_menu(self.controller.list_server_bindings())

    @QtCore.pyqtSlot(str)
    def handle_invalid_credentials(self, local_folder):
        sb = self.controller.get_server_binding(unicode(local_folder))
        sb.invalidate_credentials()
        self.controller.get_session().commit()
        self.communicator.menu.emit()

    def settings(self):
        sb_settings = self.controller.get_server_binding_settings()
        proxy_settings = self.controller.get_proxy_settings()
        general_settings = self.controller.get_general_settings()
        version = self.controller.get_version()
        settings_accepted = prompt_settings(self.controller, sb_settings,
                                            proxy_settings, general_settings,
                                            version)
        if settings_accepted:
            # Check for application udpate
            self.refresh_update_status()
            # Start synchronization thread if needed
            self.start_synchronization_thread()
        return settings_accepted

    def start_synchronization_thread(self):
        # Make sure an application update is not required and synchronization
        # thread is not already started before actually starting it
        if (not self._is_update_required()
            and not self._is_sync_thread_started()):
            delay = getattr(self.options, 'delay', 5.0)
            max_sync_step = getattr(self.options, 'max_sync_step', 10)
            update_check_delay = getattr(self.options, 'update_check_delay',
                                         3600)
            # Controller and its database session pool are thread safe,
            # hence reuse it directly
            self.controller.synchronizer.register_frontend(self)
            self.controller.synchronizer.delay = delay
            self.controller.synchronizer.max_sync_step = max_sync_step
            self.controller.synchronizer.update_check_delay = (
                                                update_check_delay)

            self.sync_thread = SynchronizerThread(self.controller)
            log.info("Starting new synchronization thread %r",
                     self.sync_thread)
            self.sync_thread.start()
            log.info("Synchronization thread %r started", self.sync_thread)

    def _is_sync_thread_started(self):
        return self.sync_thread is not None and self.sync_thread.isAlive()

    def event(self, event):
        """Handle URL scheme events under OSX"""
        log.trace("Received Qt application event")
        if hasattr(event, 'url'):
            url = str(event.url().toString())
            log.debug("Event URL: %s", url)
            try:
                info = parse_protocol_url(url)
                log.debug("URL info: %r", info)
                if info is not None:
                    log.debug("Received nxdrive URL scheme event: %s", url)
                    if info.get('command') == 'edit':
                        # This is a quick operation, no need to fork a QThread
                        self.controller.launch_file_editor(
                            info['server_url'], info['item_id'])
            except:
                log.error("Error handling URL event: %s", url, exc_info=True)
        return super(Application, self).event(event)