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.

webextension: add simple-icons support

authored by

Aly Raffauf and committed by
Aly Raffauf
(May 24, 2026, 6:44 PM EDT) ed389977 273b75c5

+125 -19
switchyard-webextension-firefox.zip

This is a binary file and will not be displayed.

+1
webextension/esbuild.config.mjs
··· 9 9 target: "es2022", 10 10 sourcemap: watch ? "inline" : false, 11 11 minify: !watch, 12 + define: watch ? {} : { "process.env.NODE_ENV": '"production"' }, 12 13 }); 13 14 14 15 if (watch) {
+25
webextension/icons/switchyard.svg
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1024 1024"> 3 + <defs> 4 + <style> 5 + .cls-1 { 6 + fill: url(#linear-gradient); 7 + } 8 + 9 + .cls-2 { 10 + fill: #fff; 11 + } 12 + </style> 13 + <linearGradient id="linear-gradient" x1="205.42" y1="205.42" x2="846.69" y2="846.69" gradientUnits="userSpaceOnUse"> 14 + <stop offset="0" stop-color="#6dd791"/> 15 + <stop offset="1" stop-color="#366946"/> 16 + </linearGradient> 17 + </defs> 18 + <rect class="cls-1" x="0" y="0" width="1024" height="1024" rx="512" ry="512"/> 19 + <g> 20 + <path class="cls-2" d="M737.91,324.02c-12.21,3-24.19,6.98-35.87,11.92-28.55,12.08-54.2,29.37-76.24,51.41-21.36,21.36-38.27,46.12-50.29,73.63,12.32,14.08,19.79,32.49,19.79,52.62,0,29.45-15.99,55.22-39.74,69.11v222.6c142.22-21.05,251.36-143.64,251.36-291.72,0-72.19-25.94-138.32-69-189.58Z"/> 21 + <path class="cls-2" d="M512,393.76c14.27-24.76,31.73-47.53,52.2-68,28.12-28.12,60.58-50.58,96.58-66.84-43.68-25.57-94.51-40.23-148.78-40.23s-105.1,14.66-148.78,40.23c36,16.27,68.46,38.73,96.58,66.84,20.47,20.47,37.93,43.24,52.2,68Z"/> 22 + <path class="cls-2" d="M435.29,513.6c0-17.66,5.75-33.99,15.48-47.24-.38-.92-.76-1.84-1.15-2.76-12.08-28.55-29.37-54.2-51.41-76.24-22.04-22.04-47.69-39.34-76.24-51.41-11.68-4.94-23.66-8.92-35.87-11.92-43.06,51.26-69,117.39-69,189.58,0,148.08,109.14,270.66,251.36,291.72v-226.9c-20.07-14.55-33.16-38.18-33.16-64.81Z"/> 23 + <circle class="cls-2" cx="515.24" cy="514.23" r="29.86" transform="translate(-104.36 132.13) rotate(-13.28)"/> 24 + </g> 25 + </svg>
+21 -1
webextension/package-lock.json
··· 9 9 "version": "0.14.0", 10 10 "dependencies": { 11 11 "react": "^19.1.0", 12 - "react-dom": "^19.1.0" 12 + "react-dom": "^19.1.0", 13 + "simple-icons": "^16.21.0" 13 14 }, 14 15 "devDependencies": { 15 16 "@types/chrome": "^0.1.30", ··· 591 592 "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", 592 593 "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", 593 594 "license": "MIT" 595 + }, 596 + "node_modules/simple-icons": { 597 + "version": "16.21.0", 598 + "resolved": "https://registry.npmjs.org/simple-icons/-/simple-icons-16.21.0.tgz", 599 + "integrity": "sha512-kH6LEx5yvBGVNaVrHnZ7D17G16CmmHiI8DD7MwIwgnWmDFTiFu4PhFZLNdV6bMGr61qbHMMTXLoo/T6C25dMGA==", 600 + "funding": [ 601 + { 602 + "type": "opencollective", 603 + "url": "https://opencollective.com/simple-icons" 604 + }, 605 + { 606 + "type": "github", 607 + "url": "https://github.com/sponsors/simple-icons" 608 + } 609 + ], 610 + "license": "CC0-1.0", 611 + "engines": { 612 + "node": ">=0.12.18" 613 + } 594 614 }, 595 615 "node_modules/typescript": { 596 616 "version": "5.9.3",
+2 -1
webextension/package.json
··· 9 9 }, 10 10 "dependencies": { 11 11 "react": "^19.1.0", 12 - "react-dom": "^19.1.0" 12 + "react-dom": "^19.1.0", 13 + "simple-icons": "^16.21.0" 13 14 }, 14 15 "devDependencies": { 15 16 "@types/chrome": "^0.1.30",
+2 -1
webextension/popup.html
··· 44 44 padding: 4px 0; 45 45 } 46 46 .row { 47 - display: block; 47 + display: flex; 48 + align-items: center; 48 49 width: 100%; 49 50 padding: 8px 12px; 50 51 border: 0;
+23 -16
webextension/src/App.tsx
··· 1 - import { useEffect, useState } from "react"; 1 + import { useEffect, useRef, useState } from "react"; 2 2 import { createRoot } from "react-dom/client"; 3 + import { BrowserIcon } from "./icons"; 3 4 4 5 interface Browser { 5 6 id: string; ··· 23 24 } 24 25 } 25 26 26 - function cacheBrowsers(browsers: Browser[]) { 27 + function cacheLocal(browsers: Browser[]) { 27 28 try { 28 29 localStorage.setItem(CACHE_KEY, JSON.stringify(browsers)); 29 - } catch { 30 - /* quota exceeded — ignore */ 31 - } 30 + } catch {} 32 31 } 33 32 34 33 function launch(url: string, tabId: number, browser?: string) { ··· 41 40 function App() { 42 41 const [tab, setTab] = useState<chrome.tabs.Tab | null>(null); 43 42 const [browsers, setBrowsers] = useState<Browser[]>(getCachedBrowsers); 44 - const [nativeHostMissing, setNativeHostMissing] = useState(false); 43 + const [loaded, setLoaded] = useState(false); 44 + 45 + const fetching = useRef(false); 45 46 46 47 useEffect(() => { 47 48 chrome.tabs.query({ active: true, currentWindow: true }, ([t]) => ··· 50 51 }, []); 51 52 52 53 useEffect(() => { 53 - if (!tab?.url || !ROUTABLE.test(tab.url)) return; 54 + if (!tab?.url || !ROUTABLE.test(tab.url) || fetching.current) return; 55 + fetching.current = true; 54 56 55 57 chrome.runtime.sendNativeMessage( 56 58 HOST, 57 59 { action: "listBrowsers" }, 58 60 (resp) => { 61 + fetching.current = false; 59 62 if (chrome.runtime.lastError) { 60 - console.error( 61 - "Switchyard native host error:", 62 - chrome.runtime.lastError.message, 63 - ); 64 - setNativeHostMissing(true); 63 + setLoaded(true); 65 64 return; 66 65 } 67 - setNativeHostMissing(false); 68 66 const list = (resp as BrowserList)?.browsers ?? []; 69 - cacheBrowsers(list); 67 + cacheLocal(list); 70 68 setBrowsers(list); 69 + setLoaded(true); 71 70 }, 72 71 ); 73 72 }, [tab]); ··· 84 83 return ( 85 84 <> 86 85 <button className="row primary" onClick={() => launch(url, tabId)}> 86 + <img 87 + src="icons/switchyard.svg" 88 + width="16" 89 + height="16" 90 + style={{ flexShrink: 0, marginRight: 8 }} 91 + alt="" 92 + /> 87 93 Open in Switchyard 88 94 </button> 89 - {(nativeHostMissing || browsers.length > 0) && ( 95 + {((loaded && browsers.length === 0) || browsers.length > 0) && ( 90 96 <div className="separator" /> 91 97 )} 92 - {nativeHostMissing && ( 98 + {loaded && browsers.length === 0 && ( 93 99 <div className="hint">Install the native host to list browsers</div> 94 100 )} 95 101 {browsers.map((b) => ( ··· 99 105 title={b.id} 100 106 onClick={() => launch(url, tabId, b.id)} 101 107 > 108 + <BrowserIcon browserId={b.id} /> 102 109 {b.name} 103 110 </button> 104 111 ))}
+51
webextension/src/icons.tsx
··· 1 + import { 2 + siFirefox, 3 + siGooglechrome, 4 + siBrave, 5 + siOpera, 6 + siOperagx, 7 + siVivaldi, 8 + siSafari, 9 + siLibrewolf, 10 + siTorbrowser, 11 + siArc, 12 + siZenbrowser, 13 + siMullvad, 14 + } from "simple-icons"; 15 + 16 + const ICON_MAP: Record<string, typeof siFirefox> = { 17 + "opera-gx": siOperagx, // before "opera" so substring match hits this first 18 + firefox: siFirefox, 19 + chrome: siGooglechrome, 20 + chromium: siGooglechrome, 21 + brave: siBrave, 22 + opera: siOpera, 23 + vivaldi: siVivaldi, 24 + librewolf: siLibrewolf, 25 + tor: siTorbrowser, 26 + arc: siArc, 27 + zen: siZenbrowser, 28 + mullvad: siMullvad, 29 + safari: siSafari, 30 + }; 31 + 32 + export function BrowserIcon({ browserId }: { browserId: string }) { 33 + const lower = browserId.toLowerCase(); 34 + const match = Object.entries(ICON_MAP).find(([key]) => lower.includes(key)); 35 + const [, ic] = match ?? []; 36 + return ( 37 + <span style={{ flexShrink: 0, width: 16, height: 16, marginRight: 8 }}> 38 + {ic && ( 39 + <svg 40 + viewBox="0 0 24 24" 41 + width="16" 42 + height="16" 43 + fill={`#${ic.hex}`} 44 + aria-hidden="true" 45 + > 46 + <path d={ic.path} /> 47 + </svg> 48 + )} 49 + </span> 50 + ); 51 + }