Beispiel #1
0
    def __init__(self,
                 config_name: str,
                 app_key: str = DROPBOX_APP_KEY) -> None:

        self._app_key = app_key
        self._config_name = config_name

        self._conf = MaestralConfig(config_name)
        self._state = MaestralState(config_name)

        self._auth_flow = DropboxOAuth2FlowNoRedirect(
            self._app_key,
            use_pkce=True,
            token_access_type=self.default_token_access_type,
        )

        self._account_id = self._conf.get("account", "account_id") or None
        self._token_access_type = (self._state.get(
            "account", "token_access_type") or None)

        self.keyring = self._get_keyring_backend()

        # defer keyring access until token requested by user
        self.loaded = False
        self._access_token: Optional[str] = None
        self._refresh_token: Optional[str] = None
        self._expires_at: Optional[datetime] = None
Beispiel #2
0
    def __init__(self, config_name='maestral', run=True, log_to_stdout=False):

        self._daemon_running = True
        self._log_to_stdout = log_to_stdout

        self._config_name = config_name
        self._conf = MaestralConfig(self._config_name)
        self._state = MaestralState(self._config_name)

        self._setup_logging()

        self.client = MaestralApiClient(self._config_name)
        self.monitor = MaestralMonitor(self.client)
        self.sync = self.monitor.sync

        # periodically check for updates and refresh account info
        self.update_thread = Thread(
            name='maestral-update-check',
            target=self._periodic_refresh,
            daemon=True,
        )
        self.update_thread.start()

        if run:
            self.run()
Beispiel #3
0
def get_keyring_backend(config_name):
    """
    Choose the most secure of the available and supported keyring backends or
    use the backend specified in the config file (if valid).

    :param str config_name: The config name.
    """

    import keyring.backends

    conf = MaestralConfig(config_name)
    keyring_name = conf.get('app', 'keyring').strip()

    if IS_MACOS_BUNDLE:
        ring = keyring.backends.OS_X.Keyring()
    else:
        try:
            ring = load_keyring(keyring_name)
        except Exception:
            # get preferred keyring backends for platform
            available_rings = keyring.backend.get_all_keyring()
            supported_rings = [
                k for k in available_rings
                if isinstance(k, _supported_keyring_backends)
            ]

            ring = max(supported_rings, key=lambda x: x.priority)

    return ring
Beispiel #4
0
    def __init__(self, config_name):

        self._conf = MaestralConfig(config_name)

        self.account_id = self._conf.get("account", "account_id")
        self.access_token = ""

        self.auth_flow = None
Beispiel #5
0
    def __init__(self, config_name):

        self.keyring = get_keyring_backend(config_name)
        self._conf = MaestralConfig(config_name)

        self.account_id = self._conf.get('account', 'account_id')
        self.access_token = ""

        self.auth_flow = None
        self.oAuth2FlowResult = None
Beispiel #6
0
def pending_dropbox_folder(config_name):
    """
    Checks if a local dropbox folder has been set. This can be used by Maestral front ends
    to check if we are linked before starting a daemon.

    :param str config_name: The config to check.
    :returns: ``True`` or ``False``.
    :rtype: bool
    """
    conf = MaestralConfig(config_name)
    return not osp.isdir(conf.get('main', 'path'))
Beispiel #7
0
def select_dbx_path_dialog(config_name, allow_merge=False):
    """
    A CLI dialog to ask for a local Dropbox folder location.

    :param str config_name: The configuration to use for the default folder name.
    :param bool allow_merge: If ``True``, allows the selection of an existing folder
        without deleting it. Defaults to ``False``.
    :returns: Path given by user.
    :rtype: str
    """

    conf = MaestralConfig(config_name)

    default = osp.join(get_home_dir(), conf.get('main', 'default_dir_name'))

    while True:
        res = click.prompt('Please give Dropbox folder location',
                           default=default,
                           type=click.Path(writable=True))

        res = res.rstrip(osp.sep)

        dropbox_path = osp.expanduser(res or default)

        if osp.exists(dropbox_path):
            if allow_merge:
                choice = click.prompt(text=(
                    f'Directory "{dropbox_path}" already exists. Do you want to '
                    f'replace it or merge its content with your Dropbox?'),
                                      type=click.Choice(
                                          ['replace', 'merge', 'cancel']))
            else:
                replace = click.confirm(text=(
                    f'Directory "{dropbox_path}" already exists. Do you want to '
                    f'replace it? Its content will be lost!'), )
                choice = 'replace' if replace else 'cancel'

            if choice == 'replace':
                err = delete(dropbox_path)
                if err:
                    click.echo(
                        f'Could not write to location "{dropbox_path}". Please '
                        'make sure that you have sufficient permissions.')
                else:
                    return dropbox_path
            elif choice == 'merge':
                return dropbox_path

        else:
            return dropbox_path
Beispiel #8
0
def select_dbx_path_dialog(config_name, allow_merge=False):
    """
    A CLI dialog to ask for a local dropbox directory path.

    :param str config_name: The configuration to use for the default folder name.
    :param bool allow_merge: If ``True``, allows for selecting an existing path without
        deleting it. Defaults to ``False``.
    :returns: Path given by user.
    :rtype: str
    """

    conf = MaestralConfig(config_name)

    default = osp.join(get_home_dir(), conf.get('main', 'default_dir_name'))

    while True:
        res = click.prompt('Please give Dropbox folder location',
                           default=default,
                           type=click.Path(writable=True))

        res = res.rstrip(osp.sep)

        dropbox_path = osp.expanduser(res or default)
        old_path = osp.expanduser(conf.get('main', 'path'))

        try:
            same_path = osp.samefile(old_path, dropbox_path)
        except FileNotFoundError:
            same_path = False

        if osp.exists(dropbox_path) and not same_path:
            msg = (f'Directory "{dropbox_path}" already exist. Do you want to '
                   'overwrite it? Its content will be lost!')
            if click.confirm(msg, prompt_suffix=''):
                err = delete(dropbox_path)
                if err:
                    click.echo(
                        f'Could not write to location "{dropbox_path}". Please '
                        'make sure that you have sufficient permissions.')
                else:
                    return dropbox_path
            elif allow_merge:
                msg = 'Would you like to merge its content with your Dropbox?'
                if click.confirm(msg, prompt_suffix=''):
                    return dropbox_path
        else:
            return dropbox_path
Beispiel #9
0
def remove_configuration(config_name):
    """
    Removes all config and state files associated with the given configuration.

    :param str config_name: The configuration to remove.
    """

    MaestralConfig(config_name).cleanup()
    MaestralState(config_name).cleanup()
    index_file = get_data_path('maestral', f'{config_name}.index')
    delete(index_file)
Beispiel #10
0
def log_level(config_name: str, level_name: str):
    """Gets or sets the log level."""

    from maestral.daemon import MaestralProxy

    try:
        with MaestralProxy(config_name) as m:
            if level_name:
                m.log_level = logging._nameToLevel[level_name]
                click.echo(f'Log level set to {level_name}.')
            else:
                level_name = logging.getLevelName(m.log_level)
                click.echo(f'Log level: {level_name}')
    except Pyro5.errors.CommunicationError:
        conf = MaestralConfig(config_name)
        if level_name:
            conf.set('app', 'log_level', logging._nameToLevel[level_name])
            click.echo(f'Log level set to {level_name}.')
        else:
            level_name = logging.getLevelName(conf.get('app', 'log_level'))
            click.echo(f'Log level: {level_name}')
Beispiel #11
0
def analytics(config_name: str, yes: bool, no: bool):
    """Enables or disables sharing error reports."""
    # This is safe to call regardless if the GUI or daemon are running.
    from maestral.daemon import MaestralProxy

    if yes or no:
        try:
            with MaestralProxy(config_name) as m:
                m.analytics = yes
        except Pyro5.errors.CommunicationError:
            MaestralConfig(config_name).set('app', 'analytics', yes)

        enabled_str = 'Enabled' if yes else 'Disabled'
        click.echo(f'{enabled_str} automatic error reports.')
    else:
        try:
            with MaestralProxy(config_name) as m:
                state = m.analytics
        except Pyro5.errors.CommunicationError:
            state = MaestralConfig(config_name).get('app', 'analytics')
        enabled_str = 'enabled' if state else 'disabled'
        click.echo(f'Automatic error reports are {enabled_str}.')
Beispiel #12
0
def notify_level(config_name: str, level_name: str):
    """Gets or sets the level for desktop notifications."""
    from maestral.daemon import MaestralProxy
    from maestral.utils.notify import levelNameToNumber, levelNumberToName

    try:
        with MaestralProxy(config_name) as m:
            if level_name:
                m.notification_level = levelNameToNumber(level_name)
                click.echo(f'Notification level set to {level_name}.')
            else:
                level_name = levelNumberToName(m.notification_level)
                click.echo(f'Notification level: {level_name}.')
    except Pyro5.errors.CommunicationError:
        conf = MaestralConfig(config_name)
        if level_name:
            conf.set('app', 'notification_level',
                     levelNameToNumber(level_name))
            click.echo(f'Notification level set to {level_name}.')
        else:
            level_name = levelNumberToName(
                conf.get('app', 'notification_level'))
            click.echo(f'Notification level: {level_name}.')
Beispiel #13
0
def pending_link(config_name):
    """
    Checks if auth key has been saved. This can be used by Maestral front ends to check
    if we are linked before starting a daemon.

    :param str config_name: The config to check.
    :returns: ``True`` or ``False``.
    :rtype: bool
    :raises: ``KeyringLocked`` if the system keyring cannot be accessed.
    """

    set_keyring_backend()

    conf = MaestralConfig(config_name)
    account_id = conf.get('account', 'account_id')
    try:
        if account_id == '':
            access_token = None
        else:
            access_token = keyring.get_password('Maestral', account_id)
        return access_token is None
    except KeyringLocked:
        info = 'Please make sure that your keyring is unlocked and restart Maestral.'
        raise KeyringLocked(info)
Beispiel #14
0
def check_for_updates() -> None:
    """
    Checks if updates are available by reading the cached release number from the
    config file and notifies the user. Prints an update note to the command line.
    """
    from packaging.version import Version

    conf = MaestralConfig("maestral")
    state = MaestralState("maestral")

    interval = conf.get("app", "update_notification_interval")
    last_update_check = state.get("app", "update_notification_last")
    latest_release = state.get("app", "latest_release")

    if interval == 0 or time.time() - last_update_check < interval:
        return

    has_update = Version(__version__) < Version(latest_release)

    if has_update:
        click.echo(
            f"Maestral v{latest_release} has been released, you have v{__version__}. "
            f"Please use your package manager to update."
        )
Beispiel #15
0
def configs() -> None:

    # clean up stale configs
    config_names = list_configs()

    for name in config_names:
        dbid = MaestralConfig(name).get("account", "account_id")
        if dbid == "" and not is_running(name):
            remove_configuration(name)

    # display remaining configs
    names = list_configs()
    emails = [MaestralState(c).get("account", "email") for c in names]

    click.echo("")
    click.echo(
        format_table(columns=[names, emails], headers=["Config name", "Account"])
    )
    click.echo("")
Beispiel #16
0
def configs():
    """Lists all configured Dropbox accounts."""
    from maestral.daemon import get_maestral_pid
    from maestral.utils.backend import remove_configuration

    # clean up stale configs
    config_names = list_configs()

    for name in config_names:
        dbid = MaestralConfig(name).get('account', 'account_id')
        if dbid == '' and not get_maestral_pid(name):
            remove_configuration(name)

    # display remaining configs
    names = list_configs()
    emails = [MaestralState(c).get('account', 'email') for c in names]

    click.echo('')
    click.echo(
        format_table(columns=[names, emails],
                     headers=['Config name', 'Account']))
    click.echo('')
Beispiel #17
0
class MaestralDesktopNotifier(logging.Handler):
    """
    Can be used as a standalone notifier or as a logging handler.
    When used as a logging handler, the log level should be set with ``setLevel``. The
    ``notify_level`` will be applied in addition to the log level.
    """

    _instances = {}

    @classmethod
    def for_config(cls, config_name):
        """
        Returns an existing instance for the config or creates a new one if none exists.
        Use this method to prevent creating multiple instances.

        :param str config_name: Name of maestral config.
        """

        if config_name in cls._instances:
            return cls._instances[config_name]
        else:
            instance = cls(config_name)
            cls._instances[config_name] = instance
            return instance

    def __init__(self, config_name):
        super().__init__()
        self.setFormatter(logging.Formatter(fmt='%(message)s'))
        self._conf = MaestralConfig(config_name)
        self._snooze = 0

    @property
    def notify_level(self):
        """Custom notification level. Notifications with a lower level will be
        discarded."""
        return self._conf.get('app', 'notification_level')

    @notify_level.setter
    def notify_level(self, level):
        """Setter: notify_level."""
        assert isinstance(level, int)
        self._conf.set('app', 'notification_level', level)

    @property
    def snoozed(self):
        """Time in minutes to snooze notifications. Applied to FILECHANGE level only."""
        return max(0.0, (self._snooze - time.time()) / 60)

    @snoozed.setter
    def snoozed(self, minutes):
        """Setter: snoozed."""
        self._snooze = time.time() + minutes * 60

    def notify(self, message, level=FILECHANGE):
        """
        Sends a desktop notification from maestral. The title defaults to 'Maestral'.

        :param str message: Notification message.
        :param int level: Notification level of the message.
        """

        ignore = self.snoozed and level == FILECHANGE
        if level == ERROR:
            urgency = system_notifier.CRITICAL
        else:
            urgency = system_notifier.NORMAL

        if level >= self.notify_level and not ignore:
            system_notifier.send(title='Maestral',
                                 message=message,
                                 icon=APP_ICON_PATH,
                                 urgency=urgency)

    def emit(self, record):
        """Emits a log record as desktop notification."""
        self.format(record)
        self.notify(record.message, level=record.levelno)
Beispiel #18
0
class OAuth2Session:
    """
    OAuth2Session provides OAuth 2 login and token store in the preferred system keyring.
    To authenticate with Dropbox, run :meth:`get_auth_url` first and direct the user to
    visit that URL and retrieve an auth token. Verify the provided auth token with
    :meth:`verify_auth_token` and save it in the system keyring together with the
    corresponding Dropbox ID by calling :meth:`save_creds`. Supported keyring backends
    are, in order of preference:

        * MacOS Keychain
        * Any keyring implementing the SecretService Dbus specification
        * KWallet
        * Gnome Keyring
        * Plain text storage

    When the auth flow is completed, a short-lived access token and a long-lived refresh
    token are generated. Only the long-lived refresh token will be saved in the system
    keychain for future sessions, it can be used to generate short-lived access tokens
    as needed.

    If the auth flow was previously completed before Dropbox migrated to short-lived
    tokens, the ``token_access_type`` will be 'legacy' and only a long-lived access
    token will be available.

    .. warning:: Unlike MacOS Keychain, Gnome Keyring and KWallet do not support
        app-specific access to passwords. If the user unlocks those keyrings, we and any
        other application in the same user session get access to *all* saved passwords.

    :param config_name: Name of maestral config.

    :cvar int Success: Exit code for successful auth.
    :cvar int InvalidToken: Exit code for invalid token.
    :cvar int ConnectionFailed: Exit code for connection errors.
    """

    Success = 0
    InvalidToken = 1
    ConnectionFailed = 2

    default_token_access_type = "offline"

    _lock = RLock()

    def __init__(self,
                 config_name: str,
                 app_key: str = DROPBOX_APP_KEY) -> None:

        self._app_key = app_key
        self._config_name = config_name

        self._conf = MaestralConfig(config_name)
        self._state = MaestralState(config_name)

        self._auth_flow = DropboxOAuth2FlowNoRedirect(
            self._app_key,
            use_pkce=True,
            token_access_type=self.default_token_access_type,
        )

        self._account_id = self._conf.get("account", "account_id") or None
        self._token_access_type = (self._state.get(
            "account", "token_access_type") or None)

        self.keyring = self._get_keyring_backend()

        # defer keyring access until token requested by user
        self.loaded = False
        self._access_token: Optional[str] = None
        self._refresh_token: Optional[str] = None
        self._expires_at: Optional[datetime] = None

    def _get_keyring_backend(self) -> KeyringBackend:
        """
        Returns the keyring backend currently used. If none is used because we are not
        yet linked, use the backend specified in the config file (if valid) or choose
        the most secure of the available and supported keyring backends.
        """

        import keyring.backends

        keyring_class = self._conf.get("app", "keyring").strip()

        if self._account_id and keyring_class != "automatic":
            # we are already linked and have a keyring set

            try:
                ring = load_keyring(keyring_class)
            except Exception as exc:
                # reset the keyring and prompt to relink
                # them bomb out with an exception

                self._conf.set("app", "keyring", "automatic")

                title = f"Cannot load keyring {keyring_class}"
                message = "Please relink Maestral to get new access token"
                new_exc = KeyringAccessError(title, message).with_traceback(
                    exc.__traceback__)
                logger.error(title, exc_info=_exc_info(new_exc))
                raise new_exc
            else:
                return ring

        else:

            try:
                ring = load_keyring(keyring_class)
            except Exception:

                # get preferred keyring backends for platform
                available_rings = keyring.backend.get_all_keyring()
                supported_rings = [
                    k for k in available_rings
                    if isinstance(k, supported_keyring_backends)
                ]

                ring = max(supported_rings, key=lambda x: x.priority)

            self._conf.set(
                "app",
                "keyring",
                f"{ring.__class__.__module__}.{ring.__class__.__name__}",
            )

            return ring

    @property
    def linked(self) -> bool:
        """Returns ``True`` if we have full auth credentials, ``False`` otherwise."""

        if self.account_id:

            legacy = self._token_access_type == "legacy" and self.access_token
            offline = self._token_access_type == "offline" and self.refresh_token

            if legacy or offline:
                return True

        return False

    @property
    def account_id(self) -> Optional[str]:
        """Returns the account ID (read only). This call may block until the keyring is
        unlocked."""

        return self._account_id

    @property
    def token_access_type(self) -> Optional[str]:
        """Returns the type of access token. If 'legacy', we have a long-lived access
        token. If 'offline', we have a short-lived access token with an expiry time and
        a long-lived refresh token to generate new access tokens."""

        with self._lock:
            if not self.loaded:
                self.load_token()

            return self._token_access_type

    @property
    def access_token(self) -> Optional[str]:
        """Returns the access token (read only). This will always be set for a 'legacy'
        token. For an 'offline' token, this will only be set if we completed the auth
        flow in the current session. In case of an 'offline' token, use the refresh
        token to retrieve a short-lived access token through the Dropbox API instead.
        The call may block until the keyring is unlocked."""

        with self._lock:
            if not self.loaded:
                self.load_token()

            return self._access_token

    @property
    def refresh_token(self) -> Optional[str]:
        """Returns the refresh token (read only). This will only be set for an 'offline'
        token. The call may block until the keyring is unlocked."""

        with self._lock:
            if not self.loaded:
                self.load_token()

            return self._refresh_token

    @property
    def access_token_expiration(self) -> Optional[datetime]:
        """Returns the expiry time for the short-lived access token. This will only be
        set for an 'offline' token and if we completed the flow during the current
        session."""

        # this will only be set if we linked in the current session

        return self._expires_at

    def load_token(self) -> None:
        """
        Loads auth token from system keyring. This will be called automatically when
        accessing of the properties :attr:`linked`, :attr:`access_token`,
        :attr:`refresh_token` or :attr:`token_access_type`.

        :raises: :class:`keyring.errors.KeyringLocked` if the system keyring is locked.
        """

        logger.debug(f"Using keyring: {self.keyring}")

        if not self._account_id:
            return

        try:

            token = self.keyring.get_password("Maestral", self._account_id)
            access_type = self._state.get("account", "token_access_type")

            if not access_type:
                # if no token type was saved, we linked with a version < 1.2.0
                # default to legacy token access type
                access_type = "legacy"
                self._state.set("account", "token_access_type", access_type)

            self.loaded = True

            if token:

                if access_type == "legacy":
                    self._access_token = token
                elif access_type == "offline":
                    self._refresh_token = token
                else:
                    msg = "Invalid token access type in state file."
                    err = RuntimeError(
                        "Invalid token access type in state file.")
                    logger.error(msg, exc_info=_exc_info(err))
                    raise err

                self._token_access_type = access_type

        except KeyringLocked:
            title = f"Could not load auth token, {self.keyring.name} is locked"
            msg = "Please unlock the keyring and try again."
            exc = KeyringAccessError(title, msg)
            logger.error(title, exc_info=_exc_info(exc))
            raise exc

    def get_auth_url(self) -> str:
        """
        Gets the auth URL to start the OAuth2 implicit grant flow.

        :returns: Dropbox auth URL.
        """
        authorize_url = self._auth_flow.start()
        return authorize_url

    def verify_auth_token(self, token) -> int:
        """
        Verify the provided authorization token with Dropbox servers.

        :returns: :attr:`Success`, :attr:`InvalidToken`, or :attr:`ConnectionFailed`.
        """

        with self._lock:

            try:
                res = self._auth_flow.finish(token)

                self._access_token = res.access_token
                self._refresh_token = res.refresh_token
                self._expires_at = res.expires_at
                self._account_id = res.account_id
                self._token_access_type = self.default_token_access_type

                self.loaded = True

                return self.Success
            except requests.exceptions.HTTPError:
                return self.InvalidToken
            except CONNECTION_ERRORS:
                return self.ConnectionFailed

    def save_creds(self) -> None:
        """
        Saves the auth token to system keyring. Falls back to plain text storage if the
        user denies access to keyring.
        """

        with self._lock:

            self._conf.set("account", "account_id", self._account_id)
            self._state.set("account", "token_access_type",
                            self._token_access_type)

            if self._token_access_type == "offline":
                token = self.refresh_token
            else:
                token = self.access_token

            try:
                self.keyring.set_password("Maestral", self._account_id, token)
                click.echo(" > Credentials written.")
                if isinstance(self.keyring,
                              keyrings.alt.file.PlaintextKeyring):
                    click.echo(" > Warning: No supported keyring found, "
                               "Dropbox credentials stored in plain text.")
            except KeyringLocked:
                # switch to plain text keyring if user won't unlock
                self.keyring = keyrings.alt.file.PlaintextKeyring()
                self._conf.set("app", "keyring",
                               "keyrings.alt.file.PlaintextKeyring")
                self.save_creds()

    def delete_creds(self) -> None:
        """
        Deletes auth token from system keyring.

        :raises: :class:`keyring.errors.KeyringLocked` if the system keyring is locked.
        """

        with self._lock:

            if not self._account_id:
                # when keyring.delete_password is called without a username,
                # it may delete all passwords stored by Maestral on some backends
                return

            try:
                self.keyring.delete_password("Maestral", self._account_id)
                click.echo(" > Credentials removed.")
            except KeyringLocked:
                title = f"Could not delete auth token, {self.keyring.name} is locked"
                msg = "Please unlock the keyring and try again."
                exc = KeyringAccessError(title, msg)
                logger.error(title, exc_info=_exc_info(exc))
                raise exc
            except PasswordDeleteError as exc:
                # password does not exist in keyring
                logger.info(exc.args[0])

            self._conf.set("account", "account_id", "")
            self._state.set("account", "token_access_type", "")
            self._conf.set("app", "keyring", "automatic")

            self._account_id = None
            self._access_token = None
            self._refresh_token = None
            self._token_access_type = None

    def __repr__(self) -> str:
        return (f"<{self.__class__.__name__}(config={self._config_name!r}, "
                f"account_id={self._account_id})>")
Beispiel #19
0
class Maestral(object):
    """An open source Dropbox client for macOS and Linux.

    All methods and properties return objects or raise exceptions which can safely be
    serialized, i.e., pure Python types. The only exception are MaestralApiErrors which
    need to be registered explicitly with the serpent serializer used by Pyro5 in order
    to be transmitted to a frontend.

    :param str config_name: Name of maestral configuration to run. This will create a new
        configuration file if none exists.
    :param bool run: If ``True``, Maestral will start syncing immediately. Defaults to
        ``True``.
    """
    def __init__(self, config_name='maestral', run=True, log_to_stdout=False):

        self._daemon_running = True
        self._log_to_stdout = log_to_stdout

        self._config_name = config_name
        self._conf = MaestralConfig(self._config_name)
        self._state = MaestralState(self._config_name)

        self._setup_logging()

        self.client = MaestralApiClient(self._config_name)
        self.monitor = MaestralMonitor(self.client)
        self.sync = self.monitor.sync

        # periodically check for updates and refresh account info
        self.update_thread = Thread(
            name='maestral-update-check',
            target=self._periodic_refresh,
            daemon=True,
        )
        self.update_thread.start()

        if run:
            self.run()

    def run(self):
        """
        Runs setup if necessary, starts syncing, and starts systemd notifications if run
        as a systemd notify service.
        """

        if self.pending_dropbox_folder:
            self.monitor.reset_sync_state()
            self.create_dropbox_directory()

        # start syncing
        self.start_sync()

        if NOTIFY_SOCKET:  # notify systemd that we have started
            logger.debug('Running as systemd notify service')
            logger.debug('NOTIFY_SOCKET = %s', NOTIFY_SOCKET)
            sd_notifier.notify('READY=1')

        if IS_WATCHDOG:  # notify systemd periodically if alive
            logger.debug('Running as systemd watchdog service')
            logger.debug('WATCHDOG_USEC = %s', WATCHDOG_USEC)
            logger.debug('WATCHDOG_PID = %s', WATCHDOG_PID)

            self.watchdog_thread = Thread(
                name='maestral-watchdog',
                target=self._periodic_watchdog,
                daemon=True,
            )
            self.watchdog_thread.start()

    def _setup_logging(self):
        """
        Sets up logging to log files, status and error properties, desktop notifications,
        the systemd journal if available, bugsnag if error reports are enabled, and to
        stdout if requested.
        """

        log_level = self._conf.get('app', 'log_level')
        mdbx_logger = logging.getLogger('maestral')
        mdbx_logger.setLevel(logging.DEBUG)

        log_fmt_long = logging.Formatter(
            fmt='%(asctime)s %(name)s %(levelname)s: %(message)s',
            datefmt='%Y-%m-%d %H:%M:%S')
        log_fmt_short = logging.Formatter(fmt='%(message)s')

        # log to file
        rfh_log_file = get_log_path('maestral', self._config_name + '.log')
        self.log_handler_file = logging.handlers.RotatingFileHandler(
            rfh_log_file, maxBytes=10**7, backupCount=1)
        self.log_handler_file.setFormatter(log_fmt_long)
        self.log_handler_file.setLevel(log_level)
        mdbx_logger.addHandler(self.log_handler_file)

        # log to journal when launched from systemd
        if INVOCATION_ID and journal:
            self.log_handler_journal = journal.JournalHandler()
            self.log_handler_journal.setFormatter(log_fmt_short)
            self.log_handler_journal.setLevel(log_level)
            mdbx_logger.addHandler(self.log_handler_journal)
        else:
            self.log_handler_journal = None

        # send systemd notifications when started as 'notify' daemon
        if NOTIFY_SOCKET:
            self.log_handler_sd = SdNotificationHandler()
            self.log_handler_sd.setFormatter(log_fmt_short)
            self.log_handler_sd.setLevel(logging.INFO)
            mdbx_logger.addHandler(self.log_handler_sd)
        else:
            self.log_handler_sd = None

        # log to stdout (disabled by default)
        level = log_level if self._log_to_stdout else 100
        self.log_handler_stream = logging.StreamHandler(sys.stdout)
        self.log_handler_stream.setFormatter(log_fmt_long)
        self.log_handler_stream.setLevel(level)
        mdbx_logger.addHandler(self.log_handler_stream)

        # log to cached handlers for GUI and CLI
        self._log_handler_info_cache = CachedHandler(maxlen=1)
        self._log_handler_info_cache.setFormatter(log_fmt_short)
        self._log_handler_info_cache.setLevel(logging.INFO)
        mdbx_logger.addHandler(self._log_handler_info_cache)

        self._log_handler_error_cache = CachedHandler()
        self._log_handler_error_cache.setFormatter(log_fmt_short)
        self._log_handler_error_cache.setLevel(logging.ERROR)
        mdbx_logger.addHandler(self._log_handler_error_cache)

        # log to desktop notifications
        # 'file changed' events will be collated and sent as desktop
        # notifications by the monitor directly, we don't handle them here
        self.desktop_notifier = MaestralDesktopNotifier.for_config(
            self.config_name)
        self.desktop_notifier.setLevel(logging.WARNING)
        mdbx_logger.addHandler(self.desktop_notifier)

        # log to bugsnag (disabled by default)
        self._log_handler_bugsnag = BugsnagHandler()
        self._log_handler_bugsnag.setLevel(100)
        mdbx_logger.addHandler(self._log_handler_bugsnag)

        self.analytics = self._conf.get('app', 'analytics')

    # ==== methods to access config and saved state ======================================

    @property
    def config_name(self):
        """The selected configuration."""
        return self._config_name

    def set_conf(self, section, name, value):
        """Sets a configuration option."""
        self._conf.set(section, name, value)

    def get_conf(self, section, name):
        """Gets a configuration option."""
        return self._conf.get(section, name)

    def set_state(self, section, name, value):
        """Sets a state value."""
        self._state.set(section, name, value)

    def get_state(self, section, name):
        """Gets a state value."""
        return self._state.get(section, name)

    # ==== getters / setters for config with side effects ================================

    @property
    def dropbox_path(self):
        """Returns the path to the local Dropbox directory. Read only. Use
        :meth:`create_dropbox_directory` or :meth:`move_dropbox_directory` to set or
        change the Dropbox directory location instead. """
        return self.sync.dropbox_path

    @property
    def excluded_items(self):
        """
        Returns a list of excluded folders (read only). Use :meth:`exclude_item`,
        :meth:`include_item` or :meth:`set_excluded_items` to change which items are
        excluded from syncing.
        """
        return self.sync.excluded_items

    @property
    def log_level(self):
        """Log level for log files, the stream handler and the systemd journal."""
        return self._conf.get('app', 'log_level')

    @log_level.setter
    def log_level(self, level_num):
        """Setter: Log level for log files, the stream handler and the systemd journal."""
        self.log_handler_file.setLevel(level_num)
        if self.log_handler_journal:
            self.log_handler_journal.setLevel(level_num)
        if self.log_to_stdout:
            self.log_handler_stream.setLevel(level_num)
        self._conf.set('app', 'log_level', level_num)

    @property
    def log_to_stdout(self):
        return self._log_to_stdout

    @log_to_stdout.setter
    def log_to_stdout(self, enabled=True):
        """Enables or disables logging to stdout."""
        self._log_to_stdout = enabled
        level = self.log_level if enabled else 100
        self.log_handler_stream.setLevel(level)

    @property
    def analytics(self):
        """Enables or disables logging of errors to bugsnag."""
        return self._conf.get('app', 'analytics')

    @analytics.setter
    def analytics(self, enabled):
        """Setter: Enables or disables logging of errors to bugsnag."""

        bugsnag.configuration.auto_notify = enabled
        bugsnag.configuration.auto_capture_sessions = enabled
        self._log_handler_bugsnag.setLevel(logging.ERROR if enabled else 100)

        self._conf.set('app', 'analytics', enabled)

    @property
    def notification_snooze(self):
        """Snoozed time for desktop notifications in minutes."""
        return self.desktop_notifier.snoozed

    @notification_snooze.setter
    def notification_snooze(self, minutes):
        """Setter: Snoozed time for desktop notifications in minutes."""
        self.desktop_notifier.snoozed = minutes

    @property
    def notification_level(self):
        """Level for desktop notifications."""
        return self.desktop_notifier.notify_level

    @notification_level.setter
    def notification_level(self, level):
        """Setter: Level for desktop notifications."""
        self.desktop_notifier.notify_level = level

    # ==== state information  ============================================================

    @property
    def pending_dropbox_folder(self):
        """Bool indicating if a local Dropbox directory has been created."""
        return not osp.isdir(self._conf.get('main', 'path'))

    @property
    def pending_first_download(self):
        """Bool indicating if the initial download has already occurred."""
        return (self._state.get('sync', 'lastsync') == 0
                or self._state.get('sync', 'cursor') == '')

    @property
    def syncing(self):
        """
        Bool indicating if Maestral is syncing. It will be ``True`` if syncing is not
        paused by the user *and* Maestral is connected to the internet.
        """
        return self.monitor.syncing.is_set() or self.monitor.startup.is_set()

    @property
    def paused(self):
        """Bool indicating if syncing is paused by the user. This is set by
        calling :meth:`pause`."""
        return self.monitor.paused_by_user.is_set(
        ) and not self.sync.lock.locked()

    @property
    def stopped(self):
        """Bool indicating if syncing is stopped, for instance because of an exception."""
        return not self.monitor.running.is_set() and not self.sync.lock.locked(
        )

    @property
    def connected(self):
        """Bool indicating if Dropbox servers can be reached."""
        return self.monitor.connected.is_set()

    @property
    def status(self):
        """
        Returns a string with the last status message. This can be displayed as
        information to the user but should not be relied on otherwise.
        """
        return self._log_handler_info_cache.getLastMessage()

    @property
    def sync_errors(self):
        """Returns list containing the current sync errors as dicts."""
        sync_errors = list(self.sync.sync_errors.queue)
        sync_errors_dicts = [error_to_dict(e) for e in sync_errors]
        return sync_errors_dicts

    @property
    def maestral_errors(self):
        """
        Returns a list of Maestral's errors as dicts. This does not include lost internet
        connections or file sync errors which only emit warnings and are tracked and
        cleared separately. Errors listed here must be acted upon for Maestral to
        continue syncing.
        """

        maestral_errors = [
            r.exc_info[1] for r in self._log_handler_error_cache.cached_records
        ]
        maestral_errors_dicts = [error_to_dict(e) for e in maestral_errors]
        return maestral_errors_dicts

    def clear_maestral_errors(self):
        """
        Manually clears all Maestral errors. This should be used after they have been
        resolved by the user through the GUI or CLI.
        """
        self._log_handler_error_cache.clear()

    @property
    def account_profile_pic_path(self):
        """
        Returns the path of the current account's profile picture. There may not be an
        actual file at that path, if the user did not set a profile picture or the
        picture has not yet been downloaded.
        """
        return get_cache_path('maestral',
                              self._config_name + '_profile_pic.jpeg')

    def get_file_status(self, local_path):
        """
        Returns the sync status of an individual file.

        :param str local_path: Path to file on the local drive. May be relative to the
            current working directory.
        :returns: String indicating the sync status. Can be 'uploading', 'downloading',
            'up to date', 'error', or 'unwatched' (for files outside of the Dropbox
            directory).
        :rtype: str
        """
        if not self.syncing:
            return FileStatus.Unwatched.value

        local_path = osp.abspath(local_path)

        try:
            dbx_path = self.sync.to_dbx_path(local_path)
        except ValueError:
            return FileStatus.Unwatched.value

        if local_path in self.monitor.queued_for_upload:
            return FileStatus.Uploading.value
        elif local_path in self.monitor.queued_for_download:
            return FileStatus.Downloading.value
        elif any(local_path == err['local_path'] for err in self.sync_errors):
            return FileStatus.Error.value
        elif self.sync.get_local_rev(dbx_path):
            return FileStatus.Synced.value
        else:
            return FileStatus.Unwatched.value

    def get_activity(self):
        """
        Gets current upload / download activity.

        :returns: A dictionary with lists of all files currently queued for or being
            uploaded or downloaded. Paths are given relative to the Dropbox folder.
        :rtype: dict(list, list)
        """
        PathItem = namedtuple('PathItem', 'path status')
        uploading = []
        downloading = []

        for path in self.monitor.uploading:
            path.lstrip(self.dropbox_path)
            uploading.append(PathItem(path, 'uploading'))

        for path in self.monitor.queued_for_upload:
            path.lstrip(self.dropbox_path)
            uploading.append(PathItem(path, 'queued'))

        for path in self.monitor.downloading:
            path.lstrip(self.dropbox_path)
            downloading.append(PathItem(path, 'downloading'))

        for path in self.monitor.queued_for_download:
            path.lstrip(self.dropbox_path)
            downloading.append(PathItem(path, 'queued'))

        return dict(uploading=uploading, downloading=downloading)

    @handle_disconnect
    def get_account_info(self):
        """
        Gets account information from Dropbox and returns it as a dictionary. The entries
        will either be of type ``str`` or ``bool``.

        :returns: Dropbox account information.
        :rtype: dict[str, bool]
        :raises: :class:`MaestralApiError`
        """
        res = self.client.get_account_info()
        return dropbox_stone_to_dict(res)

    @handle_disconnect
    def get_space_usage(self):
        """
        Gets the space usage stored by Dropbox and returns it as a dictionary. The
        entries will either be of type ``str`` or ``bool``.

        :returns: Dropbox account information.
        :rtype: dict[str, bool]
        :raises: :class:`MaestralApiError`
        """
        res = self.client.get_space_usage()
        return dropbox_stone_to_dict(res)

    # ==== control methods for front ends ================================================

    @handle_disconnect
    def get_profile_pic(self):
        """
        Attempts to download the user's profile picture from Dropbox. The picture saved
        in Maestral's cache directory for retrieval when there is no internet connection.
        This function will fail silently in case of :class:`MaestralApiError`s.

        :returns: Path to saved profile picture or None if no profile picture is set.
        """

        try:
            res = self.client.get_account_info()
        except MaestralApiError:
            pass
        else:
            if res.profile_photo_url:
                # download current profile pic
                res = requests.get(res.profile_photo_url)
                with open(self.account_profile_pic_path, 'wb') as f:
                    f.write(res.content)
                return self.account_profile_pic_path
            else:
                # delete current profile pic
                self._delete_old_profile_pics()

    @handle_disconnect
    def list_folder(self, dbx_path, **kwargs):
        """
        List all items inside the folder given by :param:`dbx_path`.

        :param dbx_path: Path to folder on Dropbox.
        :returns: List of Dropbox item metadata as dicts or ``False`` if listing failed
            due to connection issues.
        :rtype: list[dict]
        :raises: :class:`MaestralApiError`
        """
        res = self.client.list_folder(dbx_path, **kwargs)

        entries = [dropbox_stone_to_dict(e) for e in res.entries]

        return entries

    def _delete_old_profile_pics(self):
        # delete all old pictures
        for file in os.listdir(get_cache_path('maestral')):
            if file.startswith(self._config_name + '_profile_pic'):
                try:
                    os.unlink(osp.join(get_cache_path('maestral'), file))
                except OSError:
                    pass

    def rebuild_index(self):
        """
        Rebuilds the rev file by comparing remote with local files and updating rev
        numbers from the Dropbox server. Files are compared by their content hashes and
        conflicting copies are created if the contents differ. File changes during the
        rebuild process will be queued and uploaded once rebuilding has completed.

        Rebuilding will be performed asynchronously.

        :raises: :class:`MaestralApiError`
        """

        self.monitor.rebuild_index()

    def start_sync(self):
        """
        Creates syncing threads and starts syncing.
        """
        self.monitor.start()

    def resume_sync(self):
        """
        Resumes the syncing threads if paused.
        """
        self.monitor.resume()

    def pause_sync(self):
        """
        Pauses the syncing threads if running.
        """
        self.monitor.pause()

    def stop_sync(self):
        """
        Stops the syncing threads if running, destroys observer thread.
        """
        self.monitor.stop()

    def reset_sync_state(self):
        """
        Resets the sync index and state. Only call this to clean up leftover state
        information if a Dropbox was improperly unlinked (e.g., auth token has been
        manually deleted). Otherwise leave state management to Maestral.
        """

        self.monitor.reset_sync_state()

    def unlink(self):
        """
        Unlinks the configured Dropbox account but leaves all downloaded files in place.
        All syncing metadata will be removed as well. Connection and API errors will be
        handled silently but the Dropbox access key will always be removed from the
        user's PC.
        """
        self.stop_sync()
        try:
            self.client.unlink()
        except (ConnectionError, MaestralApiError):
            pass

        try:
            os.remove(self.sync.rev_file_path)
        except OSError:
            pass

        self.sync.clear_rev_index()
        delete(self.sync.rev_file_path)
        self._conf.cleanup()
        self._state.cleanup()

        logger.info('Unlinked Dropbox account.')

    def exclude_item(self, dbx_path):
        """
        Excludes file or folder from sync and deletes it locally. It is safe to call this
        method with items which have already been excluded.

        :param str dbx_path: Dropbox path of item to exclude.
        :raises: :class:`ValueError` if ``dbx_path`` is not on Dropbox.
        :raises: :class:`ConnectionError` if connection to Dropbox fails.
        """

        # input validation
        md = self.client.get_metadata(dbx_path)

        if not md:
            raise ValueError(f'"{dbx_path}" does not exist on Dropbox')

        dbx_path = dbx_path.lower().rstrip(osp.sep)

        # add the path to excluded list
        if self.sync.is_excluded_by_user(dbx_path):
            logger.info('%s was already excluded', dbx_path)
            logger.info(IDLE)
            return

        excluded_items = self.sync.excluded_items
        excluded_items.append(dbx_path)

        self.sync.excluded_items = excluded_items

        logger.info('Excluded %s', dbx_path)

        self._remove_after_excluded(dbx_path)

        logger.info(IDLE)

    def _remove_after_excluded(self, dbx_path):

        # book keeping
        self.sync.clear_sync_error(dbx_path=dbx_path)
        self.sync.set_local_rev(dbx_path, None)

        # remove folder from local drive
        local_path = self.sync.to_local_path(dbx_path)
        # dbx_path will be lower-case, we there explicitly run `to_cased_path`
        local_path = to_cased_path(local_path)
        if local_path:
            with self.monitor.fs_event_handler.ignore(
                    local_path,
                    recursive=osp.isdir(local_path),
                    event_types=(EVENT_TYPE_DELETED, )):
                delete(local_path)

    def include_item(self, dbx_path):
        """
        Includes file or folder in sync and downloads in the background. It is safe to
        call this method with items which have already been included, they will not be
        downloaded again.

        :param str dbx_path: Dropbox path of item to include.
        :raises: :class:`ValueError` if ``dbx_path`` is not on Dropbox or lies inside
            another excluded folder.
        :raises: :class:`ConnectionError` if connection to Dropbox fails.
        """

        # input validation
        md = self.client.get_metadata(dbx_path)

        if not md:
            raise ValueError(f'"{dbx_path}" does not exist on Dropbox')

        dbx_path = dbx_path.lower().rstrip(osp.sep)

        old_excluded_items = self.sync.excluded_items

        for folder in old_excluded_items:
            if is_child(dbx_path, folder):
                raise ValueError(
                    f'"{dbx_path}" lies inside the excluded folder '
                    f'"{folder}". Please include "{folder}" first.')

        # Get items which will need to be downloaded, do not attempt to download
        # children of `dbx_path` which were already included.
        # `new_included_items` will either be empty (`dbx_path` was already
        # included), just contain `dbx_path` itself (the item was fully excluded) or
        # only contain children of `dbx_path` (`dbx_path` was partially included).
        new_included_items = tuple(x for x in old_excluded_items
                                   if x == dbx_path or is_child(x, dbx_path))

        if new_included_items:
            # remove `dbx_path` or all excluded children from the excluded list
            excluded_items = list(
                set(old_excluded_items) - set(new_included_items))
        else:
            logger.info('%s was already included', dbx_path)
            return

        self.sync.excluded_items = excluded_items

        logger.info('Included %s', dbx_path)

        # download items from Dropbox
        for folder in new_included_items:
            self.sync.queued_newly_included_downloads.put(folder)

    @handle_disconnect
    def set_excluded_items(self, items=None):
        """
        Sets the list of excluded files or folders. If not given, gets all top level
        folder paths from Dropbox and asks user to include or exclude. Items which are
        no in ``items`` but were previously excluded will be downloaded.

        On initial sync, this does not trigger any downloads.

        :param list items: If given, list of excluded files or folders to set.
        :raises: :class:`MaestralApiError`
        """

        if items is None:

            excluded_items = []

            # get all top-level Dropbox folders
            result = self.client.list_folder('/', recursive=False)

            # paginate through top-level folders, ask to exclude
            for entry in result.entries:
                if isinstance(entry, files.FolderMetadata):
                    yes = click.confirm(
                        f'Exclude "{entry.path_display}" from sync?',
                        prompt_suffix='')
                    if yes:
                        excluded_items.append(entry.path_lower)
        else:
            excluded_items = self.sync.clean_excluded_items_list(items)

        old_excluded_items = self.sync.excluded_items

        added_excluded_items = set(excluded_items) - set(old_excluded_items)
        added_included_items = set(old_excluded_items) - set(excluded_items)

        self.sync.excluded_items = excluded_items

        if not self.pending_first_download:
            # apply changes
            for path in added_excluded_items:
                logger.info('Excluded %s', path)
                self._remove_after_excluded(path)
            for path in added_included_items:
                if not self.sync.is_excluded_by_user(path):
                    logger.info('Included %s', path)
                    self.sync.queued_newly_included_downloads.put(path)

        logger.info(IDLE)

    def excluded_status(self, dbx_path):
        """
        Returns 'excluded', 'partially excluded' or 'included'. This function will not
        check if the item actually exists on Dropbox.

        :param str dbx_path: Path to item on Dropbox.
        :returns: Excluded status.
        :rtype: str
        """

        dbx_path = dbx_path.lower().rstrip(osp.sep)

        excluded_items = self._conf.get('main', 'excluded_items')

        if dbx_path in excluded_items:
            return 'excluded'
        elif any(is_child(f, dbx_path) for f in excluded_items):
            return 'partially excluded'
        else:
            return 'included'

    @with_sync_paused
    def move_dropbox_directory(self, new_path=None):
        """
        Sets the local Dropbox directory. This moves all local files to the new location
        and resumes syncing afterwards.

        :param str new_path: Full path to local Dropbox folder. If not given, the user
            will be prompted to input the path.
        :raises: ``OSError`` if moving the directory fails.
        """

        # get old and new paths
        old_path = self.sync.dropbox_path
        new_path = new_path or select_dbx_path_dialog(self._config_name)

        try:
            if osp.samefile(old_path, new_path):
                return
        except FileNotFoundError:
            pass

        if osp.exists(new_path):
            raise FileExistsError(f'Path "{new_path}" already exists.')

        # move folder from old location or create a new one if no old folder exists
        if osp.isdir(old_path):
            shutil.move(old_path, new_path)
        else:
            os.makedirs(new_path)

        # update config file and client
        self.sync.dropbox_path = new_path

    @with_sync_paused
    def create_dropbox_directory(self, path=None):
        """
        Creates a new Dropbox directory. Only call this during setup.

        :param str path: Full path to local Dropbox folder. If not given, the user will be
            prompted to input the path.
        :raises: ``OSError`` if creation fails
        """
        path = path or select_dbx_path_dialog(self._config_name,
                                              allow_merge=True)

        # create new folder
        os.makedirs(path, exist_ok=True)

        # update config file and client
        self.sync.dropbox_path = path

    # ==== utility methods for front ends ================================================

    def to_local_path(self, dbx_path):
        """
        Converts a path relative to the Dropbox folder to a correctly cased local file
        system path.

        :param str dbx_path: Path relative to Dropbox root.
        :returns: Corresponding path of a location in the local Dropbox folder.
        :rtype: str
        """
        return self.sync.to_local_path(dbx_path)

    @staticmethod
    def check_for_updates():
        """
        Checks if an update is available.

        :returns: A dictionary with information about the latest release with the fields
            ``update_available`` (bool), ``latest_release`` (str), ``release_notes`` (str)
            and ``error`` (str or None).
        :rtype: dict
        """
        return check_update_available()

    def shutdown_pyro_daemon(self):
        """
        Sets the ``_daemon_running`` flag to ``False``. This will be checked by Pyro5
        periodically to shut down the daemon when requested.
        """
        self._daemon_running = False
        if NOTIFY_SOCKET:
            # notify systemd that we are shutting down
            sd_notifier.notify('STOPPING=1')

    # ==== private methods ===============================================================

    def _loop_condition(self):
        return self._daemon_running

    def _periodic_refresh(self):
        while True:
            # update account info
            self.get_account_info()
            self.get_space_usage()
            self.get_profile_pic()
            # check for maestral updates
            res = self.check_for_updates()
            if not res['error']:
                self._state.set('app', 'latest_release', res['latest_release'])
            time.sleep(60 * 60)  # 60 min

    def _periodic_watchdog(self):
        while self.monitor._threads_alive():
            sd_notifier.notify('WATCHDOG=1')
            time.sleep(int(WATCHDOG_USEC) / (2 * 10**6))

    def __del__(self):
        try:
            self.monitor.stop()
        except Exception:
            pass

    def __repr__(self):
        email = self._state.get('account', 'email')
        account_type = self._state.get('account', 'type')

        return f'<{self.__class__.__name__}({email}, {account_type})>'
Beispiel #20
0
class SetupDialog(QtWidgets.QDialog):
    """A dialog to link and set up a new Dropbox account."""

    auth_session = ''
    auth_url = ''

    accepted = False

    def __init__(self, config_name='maestral', pending_link=True, parent=None):
        super().__init__(parent=parent)
        # load user interface layout from .ui file
        uic.loadUi(SETUP_DIALOG_PATH, self)
        self.setWindowFlags(Qt.WindowStaysOnTopHint)

        self._config_name = config_name
        self._conf = MaestralConfig(config_name)
        self._state = MaestralState(config_name)

        self.app_icon = QtGui.QIcon(APP_ICON_PATH)

        self.labelIcon_0.setPixmap(icon_to_pixmap(self.app_icon, 150))
        self.labelIcon_1.setPixmap(icon_to_pixmap(self.app_icon, 70))
        self.labelIcon_2.setPixmap(icon_to_pixmap(self.app_icon, 70))
        self.labelIcon_3.setPixmap(icon_to_pixmap(self.app_icon, 120))

        self.mdbx = None
        self.dbx_model = None
        self.excluded_items = []

        # resize dialog buttons
        width = self.pushButtonAuthPageCancel.width()*1.1
        for b in (self.pushButtonAuthPageLink, self.pushButtonDropboxPathUnlink,
                  self.pushButtonDropboxPathSelect, self.pushButtonFolderSelectionBack,
                  self.pushButtonFolderSelectionSelect, self.pushButtonAuthPageCancel,
                  self.pushButtonDropboxPathCalcel, self.pushButtonClose):
            b.setMinimumWidth(width)
            b.setMaximumWidth(width)

        # set up combobox
        self.dropbox_location = osp.dirname(self._conf.get('main', 'path')) or get_home_dir()
        relative_path = self.rel_path(self.dropbox_location)

        folder_icon = get_native_item_icon(self.dropbox_location)
        self.comboBoxDropboxPath.addItem(folder_icon, relative_path)

        self.comboBoxDropboxPath.insertSeparator(1)
        self.comboBoxDropboxPath.addItem(QtGui.QIcon(), 'Other...')
        self.comboBoxDropboxPath.currentIndexChanged.connect(self.on_combobox)
        self.dropbox_folder_dialog = QtWidgets.QFileDialog(self)
        self.dropbox_folder_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptOpen)
        self.dropbox_folder_dialog.setFileMode(QtWidgets.QFileDialog.Directory)
        self.dropbox_folder_dialog.setOption(QtWidgets.QFileDialog.ShowDirsOnly, True)
        self.dropbox_folder_dialog.fileSelected.connect(self.on_new_dbx_folder)
        self.dropbox_folder_dialog.rejected.connect(
                lambda: self.comboBoxDropboxPath.setCurrentIndex(0))

        # connect buttons to callbacks
        self.setAttribute(Qt.WA_DeleteOnClose)
        self.pushButtonLink.clicked.connect(self.on_link)
        self.pushButtonAuthPageCancel.clicked.connect(self.on_reject_requested)
        self.pushButtonAuthPageLink.clicked.connect(self.on_auth_clicked)
        self.pushButtonDropboxPathCalcel.clicked.connect(self.on_reject_requested)
        self.pushButtonDropboxPathSelect.clicked.connect(self.on_dropbox_location_selected)
        self.pushButtonDropboxPathUnlink.clicked.connect(self.unlink_and_go_to_start)
        self.pushButtonFolderSelectionBack.clicked.connect(self.stackedWidget.slideInPrev)
        self.pushButtonFolderSelectionSelect.clicked.connect(self.on_folders_selected)
        self.pushButtonClose.clicked.connect(self.on_accept_requested)
        self.selectAllCheckBox.clicked.connect(self.on_select_all_clicked)

        default_dir_name = self._conf.get('main', 'default_dir_name')

        self.labelDropboxPath.setText(self.labelDropboxPath.text().format(default_dir_name))

        # check if we are already authenticated, skip authentication if yes
        if not pending_link:
            start_maestral_daemon_thread(self._config_name, run=False)
            self.mdbx = get_maestral_proxy(self._config_name)
            self.mdbx.get_account_info()
            self.labelDropboxPath.setText("""
            <html><head/><body>
            <p align="left">
            Your Dropbox folder has been moved or deleted from its original location.
            Maestral will not work properly until you move it back. It used to be located
            at: </p><p align="left">{0}</p>
            <p align="left">
            To move it back, click "Quit" below, move the Dropbox folder back to its
            original location, and launch Maestral again.
            </p>
            <p align="left">
            To re-download your Dropbox, please select a location for your Dropbox
            folder below. Maestral will create a new folder named "{1}" in the
            selected location.</p>
            <p align="left">
            To unlink your Dropbox account from Maestral, click "Unlink" below.</p>
            </body></html>
            """.format(self._conf.get('main', 'path'), default_dir_name))
            self.pushButtonDropboxPathCalcel.setText('Quit')
            self.stackedWidget.setCurrentIndex(2)
            self.stackedWidgetButtons.setCurrentIndex(2)
        else:
            self.stackedWidget.setCurrentIndex(0)
            self.stackedWidgetButtons.setCurrentIndex(0)

# =============================================================================
# Main callbacks
# =============================================================================

    def closeEvent(self, event):

        if self.stackedWidget.currentIndex == 4:
            self.on_accept_requested()
        else:
            self.on_reject_requested()

    @QtCore.pyqtSlot()
    def on_accept_requested(self):
        del self.mdbx

        self.accepted = True
        self.accept()

    @QtCore.pyqtSlot()
    def on_reject_requested(self):
        self.accepted = False
        self.reject()

    def unlink_and_go_to_start(self):
        self.mdbx.unlink()
        self.stackedWidget.slideInIdx(0)

    @QtCore.pyqtSlot()
    def on_link(self):
        self.auth_session = OAuth2Session(self._config_name)
        self.auth_url = self.auth_session.get_auth_url()
        prompt = self.labelAuthLink.text().format(self.auth_url)
        self.labelAuthLink.setText(prompt)

        self.stackedWidget.fadeInIdx(1)
        self.pushButtonAuthPageLink.setFocus()

    @QtCore.pyqtSlot()
    def on_auth_clicked(self):

        if self.lineEditAuthCode.text() == '':
            msg = 'Please enter an authentication token.'
            msg_box = UserDialog('Authentication failed.', msg, parent=self)
            msg_box.open()
        else:
            self.progressIndicator.startAnimation()
            self.pushButtonAuthPageLink.setEnabled(False)
            self.lineEditAuthCode.setEnabled(False)

            self.verify_token_async()

    def verify_token_async(self):

        token = self.lineEditAuthCode.text()

        self.auth_task = BackgroundTask(
            parent=self,
            target=self.auth_session.verify_auth_token,
            args=(token,)
        )
        self.auth_task.sig_done.connect(self.on_verify_token_finished)

    def on_verify_token_finished(self, res):

        if res == OAuth2Session.Success:
            self.auth_session.save_creds()

            # switch to next page
            self.stackedWidget.slideInIdx(2)
            self.pushButtonDropboxPathSelect.setFocus()
            self.lineEditAuthCode.clear()  # clear since we might come back on unlink

            # start Maestral after linking to Dropbox account
            start_maestral_daemon_thread(self._config_name, run=False)
            self.mdbx = get_maestral_proxy(self._config_name)
            self.mdbx.reset_sync_state()
            self.mdbx.get_account_info()
        elif res == OAuth2Session.InvalidToken:
            msg = 'Please make sure that you entered the correct authentication token.'
            msg_box = UserDialog('Authentication failed.', msg, parent=self)
            msg_box.open()
        elif res == OAuth2Session.ConnectionFailed:
            msg = 'Please make sure that you are connected to the internet and try again.'
            msg_box = UserDialog('Connection failed.', msg, parent=self)
            msg_box.open()

        self.progressIndicator.stopAnimation()
        self.pushButtonAuthPageLink.setEnabled(True)
        self.lineEditAuthCode.setEnabled(True)

    @QtCore.pyqtSlot()
    def on_dropbox_location_selected(self):

        # start with clean sync state
        self.mdbx.reset_sync_state()

        # apply dropbox path
        dropbox_path = osp.join(self.dropbox_location, self.mdbx.get_conf('main', 'default_dir_name'))

        if osp.exists(dropbox_path):
            if osp.isdir(dropbox_path):
                msg_box = UserDialog(
                    title='Folder already exists',
                    message=(f'The folder "{dropbox_path}" already exists. Would '
                             'you like to keep using it?'),
                    button_names=('Replace', 'Cancel', 'Keep'),
                    parent=self,
                )
                msg_box.setAcceptButtonIcon('edit-clear')
                res = msg_box.exec_()

            else:
                dir_name = self.mdbx.get_conf('main', 'default_dir_name')
                msg_box = UserDialog(
                    title='File conflict',
                    message=(f'There already is a file named "{dir_name}" at this '
                             'location. Would you like to replace it?'),
                    button_names=('Replace', 'Cancel'),
                    parent=self,
                )
                res = msg_box.exec_()

            if res == UserDialog.Rejected:
                return
            elif res == UserDialog.Accepted:
                err = delete(dropbox_path)
                if err:
                    msg_box = UserDialog(
                        title='Could not write to destination',
                        message=('Please check if you have permissions to write to the '
                                 'selected location.'),
                        parent=self
                    )
                    msg_box.exec_()
                    return
            elif res == UserDialog.Accepted2:
                pass

        try:
            self.mdbx.create_dropbox_directory(dropbox_path)
        except OSError:
            msg_box = UserDialog(
                title='Could not create directory',
                message=('Please check if you have permissions to write to the '
                         'selected location.'),
                parent=self
            )
            msg_box.exec_()
            return

        # switch to next page
        self.mdbx.set_conf('main', 'excluded_items', [])
        self.stackedWidget.slideInIdx(3)
        self.treeViewFolders.setFocus()

        # populate folder list
        if not self.excluded_items:  # don't repopulate
            self.populate_folders_list()

    @QtCore.pyqtSlot()
    def on_folders_selected(self):

        self.update_selection()
        # this won't trigger downloads because we have not yet performed our first sync
        self.mdbx.set_excluded_items(self.excluded_items)

        # if any excluded items are currently on the drive, delete them
        for item in self.excluded_items:
            local_item = self.mdbx.to_local_path(item)
            delete(local_item)

        # switch to next page
        self.stackedWidget.slideInIdx(4)

# =============================================================================
# Helper functions
# =============================================================================

    @QtCore.pyqtSlot(int)
    def on_combobox(self, idx):
        if idx == 2:
            self.dropbox_folder_dialog.open()

    @QtCore.pyqtSlot(str)
    def on_new_dbx_folder(self, new_location):
        self.comboBoxDropboxPath.setCurrentIndex(0)
        if not new_location == '':
            self.comboBoxDropboxPath.setItemText(0, self.rel_path(new_location))
            self.comboBoxDropboxPath.setItemIcon(0, get_native_item_icon(new_location))

        self.dropbox_location = new_location

    def populate_folders_list(self):
        self.async_loader = AsyncListFolder(self.mdbx, self)
        self.dbx_root = DropboxPathModel(self.mdbx, self.async_loader, '/')
        self.dbx_model = TreeModel(self.dbx_root)
        self.dbx_model.dataChanged.connect(self.update_select_all_checkbox)
        self.treeViewFolders.setModel(self.dbx_model)

        self.dbx_model.loading_done.connect(
            lambda: self.pushButtonFolderSelectionSelect.setEnabled(True))
        self.dbx_model.loading_failed.connect(
            lambda: self.pushButtonFolderSelectionSelect.setEnabled(False))

        self.dbx_model.loading_done.connect(
            lambda: self.selectAllCheckBox.setEnabled(True))
        self.dbx_model.loading_failed.connect(
            lambda: self.selectAllCheckBox.setEnabled(False))

    @QtCore.pyqtSlot()
    def update_select_all_checkbox(self):
        check_states = []
        for irow in range(self.dbx_model._root_item.child_count_loaded()):
            index = self.dbx_model.index(irow, 0, QModelIndex())
            check_states.append(self.dbx_model.data(index, Qt.CheckStateRole))
        if all(cs == 2 for cs in check_states):
            self.selectAllCheckBox.setChecked(True)
        else:
            self.selectAllCheckBox.setChecked(False)

    @QtCore.pyqtSlot(bool)
    def on_select_all_clicked(self, checked):
        checked_state = 2 if checked else 0
        for irow in range(self.dbx_model._root_item.child_count_loaded()):
            index = self.dbx_model.index(irow, 0, QModelIndex())
            self.dbx_model.setCheckState(index, checked_state)

    def update_selection(self, index=QModelIndex()):

        if index.isValid():
            item = index.internalPointer()
            item_dbx_path = item._path.lower()

            # We have started with all folders included. Therefore just append excluded
            # folders here.
            if item.checkState == 0:
                self.excluded_items.append(item_dbx_path)
        else:
            item = self.dbx_model._root_item

        for row in range(item.child_count_loaded()):
            index_child = self.dbx_model.index(row, 0, index)
            self.update_selection(index=index_child)

    @staticmethod
    def rel_path(path):
        """
        Returns the path relative to the users directory, or the absolute
        path if not in a user directory.
        """
        usr = osp.abspath(osp.join(get_home_dir(), osp.pardir))
        if osp.commonprefix([path, usr]) == usr:
            return osp.relpath(path, usr)
        else:
            return path

    def changeEvent(self, QEvent):

        if QEvent.type() == QtCore.QEvent.PaletteChange:
            self.update_dark_mode()

    def update_dark_mode(self):
        if self.dbx_model:
            self.dbx_model.reloadData([Qt.DecorationRole])  # reload folder icons

    # static method to create the dialog and return Maestral instance on success
    @staticmethod
    def configureMaestral(config_name='maestral', pending_link=True, parent=None):
        fsd = SetupDialog(config_name, pending_link, parent)
        fsd.show()
        fsd.exec_()

        return fsd.accepted
Beispiel #21
0
    def __init__(self, config_name='maestral', pending_link=True, parent=None):
        super().__init__(parent=parent)
        # load user interface layout from .ui file
        uic.loadUi(SETUP_DIALOG_PATH, self)
        self.setWindowFlags(Qt.WindowStaysOnTopHint)

        self._config_name = config_name
        self._conf = MaestralConfig(config_name)
        self._state = MaestralState(config_name)

        self.app_icon = QtGui.QIcon(APP_ICON_PATH)

        self.labelIcon_0.setPixmap(icon_to_pixmap(self.app_icon, 150))
        self.labelIcon_1.setPixmap(icon_to_pixmap(self.app_icon, 70))
        self.labelIcon_2.setPixmap(icon_to_pixmap(self.app_icon, 70))
        self.labelIcon_3.setPixmap(icon_to_pixmap(self.app_icon, 120))

        self.mdbx = None
        self.dbx_model = None
        self.excluded_items = []

        # resize dialog buttons
        width = self.pushButtonAuthPageCancel.width()*1.1
        for b in (self.pushButtonAuthPageLink, self.pushButtonDropboxPathUnlink,
                  self.pushButtonDropboxPathSelect, self.pushButtonFolderSelectionBack,
                  self.pushButtonFolderSelectionSelect, self.pushButtonAuthPageCancel,
                  self.pushButtonDropboxPathCalcel, self.pushButtonClose):
            b.setMinimumWidth(width)
            b.setMaximumWidth(width)

        # set up combobox
        self.dropbox_location = osp.dirname(self._conf.get('main', 'path')) or get_home_dir()
        relative_path = self.rel_path(self.dropbox_location)

        folder_icon = get_native_item_icon(self.dropbox_location)
        self.comboBoxDropboxPath.addItem(folder_icon, relative_path)

        self.comboBoxDropboxPath.insertSeparator(1)
        self.comboBoxDropboxPath.addItem(QtGui.QIcon(), 'Other...')
        self.comboBoxDropboxPath.currentIndexChanged.connect(self.on_combobox)
        self.dropbox_folder_dialog = QtWidgets.QFileDialog(self)
        self.dropbox_folder_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptOpen)
        self.dropbox_folder_dialog.setFileMode(QtWidgets.QFileDialog.Directory)
        self.dropbox_folder_dialog.setOption(QtWidgets.QFileDialog.ShowDirsOnly, True)
        self.dropbox_folder_dialog.fileSelected.connect(self.on_new_dbx_folder)
        self.dropbox_folder_dialog.rejected.connect(
                lambda: self.comboBoxDropboxPath.setCurrentIndex(0))

        # connect buttons to callbacks
        self.setAttribute(Qt.WA_DeleteOnClose)
        self.pushButtonLink.clicked.connect(self.on_link)
        self.pushButtonAuthPageCancel.clicked.connect(self.on_reject_requested)
        self.pushButtonAuthPageLink.clicked.connect(self.on_auth_clicked)
        self.pushButtonDropboxPathCalcel.clicked.connect(self.on_reject_requested)
        self.pushButtonDropboxPathSelect.clicked.connect(self.on_dropbox_location_selected)
        self.pushButtonDropboxPathUnlink.clicked.connect(self.unlink_and_go_to_start)
        self.pushButtonFolderSelectionBack.clicked.connect(self.stackedWidget.slideInPrev)
        self.pushButtonFolderSelectionSelect.clicked.connect(self.on_folders_selected)
        self.pushButtonClose.clicked.connect(self.on_accept_requested)
        self.selectAllCheckBox.clicked.connect(self.on_select_all_clicked)

        default_dir_name = self._conf.get('main', 'default_dir_name')

        self.labelDropboxPath.setText(self.labelDropboxPath.text().format(default_dir_name))

        # check if we are already authenticated, skip authentication if yes
        if not pending_link:
            start_maestral_daemon_thread(self._config_name, run=False)
            self.mdbx = get_maestral_proxy(self._config_name)
            self.mdbx.get_account_info()
            self.labelDropboxPath.setText("""
            <html><head/><body>
            <p align="left">
            Your Dropbox folder has been moved or deleted from its original location.
            Maestral will not work properly until you move it back. It used to be located
            at: </p><p align="left">{0}</p>
            <p align="left">
            To move it back, click "Quit" below, move the Dropbox folder back to its
            original location, and launch Maestral again.
            </p>
            <p align="left">
            To re-download your Dropbox, please select a location for your Dropbox
            folder below. Maestral will create a new folder named "{1}" in the
            selected location.</p>
            <p align="left">
            To unlink your Dropbox account from Maestral, click "Unlink" below.</p>
            </body></html>
            """.format(self._conf.get('main', 'path'), default_dir_name))
            self.pushButtonDropboxPathCalcel.setText('Quit')
            self.stackedWidget.setCurrentIndex(2)
            self.stackedWidgetButtons.setCurrentIndex(2)
        else:
            self.stackedWidget.setCurrentIndex(0)
            self.stackedWidgetButtons.setCurrentIndex(0)
Beispiel #22
0
class MaestralDesktopNotifier(logging.Handler):
    """
    Can be used as a standalone notifier or as a logging handler.
    When used as a logging handler, the log level should be set with ``setLevel``. The
    ``notify_level`` will be applied in addition to the log level.
    """

    _instances = {}

    @classmethod
    def for_config(cls, config_name):
        """
        Returns an existing instance for the config or creates a new one if none exists.
        """

        if config_name in cls._instances:
            return cls._instances[config_name]
        else:
            instance = cls(config_name)
            cls._instances[config_name] = instance
            return instance

    def __init__(self, config_name):
        super().__init__()
        self.setFormatter(logging.Formatter(fmt="%(message)s"))
        self._conf = MaestralConfig(config_name)
        self._snooze = 0

    @property
    def notify_level(self):
        """Custom notification level."""
        return self._conf.get('app', 'notification_level')

    @notify_level.setter
    def notify_level(self, level):
        """Setter: Custom notification level."""
        assert isinstance(level, int)
        self._conf.set('app', 'notification_level', level)

    @property
    def snoozed(self):
        """
        Time in minutes to snooze notifications. Applied to FILECHANGE level only.
        """
        return max(0.0, (self._snooze - time.time()) / 60)

    @snoozed.setter
    def snoozed(self, minutes):
        self._snooze = time.time() + minutes * 60

    def notify(self, message, level):

        ignore = self.snoozed and level == FILECHANGE

        if level >= self.notify_level and not ignore:
            system_notifier.send(
                title='Maestral',
                message=message,
                icon_path=APP_ICON_PATH
            )

    def emit(self, record):
        self.format(record)
        self.notify(record.message, level=record.levelno)
Beispiel #23
0
class OAuth2Session:
    """
    OAuth2Session provides OAuth2 login and token store. To authenticate with Dropbox,
    run ``get_auth_url`` first and direct the user to visit that URL and retrieve an auth
    token. Verify the provided auth token with ``verify_auth_token`` and save it in the
    system keyring together with the corresponding Dropbox ID by calling ``save_creds``.

    The convenience method ``link`` runs through the above auth flow in a command line
    user dialog.
    """

    oAuth2FlowResult = None

    Success = 0
    InvalidToken = 1
    ConnectionFailed = 2

    def __init__(self, config_name):

        self._conf = MaestralConfig(config_name)

        self.account_id = self._conf.get("account", "account_id")
        self.access_token = ""

        self.auth_flow = None

    def load_token(self):
        """
        Check if auth key has been saved.

        :raises: ``KeyringLocked`` if the system keyring cannot be accessed.
        """
        logger.debug("Using keyring: %s" % keyring.get_keyring())
        try:
            if self.account_id == "":
                self.access_token = None
            else:
                self.access_token = keyring.get_password(
                    "Maestral", self.account_id)
            return self.access_token
        except KeyringLocked:
            info = "Please make sure that your keyring is unlocked and restart Maestral."
            raise KeyringLocked(info)

    def get_auth_url(self):
        """Gets the auth URL to start the OAuth2 implicit grant flow."""

        self.auth_flow = DropboxOAuth2FlowImplicit(APP_KEY)
        authorize_url = self.auth_flow.start()
        return authorize_url

    def verify_auth_token(self, token):
        """
        Verify the provided authorization token with Dropbox servers.

        :returns: OAuth2Session.Success, OAuth2Session.InvalidToken, or
            OAuth2Session.ConnectionFailed
        :rtype: int
        """

        if not self.auth_flow:
            raise RuntimeError(
                'Auth flow not yet started. Please call \'get_auth_url\'.')

        try:
            self.oAuth2FlowResult = self.auth_flow.finish(token)
            self.access_token = self.oAuth2FlowResult.access_token
            self.account_id = self.oAuth2FlowResult.account_id
            return self.Success
        except DropboxAuthError:
            return self.InvalidToken
        except ConnectionError:
            return self.ConnectionFailed

    def save_creds(self):
        """Saves auth key to system keyring."""
        self._conf.set("account", "account_id", self.account_id)
        try:
            keyring.set_password("Maestral", self.account_id,
                                 self.access_token)
            click.echo(" > Credentials written.")
        except KeyringLocked:
            logger.error(
                "Could not access the user keyring to save your authentication "
                "token. Please make sure that the keyring is unlocked.")

    def link(self):
        """
        Command line flow to get an auth key from Dropbox and save it in the system
        keyring.
        """
        authorize_url = self.get_auth_url()
        click.echo("1. Go to: " + authorize_url)
        click.echo("2. Click \"Allow\" (you might have to log in first).")
        click.echo("3. Copy the authorization token.")

        res = self.InvalidToken
        while res != self.Success:
            auth_code = click.prompt("Enter the authorization token here",
                                     type=str)
            auth_code = auth_code.strip()
            res = self.verify_auth_token(auth_code)

            if res == self.InvalidToken:
                click.secho("Invalid token. Please try again.", fg='red')
            elif res == self.ConnectionFailed:
                click.secho("Could not connect to Dropbox. Please try again.",
                            fg='red')

        self.save_creds()

    def delete_creds(self):
        """Deletes auth key from system keyring."""
        self._conf.set("account", "account_id", "")
        try:
            keyring.delete_password("Maestral", self.account_id)
            click.echo(" > Credentials removed.")
        except KeyringLocked:
            logger.error(
                "Could not access the user keyring to delete your authentication"
                " token. Please make sure that the keyring is unlocked.")
Beispiel #24
0
class MaestralDesktopNotifier(logging.Handler):
    """
    Can be used as a standalone notifier or as a logging handler. When used as a logging
    handler, the log level should be set with ``setLevel``. The ``notify_level`` will be
    applied in addition to the log level.

    :cvar int NONE: Notification level for no desktop notifications.
    :cvar int ERROR: Notification level for errors.
    :cvar int SYNCISSUE: Notification level for sync issues.
    :cvar int FILECHANGE: Notification level for file changes.
    """

    _instances: ClassVar[Dict[str, "MaestralDesktopNotifier"]] = dict()
    _lock = Lock()

    NONE = 100
    ERROR = 40
    SYNCISSUE = 30
    FILECHANGE = 15

    _levelToName = {
        NONE: "NONE",
        ERROR: "ERROR",
        SYNCISSUE: "SYNCISSUE",
        FILECHANGE: "FILECHANGE",
    }

    _nameToLevel = {
        "NONE": 100,
        "ERROR": 40,
        "SYNCISSUE": 30,
        "FILECHANGE": 15,
    }

    @classmethod
    def level_number_to_name(cls, number: int) -> str:
        """Converts a Maestral notification level number to name."""
        return cls._levelToName[number]

    @classmethod
    def level_name_to_number(cls, name: str) -> int:
        """Converts a Maestral notification level name to number."""
        return cls._nameToLevel[name]

    @classmethod
    def for_config(cls, config_name: str) -> "MaestralDesktopNotifier":
        """
        Returns an existing instance for the config or creates a new one if none exists.
        Use this method to prevent creating multiple instances.

        :param config_name: Name of maestral config.
        """

        with cls._lock:
            try:
                return cls._instances[config_name]
            except KeyError:
                instance = cls(config_name)
                cls._instances[config_name] = instance
                return instance

    def __init__(self, config_name: str) -> None:
        super().__init__()
        self.setFormatter(logging.Formatter(fmt="%(message)s"))
        self._conf = MaestralConfig(config_name)
        self._snooze = 0.0
        self._system_notifier = DesktopNotifier(APP_NAME, BUNDLE_ID)

    @property
    def notify_level(self) -> int:
        """Custom notification level. Notifications with a lower level will be
        discarded."""
        return self._conf.get("app", "notification_level")

    @notify_level.setter
    def notify_level(self, level: int) -> None:
        """Setter: notify_level."""
        self._conf.set("app", "notification_level", level)

    @property
    def snoozed(self) -> float:
        """Time in minutes to snooze notifications. Applied to FILECHANGE level only."""
        return max(0.0, (self._snooze - time.time()) / 60.0)

    @snoozed.setter
    def snoozed(self, minutes: float) -> None:
        """Setter: snoozed."""
        self._snooze = time.time() + minutes * 60.0

    def notify(
        self,
        title: str,
        message: str,
        level: int = FILECHANGE,
        on_click: Optional[Callable] = None,
        buttons: Optional[Dict[str, Optional[Callable]]] = None,
    ) -> None:
        """
        Sends a desktop notification from maestral. The title defaults to 'Maestral'.

        :param title: Notification title.
        :param message: Notification message.
        :param level: Notification level of the message.
        :param on_click: A callback to execute when the notification is clicked. The
            provided callable must not take any arguments.
        :param buttons: A dictionary with button names and callbacks for the
            notification.
        """

        ignore = self.snoozed and level == MaestralDesktopNotifier.FILECHANGE
        if level == MaestralDesktopNotifier.ERROR:
            urgency = NotificationLevel.Critical
        else:
            urgency = NotificationLevel.Normal

        if level >= self.notify_level and not ignore:
            self._system_notifier.send(
                title=title,
                message=message,
                icon=APP_ICON_PATH,
                urgency=urgency,
                action=on_click,
                buttons=buttons,
            )

    def emit(self, record: logging.LogRecord) -> None:
        """Emits a log record as a desktop notification."""

        # avoid recursive notifications from our own logger
        if not record.name.startswith(__name__):
            self.format(record)
            self.notify(record.levelname, record.message, level=record.levelno)
Beispiel #25
0
 def __init__(self, config_name: str) -> None:
     super().__init__()
     self.setFormatter(logging.Formatter(fmt="%(message)s"))
     self._conf = MaestralConfig(config_name)
     self._snooze = 0.0
     self._system_notifier = DesktopNotifier(APP_NAME, BUNDLE_ID)
Beispiel #26
0
 def __init__(self, config_name: str) -> None:
     self._conf = MaestralConfig(config_name)
     self._snooze = 0.0
Beispiel #27
0
class MaestralDesktopNotifier:
    """Desktop notification emitter for Maestral

    Desktop notifier with snooze functionality and variable notification levels.

    :cvar int NONE: Notification level for no desktop notifications.
    :cvar int ERROR: Notification level for errors.
    :cvar int SYNCISSUE: Notification level for sync issues.
    :cvar int FILECHANGE: Notification level for file changes.
    """

    _instances: ClassVar[Dict[str, "MaestralDesktopNotifier"]] = dict()
    _lock = Lock()

    NONE = 100
    ERROR = 40
    SYNCISSUE = 30
    FILECHANGE = 15

    _levelToName = {
        NONE: "NONE",
        ERROR: "ERROR",
        SYNCISSUE: "SYNCISSUE",
        FILECHANGE: "FILECHANGE",
    }

    _nameToLevel = {
        "NONE": 100,
        "ERROR": 40,
        "SYNCISSUE": 30,
        "FILECHANGE": 15,
    }

    @classmethod
    def level_number_to_name(cls, number: int) -> str:
        """Converts a Maestral notification level number to name."""
        return cls._levelToName[number]

    @classmethod
    def level_name_to_number(cls, name: str) -> int:
        """Converts a Maestral notification level name to number."""
        return cls._nameToLevel[name]

    def __init__(self, config_name: str) -> None:
        self._conf = MaestralConfig(config_name)
        self._snooze = 0.0

    @property
    def notify_level(self) -> int:
        """Custom notification level. Notifications with a lower level will be
        discarded."""
        return self._conf.get("app", "notification_level")

    @notify_level.setter
    def notify_level(self, level: int) -> None:
        """Setter: notify_level."""
        self._conf.set("app", "notification_level", level)

    @property
    def snoozed(self) -> float:
        """Time in minutes to snooze notifications. Applied to FILECHANGE level only."""
        return max(0.0, (self._snooze - time.time()) / 60.0)

    @snoozed.setter
    def snoozed(self, minutes: float) -> None:
        """Setter: snoozed."""
        self._snooze = time.time() + minutes * 60.0

    def notify(
        self,
        title: str,
        message: str,
        level: int = FILECHANGE,
        on_click: Optional[Callable] = None,
        buttons: Optional[Dict[str, Optional[Callable]]] = None,
    ) -> None:
        """
        Sends a desktop notification.

        :param title: Notification title.
        :param message: Notification message.
        :param level: Notification level of the message.
        :param on_click: A callback to execute when the notification is clicked. The
            provided callable must not take any arguments.
        :param buttons: A dictionary with button names and callbacks for the
            notification.
        """

        ignore = self.snoozed and level == MaestralDesktopNotifier.FILECHANGE
        if level == MaestralDesktopNotifier.ERROR:
            urgency = NotificationLevel.Critical
        else:
            urgency = NotificationLevel.Normal

        if level >= self.notify_level and not ignore:
            _desktop_notifier_maestral.send(
                title=title,
                message=message,
                icon=APP_ICON_PATH,
                urgency=urgency,
                action=on_click,
                buttons=buttons,
            )
Beispiel #28
0
 def __init__(self, config_name):
     super().__init__()
     self.setFormatter(logging.Formatter(fmt="%(message)s"))
     self._conf = MaestralConfig(config_name)
     self._snooze = 0
Beispiel #29
0
class OAuth2Session:
    """
    OAuth2Session provides OAuth 2 login and token store. To authenticate with Dropbox,
    run ``get_auth_url`` first and direct the user to visit that URL and retrieve an auth
    token. Verify the provided auth token with ``verify_auth_token`` and save it in the
    system keyring together with the corresponding Dropbox ID by calling ``save_creds``.
    The convenience method ``link`` runs through the above auth flow in a command line
    user dialog.

    :param str config_name: Name of maestral config.

    :cvar int Success: Exit code for successful auth.
    :cvar int InvalidToken: Exit code for invalid token.
    :cvar int ConnectionFailed: Exit code for connection errors.
    """

    Success = 0
    InvalidToken = 1
    ConnectionFailed = 2

    def __init__(self, config_name):

        self.keyring = get_keyring_backend(config_name)
        self._conf = MaestralConfig(config_name)

        self.account_id = self._conf.get('account', 'account_id')
        self.access_token = ""

        self.auth_flow = None
        self.oAuth2FlowResult = None

    def load_token(self):
        """
        Load auth token from system keyring.

        :returns: Auth token.
        :rtype: str
        :raises: ``KeyringLocked`` if the system keyring cannot be accessed.
        """
        logger.debug(f'Using keyring: {self.keyring}')

        try:
            if self.account_id == '':
                self.access_token = None
            else:
                self.access_token = self.keyring.get_password(
                    'Maestral', self.account_id)
            return self.access_token
        except KeyringLocked:
            info = 'Please make sure that your keyring is unlocked and restart Maestral.'
            raise KeyringLocked(info)

    def get_auth_url(self):
        """
        Gets the auth URL to start the OAuth2 implicit grant flow.

        :returns: Dropbox auth URL.
        :rtype: str
        """

        self.auth_flow = DropboxOAuth2FlowImplicit(DROPBOX_APP_KEY)
        authorize_url = self.auth_flow.start()
        return authorize_url

    def verify_auth_token(self, token):
        """
        Verify the provided authorization token with Dropbox servers.

        :returns: :attr:`Success`, :attr:`InvalidToken`, or :attr:`ConnectionFailed`.
        :rtype: int
        """

        if not self.auth_flow:
            raise RuntimeError(
                'Auth flow not yet started. Please call "get_auth_url".')

        try:
            self.oAuth2FlowResult = self.auth_flow.finish(token)
            self.access_token = self.oAuth2FlowResult.access_token
            self.account_id = self.oAuth2FlowResult.account_id
            return self.Success
        except DropboxAuthError:
            return self.InvalidToken
        except ConnectionError:
            return self.ConnectionFailed

    def save_creds(self):
        """Saves auth key to system keyring."""
        self._conf.set('account', 'account_id', self.account_id)
        try:
            self.keyring.set_password('Maestral', self.account_id,
                                      self.access_token)
            click.echo(' > Credentials written.')
            if isinstance(self.keyring, keyrings.alt.file.PlaintextKeyring):
                click.echo(' > Warning: No supported keyring found, '
                           'Dropbox credentials stored in plain text.')
        except KeyringLocked:
            logger.error(
                'Could not access the user keyring to save your authentication '
                'token. Please make sure that the keyring is unlocked.')

    def link(self):
        """
        Command line flow to get an auth key from Dropbox and save it in the system
        keyring.
        """
        authorize_url = self.get_auth_url()
        click.echo('1. Go to: ' + authorize_url)
        click.echo('2. Click "Allow" (you might have to log in first).')
        click.echo('3. Copy the authorization token.')

        res = self.InvalidToken
        while res != self.Success:
            auth_code = click.prompt('Enter the authorization token here',
                                     type=str)
            auth_code = auth_code.strip()
            res = self.verify_auth_token(auth_code)

            if res == self.InvalidToken:
                click.secho('Invalid token. Please try again.', fg='red')
            elif res == self.ConnectionFailed:
                click.secho('Could not connect to Dropbox. Please try again.',
                            fg='red')

        self.save_creds()

    def delete_creds(self):
        """Deletes auth key from system keyring."""
        self._conf.set('account', 'account_id', "")
        try:
            self.keyring.delete_password('Maestral', self.account_id)
            click.echo(' > Credentials removed.')
        except KeyringLocked:
            logger.error(
                'Could not access the user keyring to delete your authentication'
                ' token. Please make sure that the keyring is unlocked.')