···2233A 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.
4455-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`.
55+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`.
6677-The intent is to add deeper integration with the desktop app. Eventually, it will be submitted to the Chrome Web Store and Mozilla.77+The intent is to add deeper integration with the desktop app. Eventually, it will be submitted to the Chrome Web Store and Mozilla.
88+99+## Native Messaging Support
1010+1111+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.
1212+1313+Please read [`scripts/install-native-host.sh`](../scripts/install-native-host.sh) before running this command:
1414+1515+```bash
1616+curl -fsSL https://raw.githubusercontent.com/alyraffauf/Switchyard/master/scripts/install-native-host.sh | bash
1717+```
1818+1919+After installation, restart your browser(s). The extension popup will list your installed browsers.
2020+2121+### Uninstall
2222+2323+```bash
2424+bash install-native-host.sh --uninstall
2525+```
2626+2727+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
···11+#!/usr/bin/env bash
22+# SPDX-License-Identifier: GPL-3.0-or-later
33+# Switchyard native messaging host installer — https://github.com/alyraffauf/Switchyard
44+#
55+# Installs manifests that let the browser extension list your installed browsers.
66+#
77+# For Flatpak browsers this grants --talk-name=org.freedesktop.Flatpak, which
88+# lets code inside the browser sandbox run arbitrary host commands via
99+# flatpak-spawn --host. This weakens the Flatpak sandbox for those browsers.
1010+# Read and understand this before running it.
1111+#
1212+# Usage: bash install-native-host.sh [--uninstall]
1313+1414+set -euo pipefail
1515+1616+# ── Find Switchyard ────────────────────────────────────────────────────────────
1717+1818+CMD=""
1919+for id in io.github.alyraffauf.Switchyard io.github.alyraffauf.Switchyard.Devel; do
2020+ if flatpak info "$id" &>/dev/null; then
2121+ CMD="/usr/bin/flatpak run --command=switchyard $id --native-host"
2222+ break
2323+ fi
2424+done
2525+if [[ -z $CMD ]]; then
2626+ bin=$(command -v switchyard 2>/dev/null) || {
2727+ echo "error: Switchyard not found" >&2
2828+ exit 1
2929+ }
3030+ CMD="$bin --native-host"
3131+fi
3232+3333+# ── Config ─────────────────────────────────────────────────────────────────────
3434+3535+NAME="io.github.alyraffauf.switchyard"
3636+WRAPPER="$HOME/.local/share/switchyard/native-host-wrapper.sh"
3737+CHROME_ORIGIN="chrome-extension://gmdmmjfmpfbmddgphjbkbbmdolkifloi/"
3838+FF_EXT="switchyard@alyraffauf.github.io"
3939+4040+NATIVE_CHROMIUM=(net.imput.helium google-chrome chromium BraveSoftware/Brave-Browser microsoft-edge vivaldi)
4141+NATIVE_FIREFOX=(.mozilla .librewolf .zen)
4242+4343+# (appId:configSubdir) — configSubdir maps to ~/.config/<sub>/ inside the sandbox
4444+FLATPAK_CHROMIUM=(
4545+ com.google.Chrome:google-chrome
4646+ com.google.ChromeDev:google-chrome-unstable
4747+ org.chromium.Chromium:chromium
4848+ com.brave.Browser:BraveSoftware/Brave-Browser
4949+ com.microsoft.Edge:microsoft-edge
5050+ com.vivaldi.Vivaldi:vivaldi
5151+ io.github.ungoogled_software.ungoogled_chromium:chromium
5252+ net.imput.helium:net.imput.helium
5353+)
5454+5555+# (appId:profileDir) — profileDir is persisted as ~/<sub>/ inside the sandbox
5656+FLATPAK_FIREFOX=(
5757+ org.mozilla.firefox:.mozilla
5858+ io.gitlab.librewolf-community:.librewolf
5959+ io.github.zen_browser.zen:.zen
6060+)
6161+6262+# ── Helpers ────────────────────────────────────────────────────────────────────
6363+6464+N=0
6565+6666+chromium_manifest() {
6767+ printf '{"name":"%s","description":"Switchyard browser selector","path":"%s","type":"stdio","allowed_origins":["%s"]}\n' \
6868+ "$NAME" "$1" "$CHROME_ORIGIN"
6969+}
7070+7171+firefox_manifest() {
7272+ printf '{"name":"%s","description":"Switchyard browser selector","path":"%s","type":"stdio","allowed_extensions":["%s"]}\n' \
7373+ "$NAME" "$1" "$FF_EXT"
7474+}
7575+7676+# ── Uninstall ──────────────────────────────────────────────────────────────────
7777+7878+if [[ ${1-} == "--uninstall" ]]; then
7979+ echo "Removing Switchyard native messaging host..."
8080+8181+ for sub in "${NATIVE_CHROMIUM[@]}"; do
8282+ f="$HOME/.config/$sub/NativeMessagingHosts/$NAME.json"
8383+ [[ -f $f ]] && rm -f "$f" && echo " removed $f" && N=$((N + 1)) || true
8484+ done
8585+8686+ for sub in "${NATIVE_FIREFOX[@]}"; do
8787+ f="$HOME/$sub/native-messaging-hosts/$NAME.json"
8888+ [[ -f $f ]] && rm -f "$f" && echo " removed $f" && N=$((N + 1)) || true
8989+ done
9090+9191+ for entry in "${FLATPAK_CHROMIUM[@]}"; do
9292+ appId="${entry%%:*}"
9393+ sub="${entry##*:}"
9494+ dir="$HOME/.var/app/$appId/config/$sub/NativeMessagingHosts"
9595+ f="$dir/$NAME.json"
9696+ [[ -f $f ]] && rm -f "$f" "$dir/switchyard-native-host.sh" && echo " removed $f" && N=$((N + 1)) || true
9797+ done
9898+9999+ for entry in "${FLATPAK_FIREFOX[@]}"; do
100100+ appId="${entry%%:*}"
101101+ sub="${entry##*:}"
102102+ dir="$HOME/.var/app/$appId/$sub/native-messaging-hosts"
103103+ f="$dir/$NAME.json"
104104+ [[ -f $f ]] && rm -f "$f" "$dir/switchyard-native-host.sh" && echo " removed $f" && N=$((N + 1)) || true
105105+ done
106106+107107+ [[ -f $WRAPPER ]] && rm -f "$WRAPPER" && echo " removed $WRAPPER" || true
108108+109109+ echo
110110+ echo "Removed $N manifest(s)."
111111+ exit 0
112112+fi
113113+114114+# ── Install ────────────────────────────────────────────────────────────────────
115115+116116+echo "Installing Switchyard native messaging host..."
117117+118118+mkdir -p "$(dirname "$WRAPPER")"
119119+printf '#!/bin/sh\nexec %s "$@"\n' "$CMD" >"$WRAPPER"
120120+chmod +x "$WRAPPER"
121121+echo "Wrapper: $WRAPPER"
122122+123123+# Flatpak browsers need a different wrapper: they detect the sandbox via
124124+# $container and use flatpak-spawn --host to escape it. This requires the
125125+# org.freedesktop.Flatpak talk-name, granted per-browser below.
126126+FP_WRAPPER="#!/bin/sh
127127+if [ \"\${container-}\" = flatpak ]; then
128128+ exec /usr/bin/flatpak-spawn --host $CMD
129129+else
130130+ exec $CMD
131131+fi"
132132+133133+for sub in "${NATIVE_CHROMIUM[@]}"; do
134134+ [[ -d "$HOME/.config/$sub" ]] || continue
135135+ target="$HOME/.config/$sub/NativeMessagingHosts/$NAME.json"
136136+ mkdir -p "$(dirname "$target")"
137137+ chromium_manifest "$WRAPPER" >"$target"
138138+ echo " $target"
139139+ N=$((N + 1))
140140+done
141141+142142+for sub in "${NATIVE_FIREFOX[@]}"; do
143143+ [[ -d "$HOME/$sub" ]] || continue
144144+ target="$HOME/$sub/native-messaging-hosts/$NAME.json"
145145+ mkdir -p "$(dirname "$target")"
146146+ firefox_manifest "$WRAPPER" >"$target"
147147+ echo " $target"
148148+ N=$((N + 1))
149149+done
150150+151151+for entry in "${FLATPAK_CHROMIUM[@]}"; do
152152+ appId="${entry%%:*}"
153153+ sub="${entry##*:}"
154154+ [[ -d "$HOME/.var/app/$appId" ]] || continue
155155+ dir="$HOME/.var/app/$appId/config/$sub/NativeMessagingHosts"
156156+ wp="$dir/switchyard-native-host.sh"
157157+ mkdir -p "$dir"
158158+ printf '%s\n' "$FP_WRAPPER" >"$wp" && chmod +x "$wp"
159159+ chromium_manifest "$wp" >"$dir/$NAME.json"
160160+ flatpak override --user --talk-name=org.freedesktop.Flatpak "$appId"
161161+ echo " $dir/$NAME.json [Flatpak]"
162162+ N=$((N + 1))
163163+done
164164+165165+for entry in "${FLATPAK_FIREFOX[@]}"; do
166166+ appId="${entry%%:*}"
167167+ sub="${entry##*:}"
168168+ [[ -d "$HOME/.var/app/$appId" ]] || continue
169169+ dir="$HOME/.var/app/$appId/$sub/native-messaging-hosts"
170170+ wp="$dir/switchyard-native-host.sh"
171171+ mkdir -p "$dir"
172172+ printf '%s\n' "$FP_WRAPPER" >"$wp" && chmod +x "$wp"
173173+ firefox_manifest "$wp" >"$dir/$NAME.json"
174174+ flatpak override --user --talk-name=org.freedesktop.Flatpak "$appId"
175175+ echo " $dir/$NAME.json [Flatpak]"
176176+ N=$((N + 1))
177177+done
178178+179179+[[ $N -gt 0 ]] || {
180180+ echo
181181+ echo "No supported browsers found."
182182+ exit 0
183183+}
184184+echo
185185+echo "Installed $N manifest(s). Restart your browser to activate."
-184
src/install_native_host.go
···11-// SPDX-License-Identifier: GPL-3.0-or-later
22-33-package main
44-55-import (
66- "bytes"
77- _ "embed"
88- "fmt"
99- "os"
1010- "os/exec"
1111- "path/filepath"
1212- "strings"
1313-)
1414-1515-//go:embed embedded/native-messaging-host-chromium.json
1616-var chromiumManifestTemplate string
1717-1818-//go:embed embedded/native-messaging-host-firefox.json
1919-var firefoxManifestTemplate string
2020-2121-// Must match HOST in webextension/popup.js.
2222-const nativeHostName = "io.github.alyraffauf.switchyard"
2323-2424-var chromiumConfigSubdirs = []string{
2525- "net.imput.helium",
2626- "google-chrome",
2727- "chromium",
2828- "BraveSoftware/Brave-Browser",
2929- "microsoft-edge",
3030- "vivaldi",
3131-}
3232-3333-var firefoxHomeSubdirs = []string{
3434- ".mozilla",
3535- ".librewolf",
3636- ".zen",
3737-}
3838-3939-func installNativeHost() error {
4040- home, err := os.UserHomeDir()
4141- if err != nil {
4242- return err
4343- }
4444- wrapperPath, err := ensureNativeHostWrapper(home)
4545- if err != nil {
4646- return err
4747- }
4848- fmt.Println("Wrapper:", wrapperPath)
4949-5050- chromiumJSON := []byte(strings.ReplaceAll(chromiumManifestTemplate, "@SWITCHYARD_NM@", wrapperPath))
5151- firefoxJSON := []byte(strings.ReplaceAll(firefoxManifestTemplate, "@SWITCHYARD_NM@", wrapperPath))
5252-5353- installed := 0
5454- for _, sub := range chromiumConfigSubdirs {
5555- if installManifestTo(filepath.Join(home, ".config", sub), "NativeMessagingHosts", chromiumJSON) {
5656- installed++
5757- }
5858- }
5959- for _, sub := range firefoxHomeSubdirs {
6060- if installManifestTo(filepath.Join(home, sub), "native-messaging-hosts", firefoxJSON) {
6161- installed++
6262- }
6363- }
6464-6565- if installed == 0 {
6666- fmt.Println("\nNo supported browsers found. Install a Chromium- or Firefox-family browser first.")
6767- return nil
6868- }
6969- fmt.Printf("\nInstalled %d manifest(s). Restart your browser to detect the native host.\n", installed)
7070- return nil
7171-}
7272-7373-func uninstallNativeHost() error {
7474- home, err := os.UserHomeDir()
7575- if err != nil {
7676- return err
7777- }
7878-7979- removed := 0
8080- for _, sub := range chromiumConfigSubdirs {
8181- if uninstallManifestFrom(filepath.Join(home, ".config", sub), "NativeMessagingHosts") {
8282- removed++
8383- }
8484- }
8585- for _, sub := range firefoxHomeSubdirs {
8686- if uninstallManifestFrom(filepath.Join(home, sub), "native-messaging-hosts") {
8787- removed++
8888- }
8989- }
9090-9191- wrappers := []string{
9292- nativeHostWrapperPath(home),
9393- // Legacy locations from earlier installs.
9494- filepath.Join(home, ".local/share/flatpak/exports/bin/io.github.alyraffauf.Switchyard-native-host"),
9595- filepath.Join(home, ".local/share/flatpak/exports/bin/io.github.alyraffauf.Switchyard.Devel-native-host"),
9696- }
9797- for _, p := range wrappers {
9898- if removeFileHost(p) {
9999- fmt.Println(" removed " + p)
100100- }
101101- }
102102-103103- fmt.Printf("\nRemoved %d manifest(s).\n", removed)
104104- return nil
105105-}
106106-107107-func installManifestTo(profileDir, manifestSubdir string, content []byte) bool {
108108- if _, err := os.Stat(profileDir); err != nil {
109109- return false
110110- }
111111- target := filepath.Join(profileDir, manifestSubdir, nativeHostName+".json")
112112- if err := writeFileHost(target, content, 0o644); err != nil {
113113- fmt.Fprintln(os.Stderr, "warning:", target+":", err)
114114- return false
115115- }
116116- fmt.Println(" " + target)
117117- return true
118118-}
119119-120120-func uninstallManifestFrom(profileDir, manifestSubdir string) bool {
121121- target := filepath.Join(profileDir, manifestSubdir, nativeHostName+".json")
122122- if !removeFileHost(target) {
123123- return false
124124- }
125125- fmt.Println(" removed " + target)
126126- return true
127127-}
128128-129129-func nativeHostWrapperPath(home string) string {
130130- return filepath.Join(home, ".local/share/switchyard/native-host-wrapper.sh")
131131-}
132132-133133-// ensureNativeHostWrapper writes the script the browser will exec as the
134134-// manifest's "path" field. The manifest format forbids inline args, so we
135135-// need a shim to add --native-host before re-entering switchyard.
136136-func ensureNativeHostWrapper(home string) (string, error) {
137137- wrapperPath := nativeHostWrapperPath(home)
138138- var script string
139139- if id := os.Getenv("FLATPAK_ID"); id != "" {
140140- script = fmt.Sprintf("#!/bin/sh\nexec /usr/bin/flatpak run --command=switchyard %s --native-host \"$@\"\n", id)
141141- } else {
142142- self, err := os.Executable()
143143- if err != nil {
144144- return "", err
145145- }
146146- script = fmt.Sprintf("#!/bin/sh\nexec %s --native-host \"$@\"\n", shellQuote(self))
147147- }
148148- if err := writeFileHost(wrapperPath, []byte(script), 0o755); err != nil {
149149- return "", fmt.Errorf("create wrapper at %s: %w", wrapperPath, err)
150150- }
151151- return wrapperPath, nil
152152-}
153153-154154-// writeFileHost writes to a path on the host filesystem. Inside Flatpak the
155155-// sandbox can read but not write most host paths, so it shells out via
156156-// flatpak-spawn instead of writing directly.
157157-func writeFileHost(path string, content []byte, mode os.FileMode) error {
158158- if os.Getenv("FLATPAK_ID") != "" {
159159- script := fmt.Sprintf("mkdir -p %s && cat > %s && chmod %o %s",
160160- shellQuote(filepath.Dir(path)), shellQuote(path), mode, shellQuote(path))
161161- cmd := exec.Command("flatpak-spawn", "--host", "sh", "-c", script)
162162- cmd.Stdin = bytes.NewReader(content)
163163- cmd.Stderr = os.Stderr
164164- return cmd.Run()
165165- }
166166- if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
167167- return err
168168- }
169169- return os.WriteFile(path, content, mode)
170170-}
171171-172172-func removeFileHost(path string) bool {
173173- if _, err := os.Stat(path); err != nil {
174174- return false
175175- }
176176- if os.Getenv("FLATPAK_ID") != "" {
177177- return exec.Command("flatpak-spawn", "--host", "rm", "-f", path).Run() == nil
178178- }
179179- return os.Remove(path) == nil
180180-}
181181-182182-func shellQuote(s string) string {
183183- return "'" + strings.ReplaceAll(s, "'", `'"'"'`) + "'"
184184-}