# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: # Copyright 2014-2017 Florian Bruhin (The Compiler) # # This file is part of qutebrowser. # # qutebrowser is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # qutebrowser is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with qutebrowser. If not, see . """Utilities to get and initialize data/config paths.""" import os import shutil import os.path import contextlib from PyQt5.QtCore import QStandardPaths from PyQt5.QtWidgets import QApplication from qutebrowser.utils import log, debug, usertypes, message, utils # The cached locations _locations = {} Location = usertypes.enum('Location', ['config', 'auto_config', 'data', 'system_data', 'cache', 'download', 'runtime']) APPNAME = 'qutebrowser' class EmptyValueError(Exception): """Error raised when QStandardPaths returns an empty value.""" @contextlib.contextmanager def _unset_organization(): """Temporarily unset QApplication.organizationName(). This is primarily needed in config.py. """ qapp = QApplication.instance() if qapp is not None: orgname = qapp.organizationName() qapp.setOrganizationName(None) try: yield finally: if qapp is not None: qapp.setOrganizationName(orgname) def _init_config(args): """Initialize the location for configs.""" typ = QStandardPaths.ConfigLocation overridden, path = _from_args(typ, args) if not overridden: if utils.is_windows: app_data_path = _writable_location( QStandardPaths.AppDataLocation) path = os.path.join(app_data_path, 'config') else: path = _writable_location(typ) _create(path) _locations[Location.config] = path _locations[Location.auto_config] = path # Override the normal (non-auto) config on macOS if utils.is_mac: overridden, path = _from_args(typ, args) if not overridden: # pragma: no branch path = os.path.expanduser('~/.' + APPNAME) _create(path) _locations[Location.config] = path def config(auto=False): """Get the location for the config directory. If auto=True is given, get the location for the autoconfig.yml directory, which is different on macOS. """ if auto: return _locations[Location.auto_config] return _locations[Location.config] def _init_data(args): """Initialize the location for data.""" typ = QStandardPaths.DataLocation overridden, path = _from_args(typ, args) if not overridden: if utils.is_windows: app_data_path = _writable_location(QStandardPaths.AppDataLocation) path = os.path.join(app_data_path, 'data') else: path = _writable_location(typ) _create(path) _locations[Location.data] = path # system_data _locations.pop(Location.system_data, None) # Remove old state if utils.is_linux: path = '/usr/share/' + APPNAME if os.path.exists(path): _locations[Location.system_data] = path def data(system=False): """Get the data directory. If system=True is given, gets the system-wide (probably non-writable) data directory. """ if system: try: return _locations[Location.system_data] except KeyError: pass return _locations[Location.data] def _init_cache(args): """Initialize the location for the cache.""" typ = QStandardPaths.CacheLocation overridden, path = _from_args(typ, args) if not overridden: if utils.is_windows: # Local, not Roaming! data_path = _writable_location(QStandardPaths.DataLocation) path = os.path.join(data_path, 'cache') else: path = _writable_location(typ) _create(path) _locations[Location.cache] = path def cache(): return _locations[Location.cache] def _init_download(args): """Initialize the location for downloads. Note this is only the default directory as found by Qt. Therefore, we also don't create it. """ typ = QStandardPaths.DownloadLocation overridden, path = _from_args(typ, args) if not overridden: path = _writable_location(typ) _locations[Location.download] = path def download(): return _locations[Location.download] def _init_runtime(args): """Initialize location for runtime data.""" if utils.is_linux: typ = QStandardPaths.RuntimeLocation else: # RuntimeLocation is a weird path on macOS and Windows. typ = QStandardPaths.TempLocation overridden, path = _from_args(typ, args) if not overridden: try: path = _writable_location(typ) except EmptyValueError: # Fall back to TempLocation when RuntimeLocation is misconfigured if typ == QStandardPaths.TempLocation: raise path = _writable_location(QStandardPaths.TempLocation) # This is generic, but per-user. # _writable_location makes sure we have a qutebrowser-specific subdir. # # For TempLocation: # "The returned value might be application-specific, shared among # other applications for this user, or even system-wide." # # Unfortunately this path could get too long for sockets (which have a # maximum length of 104 chars), so we don't add the username here... _create(path) _locations[Location.runtime] = path def runtime(): return _locations[Location.runtime] def _writable_location(typ): """Wrapper around QStandardPaths.writableLocation. Arguments: typ: A QStandardPaths::StandardLocation member. """ typ_str = debug.qenum_key(QStandardPaths, typ) # Types we are sure we handle correctly below. assert typ in [ QStandardPaths.ConfigLocation, QStandardPaths.DataLocation, QStandardPaths.CacheLocation, QStandardPaths.DownloadLocation, QStandardPaths.RuntimeLocation, QStandardPaths.TempLocation, # FIXME old Qt getattr(QStandardPaths, 'AppDataLocation', object())], typ_str with _unset_organization(): path = QStandardPaths.writableLocation(typ) log.misc.debug("writable location for {}: {}".format(typ_str, path)) if not path: raise EmptyValueError("QStandardPaths returned an empty value!") # Qt seems to use '/' as path separator even on Windows... path = path.replace('/', os.sep) # Add the application name to the given path if needed. # This is in order for this to work without a QApplication (and thus # QStandardsPaths not knowing the application name), as well as a # workaround for https://bugreports.qt.io/browse/QTBUG-38872 if (typ != QStandardPaths.DownloadLocation and path.split(os.sep)[-1] != APPNAME): path = os.path.join(path, APPNAME) return path def _from_args(typ, args): """Get the standard directory from an argparse namespace. Args: typ: A member of the QStandardPaths::StandardLocation enum args: An argparse namespace or None. Return: A (override, path) tuple. override: boolean, if the user did override the path path: The overridden path, or None to turn off storage. """ basedir_suffix = { QStandardPaths.ConfigLocation: 'config', QStandardPaths.DataLocation: 'data', QStandardPaths.CacheLocation: 'cache', QStandardPaths.DownloadLocation: 'download', QStandardPaths.RuntimeLocation: 'runtime', } if getattr(args, 'basedir', None) is not None: basedir = args.basedir try: suffix = basedir_suffix[typ] except KeyError: # pragma: no cover return (False, None) return (True, os.path.abspath(os.path.join(basedir, suffix))) else: return (False, None) def _create(path): """Create the `path` directory. From the XDG basedir spec: If, when attempting to write a file, the destination directory is non-existent an attempt should be made to create it with permission 0700. If the destination directory exists already the permissions should not be changed. """ try: os.makedirs(path, 0o700) except FileExistsError: pass def _init_dirs(args=None): """Create and cache standard directory locations. Mainly in a separate function because we need to call it in tests. """ _init_config(args) _init_data(args) _init_cache(args) _init_download(args) _init_runtime(args) def init(args): """Initialize all standard dirs.""" if args is not None: # args can be None during tests log.init.debug("Base directory: {}".format(args.basedir)) _init_dirs(args) _init_cachedir_tag() if args is not None and getattr(args, 'basedir', None) is None: if utils.is_mac: # pragma: no cover _move_macos() elif utils.is_windows: # pragma: no cover _move_windows() def _move_macos(): """Move most config files to new location on macOS.""" old_config = config(auto=True) # ~/Library/Preferences/qutebrowser new_config = config() # ~/.qutebrowser for f in os.listdir(old_config): if f not in ['qsettings', 'autoconfig.yml']: _move_data(os.path.join(old_config, f), os.path.join(new_config, f)) def _move_windows(): """Move the whole qutebrowser directory from Local to Roaming AppData.""" # %APPDATA%\Local\qutebrowser old_appdata_dir = _writable_location(QStandardPaths.DataLocation) # %APPDATA%\Roaming\qutebrowser new_appdata_dir = _writable_location(QStandardPaths.AppDataLocation) # data subfolder old_data = os.path.join(old_appdata_dir, 'data') new_data = os.path.join(new_appdata_dir, 'data') ok = _move_data(old_data, new_data) if not ok: # pragma: no cover return # config files new_config_dir = os.path.join(new_appdata_dir, 'config') _create(new_config_dir) for f in os.listdir(old_appdata_dir): if f != 'cache': _move_data(os.path.join(old_appdata_dir, f), os.path.join(new_config_dir, f)) def _init_cachedir_tag(): """Create CACHEDIR.TAG if it doesn't exist. See http://www.brynosaurus.com/cachedir/spec.html """ cachedir_tag = os.path.join(cache(), 'CACHEDIR.TAG') if not os.path.exists(cachedir_tag): try: with open(cachedir_tag, 'w', encoding='utf-8') as f: f.write("Signature: 8a477f597d28d172789f06886806bc55\n") f.write("# This file is a cache directory tag created by " "qutebrowser.\n") f.write("# For information about cache directory tags, see:\n") f.write("# http://www.brynosaurus.com/" "cachedir/\n") except OSError: log.init.exception("Failed to create CACHEDIR.TAG") def _move_data(old, new): """Migrate data from an old to a new directory. If the old directory does not exist, the migration is skipped. If the new directory already exists, an error is shown. Return: True if moving succeeded, False otherwise. """ if not os.path.exists(old): return False log.init.debug("Migrating data from {} to {}".format(old, new)) if os.path.exists(new): if not os.path.isdir(new) or os.listdir(new): message.error("Failed to move data from {} as {} is non-empty!" .format(old, new)) return False os.rmdir(new) try: shutil.move(old, new) except OSError as e: message.error("Failed to move data from {} to {}: {}".format( old, new, e)) return False return True