Rules-based browser launcher for TUI + GNOME. switchyard.aly.codes
tui gome bowser go
0

Configure Feed

Select the types of activity you want to include in your feed.

move desktop integration to its own package and package with nix

Aly Raffauf (Jun 2, 2026, 11:21 AM EDT) 8db6097a d636334a

+283 -3
+1
browser-setup/.python-version
··· 1 + 3.14
+257
browser-setup/main.py
··· 1 + #!/usr/bin/env python3 2 + # SPDX-License-Identifier: GPL-3.0-or-later 3 + # Switchyard desktop integration installer — https://github.com/alyraffauf/Switchyard 4 + 5 + import argparse 6 + import json 7 + import shutil 8 + import subprocess 9 + import sys 10 + from dataclasses import asdict, dataclass 11 + from pathlib import Path 12 + 13 + FLATPAK_CHROMIUM: dict[str, str] = { 14 + "com.brave.Browser": "BraveSoftware/Brave-Browser", 15 + "com.google.Chrome": "google-chrome", 16 + "com.microsoft.Edge": "microsoft-edge", 17 + "com.vivaldi.Vivaldi": "vivaldi", 18 + "io.github.ungoogled_software.ungoogled_chromium": "chromium", 19 + "net.imput.helium": "net.imput.helium", 20 + "org.chromium.Chromium": "chromium", 21 + } 22 + 23 + NATIVE_CHROMIUM = [ 24 + "net.imput.helium", 25 + "google-chrome", 26 + "chromium", 27 + "BraveSoftware/Brave-Browser", 28 + "microsoft-edge", 29 + "vivaldi", 30 + ] 31 + 32 + 33 + FLATPAK_FIREFOX: dict[str, str] = { 34 + "org.mozilla.firefox": ".mozilla", 35 + "io.gitlab.librewolf-community": ".librewolf", 36 + } 37 + 38 + NATIVE_FIREFOX = [".mozilla", ".librewolf"] 39 + 40 + 41 + @dataclass 42 + class NativeMessagingManifest: 43 + path: str 44 + name: str = "io.github.alyraffauf.switchyard" 45 + description: str = "Switchyard browser selector" 46 + type: str = "stdio" 47 + allowed_origins: list[str] | None = None 48 + allowed_extensions: list[str] | None = None 49 + 50 + 51 + def run(cmd: list[str]) -> bool: 52 + return subprocess.run(cmd, capture_output=True).returncode == 0 53 + 54 + 55 + def flatpak_installed(flatpak: str | None, id: str) -> bool: 56 + if flatpak: 57 + return run([flatpak, "info", id]) 58 + return False 59 + 60 + 61 + def find_switchyard(flatpak: str | None) -> str | None: 62 + switchyard = shutil.which("switchyard") 63 + 64 + if flatpak and flatpak_installed(flatpak, "io.github.alyraffauf.Switchyard"): 65 + return f"{flatpak} run io.github.alyraffauf.Switchyard" 66 + 67 + return switchyard 68 + 69 + 70 + def write_wrapper(switchyard: str, path: Path): 71 + wrapper = f"""#!/bin/sh 72 + if [ "${{container-}}" = flatpak ]; then 73 + exec /usr/bin/flatpak-spawn --host {switchyard} --native-host "$@" 74 + else 75 + exec {switchyard} --native-host "$@" 76 + fi 77 + """ 78 + 79 + path.parent.mkdir(parents=True, exist_ok=True) 80 + path.write_text(wrapper) 81 + path.chmod(0o755) 82 + 83 + 84 + def install_manifests(switchyard: str, configs: list[Path], hosts_dir: str, **kwargs): 85 + for config_dir in configs: 86 + manifest_file = config_dir / hosts_dir / "io.github.alyraffauf.switchyard.json" 87 + manifest_file.parent.mkdir(parents=True, exist_ok=True) 88 + manifest = NativeMessagingManifest( 89 + path=str(manifest_file.parent / "switchyard-native-host-wrapper.sh"), 90 + **kwargs, 91 + ) 92 + manifest_file.write_text( 93 + json.dumps( 94 + {key: val for key, val in asdict(manifest).items() if val is not None} 95 + ) 96 + ) 97 + print(f" Installed {manifest_file}") 98 + write_wrapper( 99 + switchyard, manifest_file.parent / "switchyard-native-host-wrapper.sh" 100 + ) 101 + 102 + 103 + def install(yes: bool = False): 104 + print("Installing Switchyard native messaging host...") 105 + 106 + flatpak = shutil.which("flatpak") 107 + switchyard = find_switchyard(flatpak) 108 + 109 + if switchyard is None: 110 + print("Switchyard is not installed.") 111 + sys.exit(1) 112 + 113 + installed_chromium = [ 114 + app_id for app_id in FLATPAK_CHROMIUM if flatpak_installed(flatpak, app_id) 115 + ] 116 + installed_firefox = [ 117 + app_id for app_id in FLATPAK_FIREFOX if flatpak_installed(flatpak, app_id) 118 + ] 119 + to_override = installed_chromium + installed_firefox 120 + 121 + chromium = [ 122 + Path.home() / f".var/app/{app_id}/config/{FLATPAK_CHROMIUM[app_id]}" 123 + for app_id in installed_chromium 124 + ] + [Path.home() / f".config/{config_dir}" for config_dir in NATIVE_CHROMIUM] 125 + 126 + firefox = [ 127 + Path.home() / f".var/app/{app_id}/{FLATPAK_FIREFOX[app_id]}" 128 + for app_id in installed_firefox 129 + ] + [Path.home() / profile_dir for profile_dir in NATIVE_FIREFOX] 130 + 131 + install_manifests( 132 + switchyard, 133 + chromium, 134 + "NativeMessagingHosts", 135 + allowed_origins=[ 136 + "chrome-extension://ncehhpikkabfdcceimdhjjjodogflokc/", 137 + "chrome-extension://gmdmmjfmpfbmddgphjbkbbmdolkifloi/", 138 + ], 139 + ) 140 + 141 + install_manifests( 142 + switchyard, 143 + firefox, 144 + "native-messaging-hosts", 145 + allowed_extensions=["switchyard@alyraffauf.github.io"], 146 + ) 147 + 148 + if to_override: 149 + print( 150 + "\nThe following browsers are installed via Flatpak and run in a " 151 + "sandbox. To let them talk to Switchyard, we need to grant each one " 152 + "permission to reach outside the sandbox. This does weaken their " 153 + "isolation slightly, so it's worth knowing before we proceed.\n" 154 + ) 155 + for app_id in to_override: 156 + print(f" {app_id}") 157 + print() 158 + 159 + if yes: 160 + answer = "y" 161 + else: 162 + answer = input("Grant permission to these browsers? [y/N] ").strip().lower() 163 + 164 + if answer != "y": 165 + print("Skipping permission grants.") 166 + to_override = [] 167 + 168 + assert flatpak 169 + for app_id in to_override: 170 + granted = run( 171 + [ 172 + flatpak, 173 + "override", 174 + "--user", 175 + "--talk-name=org.freedesktop.Flatpak", 176 + app_id, 177 + ] 178 + ) 179 + if granted: 180 + print(f" Granted: {app_id}") 181 + else: 182 + print(f" Failed to grant permission for {app_id}") 183 + 184 + print("\nManifests installed. Please restart your browser.") 185 + 186 + 187 + def uninstall(): 188 + chromium = [ 189 + Path.home() / f".var/app/{app_id}/config/{FLATPAK_CHROMIUM[app_id]}" 190 + for app_id in FLATPAK_CHROMIUM 191 + ] + [Path.home() / f".config/{config_dir}" for config_dir in NATIVE_CHROMIUM] 192 + 193 + firefox = [ 194 + Path.home() / f".var/app/{app_id}/{FLATPAK_FIREFOX[app_id]}" 195 + for app_id in FLATPAK_FIREFOX 196 + ] + [Path.home() / profile_dir for profile_dir in NATIVE_FIREFOX] 197 + 198 + count = 0 199 + for config_dir in chromium: 200 + hosts = config_dir / "NativeMessagingHosts" 201 + for file in ( 202 + hosts / "io.github.alyraffauf.switchyard.json", 203 + hosts / "switchyard-native-host-wrapper.sh", 204 + ): 205 + if file.exists(): 206 + file.unlink() 207 + print(f" Removed {file}") 208 + count += 1 209 + for profile_dir in firefox: 210 + hosts = profile_dir / "native-messaging-hosts" 211 + for file in ( 212 + hosts / "io.github.alyraffauf.switchyard.json", 213 + hosts / "switchyard-native-host-wrapper.sh", 214 + ): 215 + if file.exists(): 216 + file.unlink() 217 + print(f" Removed {file}") 218 + count += 1 219 + print(f"\nRemoved {count} file(s).") 220 + 221 + 222 + def main(): 223 + parser = argparse.ArgumentParser( 224 + prog="switchyard-browser-setup", 225 + description="Set up desktop integration between the Switchyard app and browser extension.", 226 + ) 227 + 228 + group = parser.add_mutually_exclusive_group() 229 + group.add_argument( 230 + "--install", action="store_true", help="install the native messaging manifest." 231 + ) 232 + 233 + parser.add_argument( 234 + "--yes", "-y", action="store_true", help="skip confirmation prompts." 235 + ) 236 + 237 + group.add_argument( 238 + "--uninstall", 239 + action="store_true", 240 + help="remove the native messaging host manifest.", 241 + ) 242 + 243 + args = parser.parse_args() 244 + 245 + if not args.install and not args.uninstall: 246 + parser.print_help() 247 + sys.exit(0) 248 + 249 + if args.install: 250 + install(yes=args.yes) 251 + 252 + if args.uninstall: 253 + uninstall() 254 + 255 + 256 + if __name__ == "__main__": 257 + main()
+14
browser-setup/pyproject.toml
··· 1 + [build-system] 2 + requires = ["setuptools"] 3 + build-backend = "setuptools.build_meta" 4 + 5 + [project] 6 + name = "switchyard-browser-setup" 7 + version = "0.1.0" 8 + description = "Desktop integration installer for Switchyard" 9 + readme = "README.md" 10 + requires-python = ">=3.11" 11 + dependencies = [] 12 + 13 + [project.scripts] 14 + browser-setup = "main:main"
+1 -1
docs/WebExtension.md
··· 14 14 15 15 The Switchyard extension can also show your installed browsers directly in the popup, letting you send the current tab to a specific browser in one click. This requires installing a native messaging host on your system. 16 16 17 - Please read [`scripts/install-desktop-integration.py`](../scripts/install-desktop-integration.py) before running this command: 17 + Please read [`browser-setup/main.py`](../browser-setup/main.py) before running this command: 18 18 19 19 ```bash 20 20 curl -fsSL https://raw.githubusercontent.com/alyraffauf/Switchyard/master/scripts/install-desktop-integration.sh | bash -s -- --install --yes
+8
flake/packages.nix
··· 7 7 packages = { 8 8 default = self'.packages.switchyard; 9 9 10 + browser-setup = pkgs.python3Packages.buildPythonApplication { 11 + pname = "browser-setup"; 12 + version = "dev"; 13 + src = ../browser-setup; 14 + pyproject = true; 15 + build-system = with pkgs.python3.pkgs; [ setuptools ]; 16 + }; 17 + 10 18 switchyard = pkgs.buildGoModule { 11 19 pname = "switchyard"; 12 20 version = "dev";
+2 -2
scripts/install-desktop-integration.sh
··· 5 5 # Usage: curl -fsSL <url> | bash -s -- [--install | --uninstall] 6 6 # 7 7 # This is a thin entrypoint that downloads and runs the Python installer. 8 - # Arguments are forwarded directly to install-desktop-integration.py. 8 + # Arguments are forwarded directly to browser-setup/main.py. 9 9 10 10 set -euo pipefail 11 11 ··· 14 14 exit 1 15 15 fi 16 16 17 - PY_URL="https://raw.githubusercontent.com/alyraffauf/Switchyard/refs/heads/master/scripts/install-desktop-integration.py" 17 + PY_URL="https://raw.githubusercontent.com/alyraffauf/Switchyard/refs/heads/master/browser-setup/main.py" 18 18 19 19 curl -fsSL "$PY_URL" | python3 - "$@"