├── .github └── workflows │ └── publish.yml ├── .gitignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── browser-hub-diagram.drawio ├── browser-hub-diagram.png ├── browsers.png ├── pyproject.toml ├── requirements.txt └── src └── browser_hub ├── __init__.py ├── config.py ├── main.py └── processes.py /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | deploy: 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v3 16 | - name: Set up Python 17 | uses: actions/setup-python@v3 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install build 24 | - name: Build package 25 | run: python -m build 26 | - name: Publish package 27 | uses: pypa/gh-action-pypi-publish@release/v1 28 | with: 29 | user: __token__ 30 | password: ${{ secrets.PYPI_API_TOKEN }} 31 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | venv 2 | __pycache__ 3 | .pytest_cache 4 | .mypy_cache 5 | *.pyc 6 | dist 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "python.analysis.extraPaths": [ 3 | "./src" 4 | ] 5 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Amir Karimi 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 | > [!NOTE] 2 | > This program is tested on Ubuntu 22.04 and mainly Chrome but same concept 3 | > applies to other OSes and browsers. 4 | 5 | ## Problem 6 | 7 | As consultants, we usually work with multiple clients. Separating the work isn't 8 | difficult until you start using the browser. For example, they might have 9 | different Google/Outlook accounts. Add to that your personal account, and it 10 | quickly becomes a mess to handle in the same browser. Another thing is the 11 | browser extensions. Imagine you and all your clients are using 12 | [Toggl](https://toggl.com/) extension to track the time, and you should use a 13 | different account for each. 14 | 15 | You would be able to use multiple profiles in Chrome and Firefox. But there's 16 | another problem. Let's say you click on a link from outside the browser (e.g., 17 | VSCode). In which profile should it be opened? 18 | 19 | ## Solution 20 | 21 | The solution is a program that replaces the default browser. It then decides 22 | which browser instance to open based on the domain or a keyword in the URL. 23 | 24 | 25 | 26 | This is a good start but not enough. At least in Chrome, the window classes 27 | (`wm_class` in xorg) of different profile instances are the same. So, even if 28 | you open a separate window for each profile, they are all grouped together. It 29 | becomes harder and harder to distinguish them when switching windows. 30 | 31 | The final piece is using separate user directory in Chrome (via 32 | `--user-data-dir`). This way Chrome creates a different window class for each 33 | profile. We can enhance it further by creating a dedicated launcher and icon. 34 | Here is an example `.desktop` file: 35 | 36 | ``` 37 | [Desktop Entry] 38 | Version=1.1 39 | Type=Application 40 | Name=Chrome - Client1 41 | Comment=Browser profile for Client1 42 | Icon=/home/amir/Documents/Icons/web-browser-yellow.svg 43 | Exec=google-chrome --user-data-dir=/home/amir/.config/google-chrome/Client1 %U 44 | Categories=Network; 45 | StartupWMClass=google-chrome (/home/amir/.config/google-chrome/Client1) 46 | ``` 47 | 48 | Specifying `StartupWMClass` lets the launcher know which profile is currently 49 | open. For example, when I open the default and the client-1's browser, the 50 | launcher shows this: 51 | 52 | 53 | 54 | ## Install 55 | 56 | 1. Install Browser Hub: `pipx install browser-hub` 57 | 2. [Configure](#configuration) it 58 | 3. Set Browser Hub as your default browser 59 | 60 | To test it, you can run Browser Hub in your terminal: 61 | 62 | ``` 63 | browser-hub {url} 64 | ``` 65 | 66 | ## Configuration 67 | 68 | Create the config folder: 69 | 70 | ``` 71 | mkdir -p ~/.config/browser-hub 72 | ``` 73 | 74 | Create `config.json` file. Here's an example for Chrome: 75 | 76 | ```json 77 | { 78 | "default_browser_open_cmd": "google-chrome {url}", 79 | "profiles": [ 80 | { 81 | "name": "Client1", 82 | "url_patterns": ["client1-domain1", "client1-domain2"], 83 | "browser": { 84 | "open_cmd": "google-chrome --user-data-dir=/home/amir/.config/google-chrome/Client1 \"{url}\"", 85 | "process_names": ["chrome"], 86 | "cmd_includes_regex": "--user-data-dir=.+google-chrome/Client1", 87 | "cmd_excludes_regex": "--type=renderer" 88 | }, 89 | "url_transformers": [ 90 | { 91 | "keywords": ["/client1-org1", "/client1-org2", "/client1-org3"], 92 | "from_url_regex": "http(s)?://(.*\\.)?github.com", 93 | "to_url": "https://ghe.client1-on-prem.com" 94 | } 95 | ] 96 | }, 97 | { 98 | "name": "Client2", 99 | "url_patterns": ["client2-domain"], 100 | "browser": { 101 | "open_cmd": "google-chrome --user-data-dir=/home/amir/.config/google-chrome/Client2 \"{url}\"", 102 | "process_names": ["chrome"], 103 | "cmd_includes_regex": "--user-data-dir=.+google-chrome/Client1", 104 | "cmd_excludes_regex": "--type=renderer" 105 | }, 106 | "url_transformers": [] 107 | } 108 | ], 109 | "profile_specific_urls": [ 110 | "amazon.com", 111 | "github.com", 112 | ".google.com", 113 | "datadoghq.com", 114 | "sentry.io", 115 | "lucid.app" 116 | ] 117 | } 118 | ``` 119 | 120 | A few examples based on this config: 121 | 122 | | Source URL | Target URL | Profile | 123 | | ------------------------------------- | -------------------------------------------------- | ------------------------------------------- | 124 | | https://www.client1-domain1.com | Same | Client 1 | 125 | | https://www.client2-domain.com | Same | Client 2 | 126 | | https://console.amazon.com | Same | Active Profile based on the running process | 127 | | https://github.com/client1-org1/repo1 | https://ghe.client1-on-prem.com/client1-org1/repo1 | Client 1 | 128 | | https://news.ycombinator.com/ | Same | Default Chrome Profile | 129 | 130 | ### Firefox Config Example 131 | 132 | You first need to create firefox profiles (in this example: `client-1`, 133 | `client-2`), then use the following config example: 134 | 135 | ```json 136 | { 137 | "default_browser_open_cmd": "firefox {url}", 138 | "profiles": [ 139 | { 140 | "name": "Client 1", 141 | "browser": { 142 | "open_cmd": "firefox -P client-1 --class client-1 \"{url}\"", 143 | "process_names": ["firefox"], 144 | "cmd_includes_regex": "-P client-1" 145 | }, 146 | "url_patterns": ["client1-domain1", "client1-domain2"] 147 | }, 148 | { 149 | "name": "Client 2", 150 | "browser": { 151 | "open_cmd": "firefox -P client-2 --class client-2 \"{url}\"", 152 | "process_names": ["firefox"], 153 | "cmd_includes_regex": "-P client-2" 154 | }, 155 | "url_patterns": ["client2-domain1", "client2-domain2"] 156 | } 157 | ], 158 | "profile_specific_urls": [ 159 | "amazon.com", 160 | "github.com", 161 | ".google.com", 162 | "datadoghq.com", 163 | "sentry.io" 164 | ] 165 | } 166 | ``` 167 | 168 | ### Options 169 | 170 | - `default_browser_open_cmd`: The shell command to run the default browser when 171 | no profile is matching the opened URL. Normally it should be set to your 172 | personal profile. 173 | - `profiles`: [Array] Profiles of your clients. 174 | - `name`: Name of the profile. 175 | - `url_patterns`: [Array] This profile will be opened if the URL contains any 176 | of these patterns. Usually set to the client-specific domains. 177 | - `browser`: Browser information specific to this profile. 178 | - `open_cmd`: The shell command to open this client profile browser. 179 | - `process_names`: [Array] Name of the browser process used to determine 180 | whether a profile is active. 181 | - `cmd_includes_regex`: A regular expression to distinguish this profile 182 | browser processes. 183 | - `cmd_excludes_regex`: The processes with a command line that matches this 184 | regular expression will be ignored. Useful to exclude the browser process 185 | when running in the background. 186 | - `url_transformers`: [Array] Transforming a URL to another one. e.g. Mapping 187 | GitHub actions to the enterprise on-prem GitHub instance. 188 | - `keywords`: [Array] Keywords that trigger the transformer if found in the 189 | URL. 190 | - `from_url_regex`: Matching regular expression that specifies what to 191 | replace in the URL (regex groups). 192 | - `to_url`: The URL will be replaced by this string. Backreferences, such as 193 | `\6`, are replaced with the substring matched by group 6 in the 194 | `from_url_regex`. 195 | - `profile_specific_urls`: [Array] If the URL contains any of the specified 196 | items in this field, Browser Hub will check whether any profile-specific 197 | browser is running, and open the URL in that browser instance. If more than 198 | one profile are running, one of them will be selected randomly (usually the 199 | one that was run first). This is useful for cases where we can't recognize the 200 | profile from the URL (e.g. Google Docs). 201 | -------------------------------------------------------------------------------- /browser-hub-diagram.drawio: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /browser-hub-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirkarimi/browser-hub/43301441f6d6fd3dc2f1a0ab94c220c2447da30c/browser-hub-diagram.png -------------------------------------------------------------------------------- /browsers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirkarimi/browser-hub/43301441f6d6fd3dc2f1a0ab94c220c2447da30c/browsers.png -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling", "hatch-vcs"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "browser-hub" 7 | description = "Manage multiple browser instances without hassles." 8 | dynamic = ["version"] 9 | authors = [ 10 | { name="Amir Karimi", email="me@amirkarimi.dev" }, 11 | ] 12 | readme = "README.md" 13 | requires-python = ">=3.8" 14 | classifiers = [ 15 | "Programming Language :: Python :: 3", 16 | "License :: OSI Approved :: MIT License", 17 | "Operating System :: POSIX", 18 | "Topic :: Internet :: WWW/HTTP :: Browsers", 19 | "Topic :: Utilities", 20 | ] 21 | dependencies = [ 22 | "psutil==5.9.*", 23 | "pydantic==2.7.*" 24 | ] 25 | 26 | [project.urls] 27 | Homepage = "https://github.com/amirkarimi/browser-hub" 28 | Issues = "https://github.com/amirkarimi/browser-hub/issues" 29 | 30 | [project.scripts] 31 | browser-hub = "browser_hub.main:cli" 32 | 33 | [tool.hatch.version] 34 | source = "vcs" 35 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | psutil==5.9.* 2 | pydantic==2.7.* 3 | -------------------------------------------------------------------------------- /src/browser_hub/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amirkarimi/browser-hub/43301441f6d6fd3dc2f1a0ab94c220c2447da30c/src/browser_hub/__init__.py -------------------------------------------------------------------------------- /src/browser_hub/config.py: -------------------------------------------------------------------------------- 1 | import json 2 | from os import path 3 | from sys import stderr 4 | from typing import List, Optional 5 | from pydantic import BaseModel, Field, ValidationError 6 | 7 | CONFIG_FILE_PATH = path.expanduser("~/.config/browser-hub/config.json") 8 | 9 | 10 | class URLTransformer(BaseModel): 11 | keywords: List[str] = Field(min_length=1) 12 | from_url_regex: str 13 | to_url: str 14 | 15 | 16 | class Browser(BaseModel): 17 | open_cmd: str 18 | process_names: List[str] = Field(min_length=1) 19 | cmd_includes_regex: str 20 | cmd_excludes_regex: Optional[str] = Field(default=None) 21 | 22 | 23 | class Profile(BaseModel): 24 | name: str 25 | browser: Browser 26 | url_patterns: List[str] = Field(min_length=1) 27 | url_transformers: List[URLTransformer] = Field(default_factory=list) 28 | 29 | 30 | class Config(BaseModel): 31 | default_browser_open_cmd: str 32 | profiles: List[Profile] = Field(min_length=1) 33 | # Open these URLs in the profile-specific browser if one is open 34 | profile_specific_urls: List[str] 35 | 36 | 37 | def _err(msg: str) -> None: 38 | print(msg, file=stderr) 39 | 40 | 41 | def load_config_or_exit() -> Config: 42 | if not path.exists(CONFIG_FILE_PATH): 43 | _err(f"Config file not found. Please create it at '{CONFIG_FILE_PATH}'.") 44 | exit(1) 45 | with open(CONFIG_FILE_PATH, "r") as f: 46 | data = json.load(f) 47 | try: 48 | config = Config(**data) 49 | return config 50 | except ValidationError as err: 51 | _err(f"Config file is not valid. File: '{CONFIG_FILE_PATH}'") 52 | for error in err.errors(): 53 | msg = error["msg"] 54 | field = ".".join(str(f) for f in error["loc"]) 55 | print(f" {field}: {msg}") 56 | exit(1) 57 | -------------------------------------------------------------------------------- /src/browser_hub/main.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import os 3 | import re 4 | from typing import Optional 5 | 6 | 7 | from browser_hub.processes import is_profile_active 8 | from browser_hub.config import ( 9 | Config, 10 | Profile, 11 | load_config_or_exit, 12 | ) 13 | 14 | 15 | class BrowserHub: 16 | open_url: str 17 | config: Config 18 | 19 | def __init__(self, open_url: str, config: Config) -> None: 20 | self.open_url = open_url 21 | self.config = config 22 | 23 | def find_matched_profile(self) -> Optional[Profile]: 24 | for profile in self.config.profiles: 25 | matches = any(p in self.open_url for p in profile.url_patterns) 26 | if matches: 27 | return profile 28 | return None 29 | 30 | def should_open_last_active_profile(self) -> bool: 31 | for url in self.config.profile_specific_urls: 32 | if url in self.open_url: 33 | return True 34 | return False 35 | 36 | def transform_url(self, profile: Profile, url: str) -> str: 37 | for transformer in profile.url_transformers: 38 | keywords_matches = any(k for k in transformer.keywords if k in url) 39 | if keywords_matches: 40 | transformed_url = re.sub( 41 | transformer.from_url_regex, 42 | transformer.to_url, 43 | url, 44 | ) 45 | return transformed_url 46 | return url 47 | 48 | def find_active_profile(self) -> Optional[Profile]: 49 | for profile in self.config.profiles: 50 | active = is_profile_active( 51 | profile.browser.process_names, 52 | profile.browser.cmd_includes_regex, 53 | profile.browser.cmd_excludes_regex, 54 | ) 55 | if active: 56 | return profile 57 | return None 58 | 59 | def open(self) -> None: 60 | profile = self.find_matched_profile() 61 | if not profile and self.should_open_last_active_profile(): 62 | # Find the active profile based on the running process 63 | profile = self.find_active_profile() 64 | 65 | if profile: 66 | url = self.transform_url(profile, self.open_url) 67 | os.system(profile.browser.open_cmd.format(url=url)) 68 | else: 69 | os.system(self.config.default_browser_open_cmd.format(url=self.open_url)) 70 | 71 | 72 | def cli() -> None: 73 | parser = argparse.ArgumentParser( 74 | prog="browser-hub", 75 | description="Open the right browser profile", 76 | ) 77 | parser.add_argument("url") 78 | args = parser.parse_args() 79 | 80 | config = load_config_or_exit() 81 | 82 | BrowserHub(open_url=args.url, config=config).open() 83 | 84 | 85 | if __name__ == "__main__": 86 | cli() 87 | -------------------------------------------------------------------------------- /src/browser_hub/processes.py: -------------------------------------------------------------------------------- 1 | import re 2 | from typing import List, Optional 3 | import psutil 4 | 5 | 6 | def is_profile_active(names: List[str], pattern: str, exclude: Optional[str]) -> bool: 7 | for proc in psutil.process_iter(): 8 | if proc.name() in names: 9 | try: 10 | cmdline = " ".join(proc.cmdline()) 11 | except (psutil.ZombieProcess, psutil.AccessDenied): 12 | # Skip zombie processes 13 | continue 14 | 15 | # Exclude pattern 16 | if exclude and re.search(exclude, cmdline): 17 | continue 18 | 19 | if re.search(pattern, cmdline): 20 | return True 21 | return False 22 | 23 | 24 | if __name__ == "__main__": 25 | print(is_profile_active(["chrome"], r"--user-data-dir.*", r"--type=renderer")) 26 | --------------------------------------------------------------------------------