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.

add initial support for native messaging host + integrated webextension

authored by

Aly Raffauf and committed by
Aly Raffauf
(May 24, 2026, 6:44 PM EDT) 5492f09d cb6c12c7

+470 -35
+1
.gitignore
··· 8 8 .pre-commit-config.yaml 9 9 .vscode/ 10 10 build-dir/ 11 + build-repo/ 11 12 build/ 12 13 coverage.out 13 14 repo/
+6
flake/packages.nix
··· 14 14 vendorHash = null; 15 15 subPackages = ["src"]; 16 16 17 + buildPhase = '' 18 + runHook preBuild 19 + go build -mod=vendor -trimpath -ldflags="-s -w" -o switchyard ./src 20 + runHook postBuild 21 + ''; 22 + 17 23 postBuild = '' 18 24 mv $GOPATH/bin/src $GOPATH/bin/switchyard || true 19 25 '';
+7
src/embedded/native-messaging-host-chromium.json
··· 1 + { 2 + "name": "io.github.alyraffauf.switchyard", 3 + "description": "Switchyard browser selector", 4 + "path": "@SWITCHYARD_NM@", 5 + "type": "stdio", 6 + "allowed_origins": ["chrome-extension://gmdmmjfmpfbmddgphjbkbbmdolkifloi/"] 7 + }
+7
src/embedded/native-messaging-host-firefox.json
··· 1 + { 2 + "name": "io.github.alyraffauf.switchyard", 3 + "description": "Switchyard browser selector", 4 + "path": "@SWITCHYARD_NM@", 5 + "type": "stdio", 6 + "allowed_extensions": ["switchyard@alyraffauf.github.io"] 7 + }
+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 + }
+31 -7
src/main.go
··· 4 4 package main 5 5 6 6 import ( 7 + "fmt" 7 8 "net/url" 8 9 "os" 9 10 "strings" 10 - "sync" 11 11 12 12 "github.com/diamondburned/gotk4-adwaita/pkg/adw" 13 13 "github.com/diamondburned/gotk4/pkg/gdk/v4" 14 14 "github.com/diamondburned/gotk4/pkg/gio/v2" 15 + "github.com/diamondburned/gotk4/pkg/glib/v2" 15 16 "github.com/diamondburned/gotk4/pkg/gtk/v4" 16 17 ) 17 18 18 - // Global flag to track if we're currently saving config to avoid file watcher race conditions 19 - var ( 20 - isSaving bool 21 - savingMux sync.Mutex 22 - ) 23 - 24 19 func main() { 25 20 app := adw.NewApplication(getAppID(), gio.ApplicationHandlesOpen) 21 + 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 + app.AddMainOption("native-host", 0, glib.OptionFlagNone, glib.OptionArgNone, 27 + "Run as native-messaging host (invoked by browsers)", "") 28 + 29 + app.ConnectHandleLocalOptions(func(options *glib.VariantDict) int { 30 + if options.Contains("native-host") { 31 + 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 + return 0 47 + } 48 + return -1 49 + }) 26 50 27 51 app.ConnectActivate(func() { 28 52 cfg := loadConfig()
+77
src/native_host.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + // Native-messaging host: stdin/stdout JSON framing with browser extensions. 4 + 5 + package main 6 + 7 + import ( 8 + "encoding/binary" 9 + "encoding/json" 10 + "io" 11 + "os" 12 + "sort" 13 + "strings" 14 + "sync" 15 + ) 16 + 17 + // Config save state shared with ui_helpers.go's debounced writer. 18 + var ( 19 + isSaving bool 20 + savingMux sync.Mutex 21 + ) 22 + 23 + type clientRequest struct { 24 + Action string `json:"action"` 25 + } 26 + 27 + type browserSummary struct { 28 + ID string `json:"id"` 29 + Name string `json:"name"` 30 + } 31 + 32 + func runNativeMessagingHost() { 33 + var length uint32 34 + if err := binary.Read(os.Stdin, binary.NativeEndian, &length); err != nil { 35 + return 36 + } 37 + payload := make([]byte, length) 38 + if _, err := io.ReadFull(os.Stdin, payload); err != nil { 39 + return 40 + } 41 + 42 + var req clientRequest 43 + if err := json.Unmarshal(payload, &req); err != nil { 44 + writeResponse(map[string]string{"error": "invalid request"}) 45 + return 46 + } 47 + 48 + switch req.Action { 49 + case "listBrowsers": 50 + writeResponse(map[string]any{"browsers": listInstalledBrowsers()}) 51 + default: 52 + writeResponse(map[string]string{"error": "unknown action: " + req.Action}) 53 + } 54 + } 55 + 56 + func listInstalledBrowsers() []browserSummary { 57 + browsers := detectBrowsers() 58 + out := make([]browserSummary, 0, len(browsers)) 59 + for _, b := range browsers { 60 + // detectBrowsers skips the currently-running variant; drop the other 61 + // one too so Devel and Stable never list each other. 62 + if strings.HasPrefix(b.ID, "io.github.alyraffauf.Switchyard") { 63 + continue 64 + } 65 + out = append(out, browserSummary{b.ID, b.Name}) 66 + } 67 + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) 68 + return out 69 + } 70 + 71 + func writeResponse(v any) { 72 + out, _ := json.Marshal(v) 73 + var prefix [4]byte 74 + binary.NativeEndian.PutUint32(prefix[:], uint32(len(out))) 75 + os.Stdout.Write(prefix[:]) 76 + os.Stdout.Write(out) 77 + }
-5
webextension/background.js
··· 1 - chrome.action.onClicked.addListener((tab) => { 2 - const encoded = encodeURIComponent(tab.url); 3 - const switchyardURL = `switchyard://open?url=${encoded}`; 4 - chrome.tabs.update(tab.id, { url: switchyardURL }); 5 - });
webextension/icons/switchyard-128.png

This is a binary file and will not be displayed.

webextension/icons/switchyard-16.png

This is a binary file and will not be displayed.

webextension/icons/switchyard-48.png

This is a binary file and will not be displayed.

+21 -23
webextension/manifest.json
··· 1 1 { 2 - "manifest_version": 3, 3 - "name": "Open in Switchyard", 4 - "version": "0.14.0", 5 - "description": "Open the current page in Switchyard for browser selection", 6 - "homepage_url": "https://github.com/alyraffauf/Switchyard", 7 - "permissions": [ 8 - "activeTab" 9 - ], 10 - "action": { 11 - "default_title": "Open in Switchyard", 12 - "default_icon": { 13 - "16": "icons/switchyard-16.png", 14 - "48": "icons/switchyard-48.png", 15 - "128": "icons/switchyard-128.png" 16 - } 17 - }, 18 - "background": { 19 - "service_worker": "background.js" 20 - }, 21 - "browser_specific_settings": { 22 - "gecko": { 23 - "id": "switchyard@alyraffauf.github.io" 24 - } 2 + "manifest_version": 3, 3 + "name": "Open in Switchyard", 4 + "description": "Open the current page in Switchyard for browser selection", 5 + "version": "0.14.0", 6 + "homepage_url": "https://github.com/alyraffauf/Switchyard", 7 + "permissions": ["activeTab", "nativeMessaging"], 8 + "key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAmV3D3go08Nhd2PfSt7t5IbK2dT8n01umhIEbExICCWhkO6qZx4E/TW+OXH9CyOIDaUnItt+ZV3VWD1TkLvHNLij5RQLxym6VSzNA4WPplVJvY/E1x3qiBVE+3KZK8tF3bWQ0d7VBFn8sUd4JkyKCs69rn+K8fuej30RCXwqtnFSP2H+nD69vsbGoy2YQ8v8RRN1TlIcSEJEfmcMXT20H7gVH1j/egULrzpey/blrCMRjxFAwbb1eNKa4OwVe4i/g72moo6RvOl1gbxem8RqoXhzWxIBfhpetf0fNqsgS3ZwKgVLr+teyX4MNELA5En1TItjeyYYFNn++1Y4QB90OpwIDAQAB", 9 + "icons": { 10 + "16": "icons/switchyard-16.png", 11 + "48": "icons/switchyard-48.png", 12 + "128": "icons/switchyard-128.png" 13 + }, 14 + "action": { 15 + "default_popup": "popup.html", 16 + "default_icon": { 17 + "16": "icons/switchyard-16.png", 18 + "48": "icons/switchyard-48.png" 25 19 } 20 + }, 21 + "browser_specific_settings": { 22 + "gecko": { "id": "switchyard@alyraffauf.github.io" } 23 + } 26 24 }
+82
webextension/popup.html
··· 1 + <!doctype html> 2 + <meta charset="utf-8" /> 3 + <style> 4 + :root { 5 + color-scheme: light dark; 6 + --bg: #fff; 7 + --fg: #1f1f1f; 8 + --muted: #6b6b6b; 9 + --hover: rgba(0, 0, 0, 0.08); 10 + --active: rgba(0, 0, 0, 0.14); 11 + --sep: rgba(0, 0, 0, 0.1); 12 + --accent: #3584e4; 13 + } 14 + :root.dark { 15 + --bg: #242424; 16 + --fg: #ececec; 17 + --muted: #a0a0a0; 18 + --hover: rgba(255, 255, 255, 0.1); 19 + --active: rgba(255, 255, 255, 0.18); 20 + --sep: rgba(255, 255, 255, 0.12); 21 + --accent: #62a0ea; 22 + } 23 + body { 24 + margin: 0; 25 + min-width: 240px; 26 + background: var(--bg); 27 + color: var(--fg); 28 + font: 29 + 13px system-ui, 30 + sans-serif; 31 + } 32 + .header { 33 + padding: 10px 12px 8px; 34 + border-bottom: 1px solid var(--sep); 35 + color: var(--muted); 36 + font-size: 11px; 37 + overflow: hidden; 38 + text-overflow: ellipsis; 39 + white-space: nowrap; 40 + } 41 + #root { 42 + padding: 4px 0; 43 + } 44 + .row { 45 + display: block; 46 + width: 100%; 47 + padding: 8px 12px; 48 + border: 0; 49 + background: transparent; 50 + color: inherit; 51 + font: inherit; 52 + text-align: left; 53 + cursor: pointer; 54 + } 55 + .row:hover { 56 + background: var(--hover); 57 + } 58 + .row:active { 59 + background: var(--active); 60 + } 61 + .row:focus-visible { 62 + outline: 2px solid var(--accent); 63 + outline-offset: -2px; 64 + } 65 + .row.primary { 66 + font-weight: 600; 67 + } 68 + .separator { 69 + height: 1px; 70 + margin: 4px 8px; 71 + background: var(--sep); 72 + } 73 + .hint { 74 + padding: 10px 12px; 75 + color: var(--muted); 76 + font-size: 11px; 77 + text-align: center; 78 + } 79 + </style> 80 + <div id="header" class="header" hidden></div> 81 + <div id="root"></div> 82 + <script src="popup.js"></script>
+54
webextension/popup.js
··· 1 + const root = document.getElementById("root"); 2 + const header = document.getElementById("header"); 3 + const HOST = "io.github.alyraffauf.switchyard"; 4 + const ROUTABLE = /^(https?|ftp|file):/; 5 + 6 + const dark = matchMedia("(prefers-color-scheme: dark)"); 7 + const setTheme = () => 8 + document.documentElement.classList.toggle("dark", dark.matches); 9 + dark.addEventListener("change", setTheme); 10 + setTheme(); 11 + 12 + const make = (tag, className, text) => { 13 + const el = document.createElement(tag); 14 + if (className) el.className = className; 15 + if (text) el.textContent = text; 16 + root.appendChild(el); 17 + return el; 18 + }; 19 + 20 + const launch = (tab, browser) => { 21 + const params = new URLSearchParams({ url: tab.url }); 22 + if (browser) params.set("browser", browser); 23 + chrome.tabs.update(tab.id, { url: `switchyard://open?${params}` }); 24 + window.close(); 25 + }; 26 + 27 + chrome.tabs.query({ active: true, currentWindow: true }, ([tab]) => { 28 + if (!tab?.url) return make("div", "hint", "No active tab"); 29 + if (!ROUTABLE.test(tab.url)) 30 + return make("div", "hint", "This page can't be opened in another browser"); 31 + 32 + // header.textContent = tab.title || tab.url; 33 + // header.title = tab.url; 34 + // header.hidden = false; 35 + 36 + make("button", "row primary", "Open in Switchyard").onclick = () => 37 + launch(tab); 38 + 39 + chrome.runtime.sendNativeMessage(HOST, { action: "listBrowsers" }, (resp) => { 40 + if (chrome.runtime.lastError) { 41 + make("div", "separator"); 42 + make("div", "hint", "Install the native host to list browsers"); 43 + return; 44 + } 45 + const browsers = resp?.browsers ?? []; 46 + if (!browsers.length) return; 47 + make("div", "separator"); 48 + for (const b of browsers) { 49 + const btn = make("button", "row", b.name); 50 + btn.title = b.id; 51 + btn.onclick = () => launch(tab, b.id); 52 + } 53 + }); 54 + });