├── .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 |
--------------------------------------------------------------------------------