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.

blepadd native messaging workaround

authored by

Aly Raffauf and committed by
Aly Raffauf
(May 24, 2026, 6:44 PM EDT) 6653fcf6 2f7c4c29

+207 -205
+22 -2
docs/WebExtension.md
··· 2 2 3 3 A companion browser extension is available that allows you to open any page in Switchyard with one click. For now, the extension uses our URI scheme under the hood. It redirects the current tab to a `switchyard://open` URL, handing off to Switchyard for browser selection. 4 4 5 - The extension lives in the `webextension/` directory and ships as a standard WebExtension compatible with both Firefox and Chromium browsers. It is included in GitHub releases as `switchyard-webextension.zip`. 5 + The extension lives in the `webextension/` directory and ships as a standard WebExtension compatible with both Firefox and Chromium browsers. It is included in GitHub releases as `switchyard-webextension.zip`. 6 6 7 - The intent is to add deeper integration with the desktop app. Eventually, it will be submitted to the Chrome Web Store and Mozilla. 7 + The intent is to add deeper integration with the desktop app. Eventually, it will be submitted to the Chrome Web Store and Mozilla. 8 + 9 + ## Native Messaging Support 10 + 11 + 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. 12 + 13 + Please read [`scripts/install-native-host.sh`](../scripts/install-native-host.sh) before running this command: 14 + 15 + ```bash 16 + curl -fsSL https://raw.githubusercontent.com/alyraffauf/Switchyard/master/scripts/install-native-host.sh | bash 17 + ``` 18 + 19 + After installation, restart your browser(s). The extension popup will list your installed browsers. 20 + 21 + ### Uninstall 22 + 23 + ```bash 24 + bash install-native-host.sh --uninstall 25 + ``` 26 + 27 + This removes all manifests and wrappers, but the `flatpak override` permissions granted during install are left in place. It's up to the user to decide what to do with them.
+185
scripts/install-native-host.sh
··· 1 + #!/usr/bin/env bash 2 + # SPDX-License-Identifier: GPL-3.0-or-later 3 + # Switchyard native messaging host installer — https://github.com/alyraffauf/Switchyard 4 + # 5 + # Installs manifests that let the browser extension list your installed browsers. 6 + # 7 + # For Flatpak browsers this grants --talk-name=org.freedesktop.Flatpak, which 8 + # lets code inside the browser sandbox run arbitrary host commands via 9 + # flatpak-spawn --host. This weakens the Flatpak sandbox for those browsers. 10 + # Read and understand this before running it. 11 + # 12 + # Usage: bash install-native-host.sh [--uninstall] 13 + 14 + set -euo pipefail 15 + 16 + # ── Find Switchyard ──────────────────────────────────────────────────────────── 17 + 18 + CMD="" 19 + for id in io.github.alyraffauf.Switchyard io.github.alyraffauf.Switchyard.Devel; do 20 + if flatpak info "$id" &>/dev/null; then 21 + CMD="/usr/bin/flatpak run --command=switchyard $id --native-host" 22 + break 23 + fi 24 + done 25 + if [[ -z $CMD ]]; then 26 + bin=$(command -v switchyard 2>/dev/null) || { 27 + echo "error: Switchyard not found" >&2 28 + exit 1 29 + } 30 + CMD="$bin --native-host" 31 + fi 32 + 33 + # ── Config ───────────────────────────────────────────────────────────────────── 34 + 35 + NAME="io.github.alyraffauf.switchyard" 36 + WRAPPER="$HOME/.local/share/switchyard/native-host-wrapper.sh" 37 + CHROME_ORIGIN="chrome-extension://gmdmmjfmpfbmddgphjbkbbmdolkifloi/" 38 + FF_EXT="switchyard@alyraffauf.github.io" 39 + 40 + NATIVE_CHROMIUM=(net.imput.helium google-chrome chromium BraveSoftware/Brave-Browser microsoft-edge vivaldi) 41 + NATIVE_FIREFOX=(.mozilla .librewolf .zen) 42 + 43 + # (appId:configSubdir) — configSubdir maps to ~/.config/<sub>/ inside the sandbox 44 + FLATPAK_CHROMIUM=( 45 + com.google.Chrome:google-chrome 46 + com.google.ChromeDev:google-chrome-unstable 47 + org.chromium.Chromium:chromium 48 + com.brave.Browser:BraveSoftware/Brave-Browser 49 + com.microsoft.Edge:microsoft-edge 50 + com.vivaldi.Vivaldi:vivaldi 51 + io.github.ungoogled_software.ungoogled_chromium:chromium 52 + net.imput.helium:net.imput.helium 53 + ) 54 + 55 + # (appId:profileDir) — profileDir is persisted as ~/<sub>/ inside the sandbox 56 + FLATPAK_FIREFOX=( 57 + org.mozilla.firefox:.mozilla 58 + io.gitlab.librewolf-community:.librewolf 59 + io.github.zen_browser.zen:.zen 60 + ) 61 + 62 + # ── Helpers ──────────────────────────────────────────────────────────────────── 63 + 64 + N=0 65 + 66 + chromium_manifest() { 67 + printf '{"name":"%s","description":"Switchyard browser selector","path":"%s","type":"stdio","allowed_origins":["%s"]}\n' \ 68 + "$NAME" "$1" "$CHROME_ORIGIN" 69 + } 70 + 71 + firefox_manifest() { 72 + printf '{"name":"%s","description":"Switchyard browser selector","path":"%s","type":"stdio","allowed_extensions":["%s"]}\n' \ 73 + "$NAME" "$1" "$FF_EXT" 74 + } 75 + 76 + # ── Uninstall ────────────────────────────────────────────────────────────────── 77 + 78 + if [[ ${1-} == "--uninstall" ]]; then 79 + echo "Removing Switchyard native messaging host..." 80 + 81 + for sub in "${NATIVE_CHROMIUM[@]}"; do 82 + f="$HOME/.config/$sub/NativeMessagingHosts/$NAME.json" 83 + [[ -f $f ]] && rm -f "$f" && echo " removed $f" && N=$((N + 1)) || true 84 + done 85 + 86 + for sub in "${NATIVE_FIREFOX[@]}"; do 87 + f="$HOME/$sub/native-messaging-hosts/$NAME.json" 88 + [[ -f $f ]] && rm -f "$f" && echo " removed $f" && N=$((N + 1)) || true 89 + done 90 + 91 + for entry in "${FLATPAK_CHROMIUM[@]}"; do 92 + appId="${entry%%:*}" 93 + sub="${entry##*:}" 94 + dir="$HOME/.var/app/$appId/config/$sub/NativeMessagingHosts" 95 + f="$dir/$NAME.json" 96 + [[ -f $f ]] && rm -f "$f" "$dir/switchyard-native-host.sh" && echo " removed $f" && N=$((N + 1)) || true 97 + done 98 + 99 + for entry in "${FLATPAK_FIREFOX[@]}"; do 100 + appId="${entry%%:*}" 101 + sub="${entry##*:}" 102 + dir="$HOME/.var/app/$appId/$sub/native-messaging-hosts" 103 + f="$dir/$NAME.json" 104 + [[ -f $f ]] && rm -f "$f" "$dir/switchyard-native-host.sh" && echo " removed $f" && N=$((N + 1)) || true 105 + done 106 + 107 + [[ -f $WRAPPER ]] && rm -f "$WRAPPER" && echo " removed $WRAPPER" || true 108 + 109 + echo 110 + echo "Removed $N manifest(s)." 111 + exit 0 112 + fi 113 + 114 + # ── Install ──────────────────────────────────────────────────────────────────── 115 + 116 + echo "Installing Switchyard native messaging host..." 117 + 118 + mkdir -p "$(dirname "$WRAPPER")" 119 + printf '#!/bin/sh\nexec %s "$@"\n' "$CMD" >"$WRAPPER" 120 + chmod +x "$WRAPPER" 121 + echo "Wrapper: $WRAPPER" 122 + 123 + # Flatpak browsers need a different wrapper: they detect the sandbox via 124 + # $container and use flatpak-spawn --host to escape it. This requires the 125 + # org.freedesktop.Flatpak talk-name, granted per-browser below. 126 + FP_WRAPPER="#!/bin/sh 127 + if [ \"\${container-}\" = flatpak ]; then 128 + exec /usr/bin/flatpak-spawn --host $CMD 129 + else 130 + exec $CMD 131 + fi" 132 + 133 + for sub in "${NATIVE_CHROMIUM[@]}"; do 134 + [[ -d "$HOME/.config/$sub" ]] || continue 135 + target="$HOME/.config/$sub/NativeMessagingHosts/$NAME.json" 136 + mkdir -p "$(dirname "$target")" 137 + chromium_manifest "$WRAPPER" >"$target" 138 + echo " $target" 139 + N=$((N + 1)) 140 + done 141 + 142 + for sub in "${NATIVE_FIREFOX[@]}"; do 143 + [[ -d "$HOME/$sub" ]] || continue 144 + target="$HOME/$sub/native-messaging-hosts/$NAME.json" 145 + mkdir -p "$(dirname "$target")" 146 + firefox_manifest "$WRAPPER" >"$target" 147 + echo " $target" 148 + N=$((N + 1)) 149 + done 150 + 151 + for entry in "${FLATPAK_CHROMIUM[@]}"; do 152 + appId="${entry%%:*}" 153 + sub="${entry##*:}" 154 + [[ -d "$HOME/.var/app/$appId" ]] || continue 155 + dir="$HOME/.var/app/$appId/config/$sub/NativeMessagingHosts" 156 + wp="$dir/switchyard-native-host.sh" 157 + mkdir -p "$dir" 158 + printf '%s\n' "$FP_WRAPPER" >"$wp" && chmod +x "$wp" 159 + chromium_manifest "$wp" >"$dir/$NAME.json" 160 + flatpak override --user --talk-name=org.freedesktop.Flatpak "$appId" 161 + echo " $dir/$NAME.json [Flatpak]" 162 + N=$((N + 1)) 163 + done 164 + 165 + for entry in "${FLATPAK_FIREFOX[@]}"; do 166 + appId="${entry%%:*}" 167 + sub="${entry##*:}" 168 + [[ -d "$HOME/.var/app/$appId" ]] || continue 169 + dir="$HOME/.var/app/$appId/$sub/native-messaging-hosts" 170 + wp="$dir/switchyard-native-host.sh" 171 + mkdir -p "$dir" 172 + printf '%s\n' "$FP_WRAPPER" >"$wp" && chmod +x "$wp" 173 + firefox_manifest "$wp" >"$dir/$NAME.json" 174 + flatpak override --user --talk-name=org.freedesktop.Flatpak "$appId" 175 + echo " $dir/$NAME.json [Flatpak]" 176 + N=$((N + 1)) 177 + done 178 + 179 + [[ $N -gt 0 ]] || { 180 + echo 181 + echo "No supported browsers found." 182 + exit 0 183 + } 184 + echo 185 + echo "Installed $N manifest(s). Restart your browser to activate."
-184
src/install_native_host.go
··· 1 - // SPDX-License-Identifier: GPL-3.0-or-later 2 - 3 - package main 4 - 5 - import ( 6 - "bytes" 7 - _ "embed" 8 - "fmt" 9 - "os" 10 - "os/exec" 11 - "path/filepath" 12 - "strings" 13 - ) 14 - 15 - //go:embed embedded/native-messaging-host-chromium.json 16 - var chromiumManifestTemplate string 17 - 18 - //go:embed embedded/native-messaging-host-firefox.json 19 - var firefoxManifestTemplate string 20 - 21 - // Must match HOST in webextension/popup.js. 22 - const nativeHostName = "io.github.alyraffauf.switchyard" 23 - 24 - var chromiumConfigSubdirs = []string{ 25 - "net.imput.helium", 26 - "google-chrome", 27 - "chromium", 28 - "BraveSoftware/Brave-Browser", 29 - "microsoft-edge", 30 - "vivaldi", 31 - } 32 - 33 - var firefoxHomeSubdirs = []string{ 34 - ".mozilla", 35 - ".librewolf", 36 - ".zen", 37 - } 38 - 39 - func installNativeHost() error { 40 - home, err := os.UserHomeDir() 41 - if err != nil { 42 - return err 43 - } 44 - wrapperPath, err := ensureNativeHostWrapper(home) 45 - if err != nil { 46 - return err 47 - } 48 - fmt.Println("Wrapper:", wrapperPath) 49 - 50 - chromiumJSON := []byte(strings.ReplaceAll(chromiumManifestTemplate, "@SWITCHYARD_NM@", wrapperPath)) 51 - firefoxJSON := []byte(strings.ReplaceAll(firefoxManifestTemplate, "@SWITCHYARD_NM@", wrapperPath)) 52 - 53 - installed := 0 54 - for _, sub := range chromiumConfigSubdirs { 55 - if installManifestTo(filepath.Join(home, ".config", sub), "NativeMessagingHosts", chromiumJSON) { 56 - installed++ 57 - } 58 - } 59 - for _, sub := range firefoxHomeSubdirs { 60 - if installManifestTo(filepath.Join(home, sub), "native-messaging-hosts", firefoxJSON) { 61 - installed++ 62 - } 63 - } 64 - 65 - if installed == 0 { 66 - fmt.Println("\nNo supported browsers found. Install a Chromium- or Firefox-family browser first.") 67 - return nil 68 - } 69 - fmt.Printf("\nInstalled %d manifest(s). Restart your browser to detect the native host.\n", installed) 70 - return nil 71 - } 72 - 73 - func uninstallNativeHost() error { 74 - home, err := os.UserHomeDir() 75 - if err != nil { 76 - return err 77 - } 78 - 79 - removed := 0 80 - for _, sub := range chromiumConfigSubdirs { 81 - if uninstallManifestFrom(filepath.Join(home, ".config", sub), "NativeMessagingHosts") { 82 - removed++ 83 - } 84 - } 85 - for _, sub := range firefoxHomeSubdirs { 86 - if uninstallManifestFrom(filepath.Join(home, sub), "native-messaging-hosts") { 87 - removed++ 88 - } 89 - } 90 - 91 - wrappers := []string{ 92 - nativeHostWrapperPath(home), 93 - // Legacy locations from earlier installs. 94 - filepath.Join(home, ".local/share/flatpak/exports/bin/io.github.alyraffauf.Switchyard-native-host"), 95 - filepath.Join(home, ".local/share/flatpak/exports/bin/io.github.alyraffauf.Switchyard.Devel-native-host"), 96 - } 97 - for _, p := range wrappers { 98 - if removeFileHost(p) { 99 - fmt.Println(" removed " + p) 100 - } 101 - } 102 - 103 - fmt.Printf("\nRemoved %d manifest(s).\n", removed) 104 - return nil 105 - } 106 - 107 - func installManifestTo(profileDir, manifestSubdir string, content []byte) bool { 108 - if _, err := os.Stat(profileDir); err != nil { 109 - return false 110 - } 111 - target := filepath.Join(profileDir, manifestSubdir, nativeHostName+".json") 112 - if err := writeFileHost(target, content, 0o644); err != nil { 113 - fmt.Fprintln(os.Stderr, "warning:", target+":", err) 114 - return false 115 - } 116 - fmt.Println(" " + target) 117 - return true 118 - } 119 - 120 - func uninstallManifestFrom(profileDir, manifestSubdir string) bool { 121 - target := filepath.Join(profileDir, manifestSubdir, nativeHostName+".json") 122 - if !removeFileHost(target) { 123 - return false 124 - } 125 - fmt.Println(" removed " + target) 126 - return true 127 - } 128 - 129 - func nativeHostWrapperPath(home string) string { 130 - return filepath.Join(home, ".local/share/switchyard/native-host-wrapper.sh") 131 - } 132 - 133 - // ensureNativeHostWrapper writes the script the browser will exec as the 134 - // manifest's "path" field. The manifest format forbids inline args, so we 135 - // need a shim to add --native-host before re-entering switchyard. 136 - func ensureNativeHostWrapper(home string) (string, error) { 137 - wrapperPath := nativeHostWrapperPath(home) 138 - var script string 139 - if id := os.Getenv("FLATPAK_ID"); id != "" { 140 - script = fmt.Sprintf("#!/bin/sh\nexec /usr/bin/flatpak run --command=switchyard %s --native-host \"$@\"\n", id) 141 - } else { 142 - self, err := os.Executable() 143 - if err != nil { 144 - return "", err 145 - } 146 - script = fmt.Sprintf("#!/bin/sh\nexec %s --native-host \"$@\"\n", shellQuote(self)) 147 - } 148 - if err := writeFileHost(wrapperPath, []byte(script), 0o755); err != nil { 149 - return "", fmt.Errorf("create wrapper at %s: %w", wrapperPath, err) 150 - } 151 - return wrapperPath, nil 152 - } 153 - 154 - // writeFileHost writes to a path on the host filesystem. Inside Flatpak the 155 - // sandbox can read but not write most host paths, so it shells out via 156 - // flatpak-spawn instead of writing directly. 157 - func writeFileHost(path string, content []byte, mode os.FileMode) error { 158 - if os.Getenv("FLATPAK_ID") != "" { 159 - script := fmt.Sprintf("mkdir -p %s && cat > %s && chmod %o %s", 160 - shellQuote(filepath.Dir(path)), shellQuote(path), mode, shellQuote(path)) 161 - cmd := exec.Command("flatpak-spawn", "--host", "sh", "-c", script) 162 - cmd.Stdin = bytes.NewReader(content) 163 - cmd.Stderr = os.Stderr 164 - return cmd.Run() 165 - } 166 - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { 167 - return err 168 - } 169 - return os.WriteFile(path, content, mode) 170 - } 171 - 172 - func removeFileHost(path string) bool { 173 - if _, err := os.Stat(path); err != nil { 174 - return false 175 - } 176 - if os.Getenv("FLATPAK_ID") != "" { 177 - return exec.Command("flatpak-spawn", "--host", "rm", "-f", path).Run() == nil 178 - } 179 - return os.Remove(path) == nil 180 - } 181 - 182 - func shellQuote(s string) string { 183 - return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'" 184 - }
-19
src/main.go
··· 4 4 package main 5 5 6 6 import ( 7 - "fmt" 8 7 "net/url" 9 8 "os" 10 9 "strings" ··· 19 18 func main() { 20 19 app := adw.NewApplication(getAppID(), gio.ApplicationHandlesOpen) 21 20 22 - app.AddMainOption("install-native-host", 0, glib.OptionFlagNone, glib.OptionArgNone, 23 - "Install browser native-messaging host manifests", "") 24 - app.AddMainOption("uninstall-native-host", 0, glib.OptionFlagNone, glib.OptionArgNone, 25 - "Remove browser native-messaging host manifests", "") 26 21 app.AddMainOption("native-host", 0, glib.OptionFlagNone, glib.OptionArgNone, 27 22 "Run as native-messaging host (invoked by browsers)", "") 28 23 29 24 app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int { 30 25 if options.Contains("native-host") { 31 26 runNativeMessagingHost() 32 - return 0 33 - } 34 - if options.Contains("install-native-host") { 35 - if err := installNativeHost(); err != nil { 36 - fmt.Fprintln(os.Stderr, "error:", err) 37 - return 1 38 - } 39 - return 0 40 - } 41 - if options.Contains("uninstall-native-host") { 42 - if err := uninstallNativeHost(); err != nil { 43 - fmt.Fprintln(os.Stderr, "error:", err) 44 - return 1 45 - } 46 27 return 0 47 28 } 48 29 return -1