├── .gitignore ├── requirements.txt ├── qutebrowser-profile.desktop ├── LICENSE ├── README.md └── qutebrowser-profile /.gitignore: -------------------------------------------------------------------------------- 1 | .mypy_cache 2 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==22.2.0 2 | cattrs==23.1.2 3 | click==8.1.7 4 | colorama==0.4.6 5 | pyyaml==6.0.1 6 | -------------------------------------------------------------------------------- /qutebrowser-profile.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Name=qutebrowser-profile 3 | Comment=Open with qutebrowser-profile 4 | Exec=qutebrowser-profile --choose %U 5 | Type=Application 6 | Categories=Network;WebBrowser; 7 | 8 | #MimeType=x-scheme-handler/unknown;x-scheme-handler/about;x-scheme-handler/https;x-scheme-handler/http;text/html; 9 | MimeType=x-scheme-handler/unknown;x-scheme-handler/about;application/rdf+xml;application/rss+xml;application/xhtml+xml;application/xhtml_xml;application/xml;image/gif;image/jpeg;image/png;image/webp;text/html;text/xml;x-scheme-handler/ftp;x-scheme-handler/http;x-scheme-handler/https; 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-2023 Jonny Tyers 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # qutebrowser-profile 2 | 3 | A simple wrapper script for qutebrowser that allows you to maintain different profiles, each with their own history and session state but sharing the same `config.py`. 4 | 5 | ## Why? 6 | 7 | I use my system for different projects and purposes, such as *email*, *cloud development*, *web browsing* and so on. Using `qutebrowser-profile` I can keep all of these in separate qutebrowser profiles. (I in fact also keep them in separate i3 profiles via [i3-launcher](https://github.com/jtyers/i3-launcher) too, but that's another story). 8 | 9 | ## Installation 10 | 11 | Clone this repository and add it to your `$PATH`. 12 | 13 | The script depends on `dmenu`. Rofi is also supported and automatically used if available. You can also override, e.g. `--dmenu="rofi -dmenu"`. 14 | 15 | ### Arch Linux 16 | 17 | There is a [package](https://aur.archlinux.org/packages/qutebrowser-profile-git/) available in the AUR: 18 | 19 | ``` 20 | yay qutebrowser-profile-git 21 | ``` 22 | 23 | ## Getting started 24 | 25 | To create a new profile, just call the script: 26 | 27 | `qutebrowser-profile` 28 | 29 | You'll get a rofi prompt asking for a profile name (if you don't have `rofi` installed, the script will try to use `dmenu`, and fallback to asking via the terminal). Type one in and hit enter, and qutebrowser will load your profile. 30 | 31 | Note that: 32 | * qutebrowser's window will have `[my-profile-name]` at the start, so you can easily distinguish different qutebrowsers loaded with different profiles 33 | * qutebrowser loads configuration from the normal location (and all qutebrowsers share configuration regardless of profile, this includes quickmarks/bookmarks) 34 | * other data, such as session history, cache, cookies, etc, will be unique to that profile 35 | 36 | Credit to @ayekat for the inspiration for the approach. 37 | 38 | ## Other options 39 | 40 | Here's the full options list (also available with `--help`): 41 | 42 | ``` 43 | qutebrowser-profile - use qutebrowser with per-profile cache, session history, etc 44 | 45 | USAGE 46 | Usage: qutebrowser-profile [OPTIONS] [QB_ARGS]... 47 | 48 | Options: 49 | --profiles-root TEXT The directory to store profiles in 50 | --choose / --no-choose Prompt the user to choose a profile, then 51 | launch it (try rofi, dmenu then fallback to terminal) 52 | --load TEXT Load the given profile (fails if profile 53 | does not exist, see --new) 54 | --new / --no-new Allow --load to create a new profile if it 55 | does not exist 56 | --dmenu TEXT Override the location of dmenu/rofi when 57 | using --choose 58 | --only-existing / --no-only-existing 59 | Do not allow the user to specify a new (non- 60 | existent) profile during --choose 61 | -l, --list-profiles, --list / --no-list-profiles, --no-list 62 | List existing profiles 63 | --show-stdio / --no-show-stio Show stdout/stderr from qutebrowser when it 64 | is launched 65 | --qutebrowser TEXT Location of qutebrowser launcher 66 | --help Show this message and exit. 67 | ``` 68 | 69 | ## Licence 70 | 71 | MIT 72 | -------------------------------------------------------------------------------- /qutebrowser-profile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | # MIT License 4 | # 5 | # Copyright (c) 2018-2023 Jonny Tyers 6 | # 7 | # Permission is hereby granted, free of charge, to any person obtaining a copy 8 | # of this software and associated documentation files (the "Software"), to deal 9 | # in the Software without restriction, including without limitation the rights 10 | # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | # copies of the Software, and to permit persons to whom the Software is 12 | # furnished to do so, subject to the following conditions: 13 | # 14 | # The above copyright notice and this permission notice shall be included in all 15 | # copies or substantial portions of the Software. 16 | # 17 | # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | # SOFTWARE. 24 | 25 | from __future__ import annotations 26 | from attrs import define 27 | from cattrs import structure 28 | from cattrs import unstructure 29 | import colorama 30 | import click 31 | import os 32 | import shlex 33 | import shutil 34 | import subprocess 35 | import sys 36 | import yaml 37 | from typing import Any 38 | from typing import Generator 39 | from typing import Optional 40 | 41 | def print_msg(*msg): 42 | print(colorama.Fore.YELLOW, *msg, colorama.Fore.RESET, file=sys.stderr) 43 | 44 | def print_warning(*msg): 45 | print(colorama.Fore.RED, *msg, colorama.Fore.RESET, file=sys.stderr) 46 | 47 | 48 | def expand(path): 49 | if path is None: 50 | return None 51 | 52 | if type(path) is list: 53 | return [os.path.expanduser(os.path.expandvars(x)) for x in path] 54 | 55 | elif type(path) is str: 56 | return " ".join(expand(path.split(" "))) 57 | 58 | else: 59 | return os.path.expanduser(os.path.expandvars(path)) 60 | 61 | 62 | xdg_runtime_dir = os.environ.get( 63 | "XDG_RUNTIME_DIR", expand(f"/run/user/{os.getuid()}") 64 | ) 65 | xdg_config_home = os.environ.get("XDG_CONFIG_HOME", expand("$HOME/.config")) 66 | xdg_cache_home = os.environ.get("XDG_CACHE_HOME", expand("$HOME/.cache")) 67 | xdg_data_home = os.environ.get("XDG_DATA_HOME", expand("$HOME/.local/share")) 68 | 69 | @define 70 | class NoSuchProfileError(Exception): 71 | profile_name: str 72 | 73 | @define 74 | class ProfileAlreadyExistsError(Exception): 75 | profile_name: str 76 | 77 | @define 78 | class ConfigProfile: 79 | name: str 80 | args: list[str] = [] 81 | 82 | @define 83 | class Config: 84 | profiles: list[ConfigProfile] = [] 85 | 86 | @define 87 | class ConfigLoader: 88 | filename: str 89 | _cached_config: Optional[Config] = None 90 | 91 | def load(self) -> Config: 92 | if self._cached_config is None: 93 | if os.path.exists(self.filename): 94 | with open(self.filename, 'r') as f: 95 | data = yaml.safe_load(f) 96 | self._cached_config = structure(data, Config) 97 | else: 98 | self._cached_config = Config() 99 | 100 | return self._cached_config 101 | 102 | def get_or_create_profile(self, profile_name: str, args: list[str] = []) -> ConfigProfile: 103 | profile = self.get_profile(profile_name) 104 | if not profile: 105 | if not args: 106 | args = ['--set', 'window.title_format', f'{{perc}}{{title_sep}}{{current_title}} - qutebrowser [{profile_name}]'] 107 | profile = ConfigProfile(name=profile_name, args=args) 108 | self.load().profiles.append(profile) 109 | return profile 110 | 111 | def get_profile(self, profile_name: str) -> Optional[ConfigProfile]: 112 | for profile in self.load().profiles: 113 | if profile.name == profile_name: 114 | return profile 115 | 116 | return None 117 | 118 | def delete_profile(self, profile_name: str) -> None: 119 | config = self.load() 120 | config.profiles = list(filter(lambda x: x.name != profile_name, config.profiles)) 121 | 122 | def save(self, config: Optional[Config] = None): 123 | config_dir = os.path.dirname(self.filename) 124 | if not os.path.isdir(config_dir): 125 | os.makedirs(config_dir, exist_ok=True) 126 | 127 | data = unstructure(config or self.load()) 128 | 129 | with open(self.filename, 'w') as f: 130 | yaml.dump(data, f) 131 | 132 | 133 | @define 134 | class QutebrowserProfile: 135 | parent: QutebrowserProfiles 136 | profile_name: str 137 | config: Optional[ConfigProfile] 138 | session: str = 'default' 139 | 140 | @property 141 | def basedir(self): 142 | return os.path.join(f"{self.parent.xdg_runtime_dir}/qutebrowser/{self.profile_name}") 143 | 144 | def _dirs_and_links(self): 145 | return dict( 146 | dirs=[ 147 | self.basedir, 148 | f"{self.parent.xdg_cache_home}/qutebrowser/{self.profile_name}", 149 | f"{self.parent.xdg_data_home}/qutebrowser/{self.profile_name}", 150 | f"{self.basedir}/runtime", 151 | ], 152 | links=[ 153 | (f"{self.parent.xdg_cache_home}/qutebrowser/{self.profile_name}", f"{self.basedir}/cache"), 154 | (f"{self.parent.xdg_data_home}/qutebrowser/{self.profile_name}", f"{self.basedir}/data"), 155 | (f"{self.parent.xdg_config_home}/qutebrowser", f"{self.basedir}/config"), 156 | ], 157 | ) 158 | 159 | def mkbasedir(self): 160 | # https://github.com/ayekat/localdir/blob/35fa033fb1274807c907a4a83431d3a8222283f6/lib/dotfiles/wrappers/qutebrowser 161 | # https://wiki.archlinux.org/index.php/Qutebrowser#dwb-like_session_handling 162 | # 163 | # Wrapper around qutebrowser that makes sessions (-r, --restore SESSION) behave 164 | # like they used to in dwb. 165 | # 166 | # We do so by filtering out the -r/--restore option passed to qutebrowser and 167 | # using the argument to set up the following directory structure and symbolic 168 | # links: 169 | # 170 | # $XDG_RUNTIME_DIR/qutebrowser/$session/cache → $XDG_CACHE_HOME/qutebrowser/$session 171 | # $XDG_RUNTIME_DIR/qutebrowser/$session/data → $XDG_STATE_HOME/qutebrowser/$session 172 | # $XDG_RUNTIME_DIR/qutebrowser/$session/data/userscripts → $XDG_DATA_HOME/qutebrowser/userscripts 173 | # $XDG_RUNTIME_DIR/qutebrowser/$session/config → $XDG_CONFIG_HOME/qutebrowser 174 | # $XDG_RUNTIME_DIR/qutebrowser/$session/runtime (no symlink, regular directory) 175 | # 176 | # We then specify $XDG_RUNTIME_DIR/qutebrowser/$session as a --basedir, and the 177 | # files will end up in their intended locations (notice how the config directory 178 | # is the same for all sessions, as there is no point in keeping it separate). 179 | # 180 | # DISCLAIMER: The author of this script manages all his configuration files 181 | # manually, so this wrapper script has not been tested for the use case where 182 | # qutebrowser itself writes to these files (and more importantly, if multiple 183 | # such "sessions" simultaneously write to the same configuration file). 184 | # 185 | # YOU HAVE BEEN WARNED. 186 | # 187 | # Written by ayekat in an burst of nostalgy, on a mildly cold wednesday night in 188 | # February 2017. 189 | # 190 | # Enhanced a little by jonny on a dreary cold Friday morning in December 2018. 191 | # 192 | 193 | dirs_and_links = self._dirs_and_links() 194 | 195 | for d in dirs_and_links['dirs']: 196 | os.makedirs(d, exist_ok=True) 197 | 198 | for src, dst in dirs_and_links['links']: 199 | if os.path.exists(dst): 200 | os.unlink(dst) 201 | os.symlink(src, dst, target_is_directory=False) 202 | 203 | def remove(self): 204 | # Delete the profile's directories and all associated links from the filesystem 205 | dirs_and_links = self._dirs_and_links() 206 | 207 | # do links first, as some (or all) of them will reside in basedir, which is one of the dirs 208 | for src, dst in dirs_and_links['links']: 209 | if os.path.exists(dst): 210 | if not os.path.islink(dst): 211 | print_warning(f'warning: did not remove {dst} as it is not a symlink') 212 | 213 | else: 214 | print_msg(f'removing {dst}') 215 | os.unlink(dst) 216 | 217 | for d in dirs_and_links['dirs']: 218 | if os.path.exists(d): 219 | if not os.path.isdir(d): 220 | print_warning(f'warning: did not remove {d} as it is not a directory') 221 | else: 222 | print_msg(f'removing {d}') 223 | shutil.rmtree(d, ignore_errors=True) 224 | 225 | self.parent.config_loader.delete_profile(self.profile_name) 226 | 227 | 228 | @define 229 | class QutebrowserProfiles: 230 | profiles_root: str 231 | 232 | config_loader: ConfigLoader 233 | 234 | xdg_runtime_dir: str 235 | xdg_cache_home: str 236 | xdg_data_home: str 237 | xdg_config_home: str 238 | 239 | _profiles: Optional[list[QutebrowserProfile]] = None 240 | 241 | def _populate_profiles(self) -> list[QutebrowserProfile]: 242 | """Populates _profiles if needed, then returns them.""" 243 | if self._profiles is None: 244 | self._profiles = [] 245 | for item in os.listdir(self.profiles_root): 246 | if self._exists(item): 247 | config = self.config_loader.get_profile(item) 248 | self._profiles.append(QutebrowserProfile(parent=self, profile_name=item, config=config)) 249 | 250 | return self._profiles 251 | 252 | def profiles(self) -> list[QutebrowserProfile]: 253 | return list(self._populate_profiles()) # new list to prevent external changes 254 | 255 | def get_profile(self, profile_name: str) -> QutebrowserProfile: 256 | for profile in self._populate_profiles(): 257 | if profile.profile_name == profile_name: 258 | return profile 259 | 260 | raise NoSuchProfileError(profile_name) 261 | 262 | def _exists(self, profile_name): 263 | """Checks if a profile with the given name exists under profiles_root. Looks at the filesystem 264 | and not at the _profiles cache.""" 265 | item_path = os.path.join(self.profiles_root, profile_name) 266 | 267 | # our profilesRoot may contain dirs that are not qutebrowser profiles, so we look for 268 | # the 'state' file to determine whether something is a profile, and then pipe thru dirname 269 | # find "$profilesRoot" -mindepth 2 -maxdepth 2 -name state -type f -printf "%P\n" | xargs dirname 270 | return os.path.isdir(item_path) and os.path.exists(os.path.join(item_path, 'state')) 271 | 272 | def new(self, profile_name, qb_args: list[str] = []) -> QutebrowserProfile: 273 | """Creates a new QutebrowserProfile as part of this QutebrowserProfiles instance, including 274 | its profile dirs.""" 275 | if self._exists(profile_name): 276 | raise ProfileAlreadyExistsError(profile_name) 277 | 278 | # route via get_or_create_profile so that a) we pick up any user-defined config and b) profile creation 279 | # is centralised in one place 280 | config = self.config_loader.get_or_create_profile(profile_name, args=qb_args) 281 | 282 | result = QutebrowserProfile(parent=self, profile_name=profile_name, config=config) 283 | self._populate_profiles().append(result) 284 | result.mkbasedir() 285 | 286 | return result 287 | 288 | 289 | def run_qb(self, qutebrowser: str, profile: QutebrowserProfile, args: list[Optional[str]] = None, show_stdio: bool = False): 290 | """Run qutebrowser, loading the given profile. 291 | 292 | Args: 293 | qutebrowser - path to qutebrowser entrypoint 294 | profile - profile to load 295 | args - arguments to pass to qutebrowser beyond those to set the profile path (replaces any args specified in the profile's config) 296 | show_stdio - pass through stdout/stderr from qutebrowser 297 | """ 298 | if not isinstance(profile, QutebrowserProfile): 299 | raise ValueError(f'profile must be a QutebrowserProfile, not {type(profile)}') 300 | 301 | # Set up session base directory, unless --basedir has been specified by the 302 | # user: 303 | profile.mkbasedir() 304 | 305 | args_ = [ qutebrowser, '--basedir', profile.basedir ] 306 | 307 | if args: 308 | args_.extend(filter(lambda x: x is not None, args)) 309 | elif profile.config and profile.config.args: 310 | args_.extend(filter(lambda x: x is not None, profile.config.args)) 311 | 312 | # Translate options: remove occurrences of -r/--restore/-R/--override-restore 313 | for idx, arg in enumerate(args_): 314 | if arg is None: 315 | continue 316 | if arg in ['--restore', '-r' ]: 317 | args_[idx] = None 318 | args_[idx+1] = None # following arg would be session_name 319 | if arg in ['--override-restore', '-R' ]: 320 | args_[idx] = None 321 | 322 | stdin = None 323 | stderr = None 324 | stdout = None 325 | 326 | if not show_stdio: 327 | stdin = subprocess.DEVNULL 328 | stderr = subprocess.DEVNULL 329 | stdout = subprocess.DEVNULL 330 | 331 | p = subprocess.Popen(args_, stdin=stdin, stdout=stdout, stderr=stderr) 332 | print_msg(f'started process {p.pid}') 333 | 334 | 335 | @click.command(context_settings=dict( 336 | ignore_unknown_options=True, 337 | help_option_names=['-h', '--help'], 338 | )) 339 | @click.option( 340 | "--profiles-root", 341 | default=expand(f"{xdg_data_home}/qutebrowser"), # "/run/user/$uid/qutebrowser" 342 | show_default=True, 343 | help="The directory to store profiles in", 344 | ) 345 | @click.option( 346 | "--choose", 347 | default=False, 348 | is_flag=True, 349 | help="Prompt the user to choose a profile, then launch it", 350 | ) 351 | @click.option( 352 | "--load", 353 | default=None, 354 | help="Load the given profile (fails if profile does not exist, see --new)", 355 | ) 356 | @click.option( 357 | "--remove", 358 | default=None, 359 | help="Delete the given profile (including cache, cookies, history, site data etc) from the filesystem", 360 | ) 361 | @click.option( 362 | "--new/--no-new", 363 | default=False, 364 | show_default=True, 365 | help="Allow --load to create a new profile if it does not exist", 366 | ) 367 | @click.option( 368 | "--dmenu", 369 | required=False, 370 | help="Override the location of dmenu/rofi when using --choose", 371 | ) 372 | @click.option( 373 | "--only-existing/--no-only-existing", 374 | default=False, 375 | help="Do not allow the user to specify a new (non-existent) profile during --choose", 376 | ) 377 | @click.option( 378 | "--list-profiles", "--list", '-l', 379 | default=False, 380 | is_flag=True, 381 | help="List existing profiles", 382 | ) 383 | @click.option( 384 | "--show-stdio", 385 | default=False, 386 | is_flag=True, 387 | show_default=True, 388 | help="Show stdout/stderr from qutebrowser when it is launched", 389 | ) 390 | @click.option( 391 | "--qutebrowser", 392 | default=shutil.which("qutebrowser"), 393 | show_default=True, 394 | help="Location of qutebrowser launcher", 395 | ) 396 | @click.option( 397 | "--config-file", 398 | default=expand('~/.config/qutebrowser-profile.yaml'), 399 | show_default=True, 400 | help="Location of profiles config file", 401 | ) 402 | # eat up remaining args to pass to qutebrowser 403 | @click.argument('qb_args', nargs=-1, type=click.UNPROCESSED) 404 | def main( 405 | profiles_root: str, 406 | choose: bool, 407 | load: Optional[str], 408 | remove: Optional[str], 409 | new: bool, 410 | dmenu: Optional[str], 411 | only_existing: bool, 412 | list_profiles: bool, 413 | qutebrowser: str, 414 | qb_args: tuple[str], 415 | show_stdio: bool, 416 | config_file: str, 417 | ): 418 | # Set default values as defined in XDG base directory spec 419 | # https://specifications.freedesktop.org/basedir-spec/latest/ 420 | 421 | config_loader = ConfigLoader(filename=config_file) 422 | 423 | qp = QutebrowserProfiles( 424 | profiles_root=profiles_root, 425 | config_loader=config_loader, 426 | xdg_runtime_dir = xdg_runtime_dir, 427 | xdg_config_home = xdg_config_home, 428 | xdg_cache_home = xdg_cache_home, 429 | xdg_data_home = xdg_data_home, 430 | ) 431 | 432 | def _update_config_profile_with_qbargs(profile_name: str) -> ConfigProfile: 433 | profile = config_loader.get_or_create_profile(profile_name) 434 | 435 | if qb_args: 436 | profile.args = list(qb_args) 437 | 438 | return profile 439 | 440 | # as we're using arguments to simulate commands, we have to manually enforce that 441 | # some commands aren't specified together 442 | @define 443 | class MutuallyExclusiveCommands: 444 | mutually_exclusive: list[tuple[str, Optional[Any]]] 445 | 446 | def check_if_any_mutually_exclusive(self, except_keys: list[str] = []) -> bool: 447 | # check if any args specified in 'mutually_exclusive' are set, except for any 448 | # specified under 'except_keys' (which should be arg strings, such as ['--load']) 449 | for k, v in self.mutually_exclusive: 450 | if k in except_keys: 451 | continue 452 | if v: 453 | return True 454 | 455 | return False 456 | 457 | def raise_if_any_mutually_exclusive(self, except_keys: list[str] = []): 458 | if self.check_if_any_mutually_exclusive(except_keys): 459 | raise click.BadParameter(f'cannot use {", ".join(map(lambda me: me[0], self.mutually_exclusive))} together') 460 | 461 | mutually_exclusive = MutuallyExclusiveCommands([ 462 | ('--load', load), 463 | ('--choose', choose), 464 | ('--list', list_profiles), 465 | ('--remove', remove), 466 | ]) 467 | 468 | if not mutually_exclusive.check_if_any_mutually_exclusive(): 469 | choose = True 470 | 471 | if list_profiles: 472 | mutually_exclusive.raise_if_any_mutually_exclusive(except_keys=['--list']) 473 | 474 | profiles = qp.profiles() 475 | for profile in profiles: 476 | print(profile.profile_name) 477 | 478 | if choose: 479 | mutually_exclusive.raise_if_any_mutually_exclusive(except_keys=['--choose']) 480 | 481 | profiles = qp.profiles() 482 | 483 | if not dmenu: 484 | rofi = shutil.which("rofi") 485 | if rofi: 486 | dmenu = f"{rofi} -dmenu" 487 | 488 | dmenu = shutil.which("dmenu") 489 | 490 | if not dmenu: 491 | # use terminal selection 492 | for idx, profile in enumerate(profiles): 493 | print(f'{idx+1}. {profile.profile_name}') 494 | 495 | print('') 496 | print('Choose a number or name: ') 497 | ans = input() 498 | 499 | try: 500 | idx = int(ans) 501 | profile = profiles[idx-1] 502 | 503 | except ValueError: # ans wasn't a number 504 | profile = qp.get_profile(ans) 505 | 506 | else: 507 | 508 | dmenuArgs = shlex.split(dmenu) + ["-p", "qutebrowser"] 509 | 510 | if only_existing: 511 | dmenuArgs.append("-no-custom") 512 | 513 | p = subprocess.Popen(dmenuArgs, 514 | stdin=subprocess.PIPE, 515 | stderr=subprocess.PIPE, 516 | stdout=subprocess.PIPE) 517 | 518 | choice_lines = '\n'.join(map(lambda px: px.profile_name, profiles)) 519 | choice, errors = p.communicate(choice_lines.encode('utf-8')) 520 | 521 | if p.returncode not in [0, 1] or (p.returncode == 1 and len(errors) != 0): 522 | raise ValueError( 523 | "{} returned {} and error:\n{}" 524 | .format(dmenuArgs, p.returncode, errors.decode('utf-8')) 525 | ) 526 | 527 | profile_name = choice.decode('utf-8').rstrip() 528 | 529 | try: 530 | profile = qp.get_profile(profile_name) 531 | 532 | except NoSuchProfileError: 533 | if not new: 534 | raise 535 | 536 | profile = qp.new(profile_name, qb_args=list(qb_args)) 537 | 538 | profile.config = _update_config_profile_with_qbargs(profile_name=profile.profile_name) 539 | qp.run_qb(qutebrowser=qutebrowser, profile=profile, args=list(qb_args), show_stdio=show_stdio,) 540 | 541 | if load: 542 | mutually_exclusive.raise_if_any_mutually_exclusive(except_keys=['--load']) 543 | 544 | try: 545 | profile = qp.get_profile(load) 546 | 547 | except NoSuchProfileError: 548 | if not new: 549 | raise 550 | 551 | profile = qp.new(load, qb_args=list(qb_args)) 552 | 553 | profile.config = _update_config_profile_with_qbargs(profile_name=load) 554 | qp.run_qb(qutebrowser=qutebrowser, profile=profile, args=list(qb_args), show_stdio=show_stdio) 555 | 556 | if remove: 557 | mutually_exclusive.raise_if_any_mutually_exclusive(except_keys=['--remove']) 558 | 559 | profile = qp.get_profile(remove) 560 | profile.remove() # remove() also deletes profile from config_loader 561 | 562 | 563 | # save the config (update-config logic happens in other args blocks above) 564 | try: 565 | config_loader.save() 566 | print_msg(f'saved config to {config_loader.filename}') 567 | 568 | except Exception as e: 569 | print_warning(f'could not save {config_loader.filename}: {str(e)}') 570 | # continue 571 | 572 | if __name__ == '__main__': 573 | main() 574 | --------------------------------------------------------------------------------