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.

feat: add automatic link sanitization using AdGuard filters

authored by

screwys and committed by
Aly Raffauf
(May 21, 2026, 3:16 PM EDT) 9a9b143d 3bc0ea3f

+204 -3
+2
src/config.go
··· 20 20 ShowAppNames bool `toml:"show_app_names"` 21 21 ForceDarkMode bool `toml:"force_dark_mode"` 22 22 StayAlive bool `toml:"stay_alive"` 23 + SanitizeLinks bool `toml:"sanitize_links"` 23 24 Redirections []Redirection `toml:"redirections,omitempty"` 24 25 Rules []Rule `toml:"rules"` 25 26 } ··· 63 64 CheckDefaultBrowser: true, 64 65 ShowAppNames: false, // Default: hide app names, show tooltips 65 66 ForceDarkMode: true, // Default: force dark mode 67 + SanitizeLinks: false, 66 68 Rules: []Rule{}, 67 69 } 68 70
+10
src/main.go
··· 26 26 27 27 app.ConnectActivate(func() { 28 28 cfg := loadConfig() 29 + InitSanitizer(cfg) 29 30 if cfg.StayAlive { 30 31 app.Hold() 31 32 } ··· 36 37 37 38 app.ConnectOpen(func(files []gio.Filer, hint string) { 38 39 cfg := loadConfig() 40 + InitSanitizer(cfg) 39 41 if cfg.StayAlive { 40 42 app.Hold() 41 43 } ··· 61 63 cmd := hostCommand("xdg-open", rawURL) 62 64 cmd.Start() 63 65 return 66 + } 67 + 68 + if cfg.SanitizeLinks { 69 + sanitized = applyTrackingProtection(sanitized) 64 70 } 65 71 66 72 if len(cfg.Redirections) > 0 { ··· 110 116 cmd := hostCommand("xdg-open", targetURL) 111 117 cmd.Start() 112 118 return 119 + } 120 + 121 + if cfg.SanitizeLinks { 122 + sanitized = applyTrackingProtection(sanitized) 113 123 } 114 124 115 125 if len(cfg.Redirections) > 0 {
+168
src/sanitizer.go
··· 1 + // SPDX-License-Identifier: GPL-3.0-or-later 2 + 3 + package main 4 + 5 + import ( 6 + "bufio" 7 + "net/http" 8 + "net/url" 9 + "os" 10 + "path/filepath" 11 + "regexp" 12 + "strings" 13 + "sync" 14 + "time" 15 + ) 16 + 17 + const adguardURL = "https://raw.githubusercontent.com/AdguardTeam/FiltersRegistry/master/filters/filter_17_TrackParam/filter.txt" 18 + 19 + var ( 20 + sanitizerRules []adGuardRule 21 + ruleMutex sync.RWMutex 22 + initOnce sync.Once 23 + ) 24 + 25 + type adGuardRule struct { 26 + isException bool 27 + urlRegex *regexp.Regexp // Filter for the URL (e.g., ||domain.com^) 28 + paramRegex *regexp.Regexp // Filter for the parameter name 29 + paramName string // Literal parameter name if not regex 30 + } 31 + 32 + func InitSanitizer(cfg *Config) { 33 + if !cfg.SanitizeLinks { 34 + return 35 + } 36 + initOnce.Do(func() { 37 + go func() { 38 + path := filepath.Join(configDir(), "adguard_filter.txt") 39 + if info, err := os.Stat(path); err != nil || time.Since(info.ModTime()) > 24*time.Hour { 40 + if resp, err := http.Get(adguardURL); err == nil && resp.StatusCode == 200 { 41 + defer resp.Body.Close() 42 + os.MkdirAll(filepath.Dir(path), 0755) 43 + if out, err := os.Create(path); err == nil { 44 + defer out.Close() 45 + _, _ = out.ReadFrom(resp.Body) 46 + } 47 + } 48 + } 49 + 50 + if file, err := os.Open(path); err == nil { 51 + defer file.Close() 52 + var rules []adGuardRule 53 + scanner := bufio.NewScanner(file) 54 + for scanner.Scan() { 55 + if rule, ok := parseRule(scanner.Text()); ok { 56 + rules = append(rules, rule) 57 + } 58 + } 59 + ruleMutex.Lock() 60 + sanitizerRules = rules 61 + ruleMutex.Unlock() 62 + } 63 + }() 64 + }) 65 + } 66 + 67 + func parseRule(line string) (adGuardRule, bool) { 68 + line = strings.TrimSpace(line) 69 + if line == "" || strings.HasPrefix(line, "!") { 70 + return adGuardRule{}, false 71 + } 72 + 73 + rule := adGuardRule{} 74 + if strings.HasPrefix(line, "@@") { 75 + rule.isException = true 76 + line = line[2:] 77 + } 78 + 79 + parts := strings.Split(line, "$") 80 + urlPattern := parts[0] 81 + 82 + // Convert AdGuard URL pattern to Regex 83 + if urlPattern != "" { 84 + pattern := regexp.QuoteMeta(urlPattern) 85 + pattern = strings.ReplaceAll(pattern, `\|\|`, `(^|\.)`) // AdGuard || 86 + pattern = strings.ReplaceAll(pattern, `\^`, `([^a-zA-Z0-9.\-%]|$)`) // AdGuard ^ 87 + pattern = strings.ReplaceAll(pattern, `\*`, `.*`) 88 + if re, err := regexp.Compile("(?i)" + pattern); err == nil { 89 + rule.urlRegex = re 90 + } 91 + } 92 + 93 + // Parse $removeparam 94 + if len(parts) > 1 { 95 + options := strings.Split(parts[1], ",") 96 + for _, opt := range options { 97 + if strings.HasPrefix(opt, "removeparam=") { 98 + p := strings.TrimPrefix(opt, "removeparam=") 99 + if strings.HasPrefix(p, "/") && strings.HasSuffix(p, "/") { 100 + if re, err := regexp.Compile("(?i)" + p[1:len(p)-1]); err == nil { 101 + rule.paramRegex = re 102 + } 103 + } else { 104 + rule.paramName = p 105 + } 106 + return rule, true 107 + } else if opt == "removeparam" { 108 + rule.paramName = "*" // Remove all tracking params matches 109 + return rule, true 110 + } 111 + } 112 + } 113 + return adGuardRule{}, false 114 + } 115 + 116 + func applyTrackingProtection(rawURL string) string { 117 + u, err := url.Parse(rawURL) 118 + if err != nil || u.RawQuery == "" { 119 + return rawURL 120 + } 121 + 122 + ruleMutex.RLock() 123 + rules := sanitizerRules 124 + ruleMutex.RUnlock() 125 + 126 + q := u.Query() 127 + changed := false 128 + 129 + for param := range q { 130 + shouldRemove := false 131 + isWhitelisted := false 132 + 133 + for _, rule := range rules { 134 + // 1. Does the URL match this rule's domain/path filter? 135 + if rule.urlRegex != nil && !rule.urlRegex.MatchString(rawURL) { 136 + continue 137 + } 138 + 139 + // 2. Does this parameter match the rule's target? 140 + matchesParam := false 141 + if rule.paramName == "*" || rule.paramName == param { 142 + matchesParam = true 143 + } else if rule.paramRegex != nil && rule.paramRegex.MatchString(param) { 144 + matchesParam = true 145 + } 146 + 147 + if matchesParam { 148 + if rule.isException { 149 + isWhitelisted = true 150 + break // If whitelisted, we stop checking for this param 151 + } else { 152 + shouldRemove = true 153 + } 154 + } 155 + } 156 + 157 + if shouldRemove && !isWhitelisted { 158 + q.Del(param) 159 + changed = true 160 + } 161 + } 162 + 163 + if changed { 164 + u.RawQuery = q.Encode() 165 + return u.String() 166 + } 167 + return rawURL 168 + }
+16
src/settings_behavior.go
··· 26 26 promptRow.SetActive(cfg.PromptOnClick) 27 27 behaviorGroup.Add(promptRow) 28 28 29 + sanitizeRow := adw.NewSwitchRow() 30 + sanitizeRow.SetTitle("Sanitize links") 31 + sanitizeRow.SetSubtitle("Automatically remove tracking parameters from URLs using AdGuard filters") 32 + sanitizeRow.SetActive(cfg.SanitizeLinks) 33 + behaviorGroup.Add(sanitizeRow) 34 + 29 35 // Favorite browser dropdown 30 36 browserNames := make([]string, len(browsers)+1) 31 37 browserNames[0] = "None" ··· 83 89 stayAliveRow.Connect("notify::active", func() { 84 90 cfg.StayAlive = stayAliveRow.Active() 85 91 saveConfigWithFlag(cfg) 92 + }) 93 + 94 + sanitizeRow.Connect("notify::active", func() { 95 + cfg.SanitizeLinks = sanitizeRow.Active() 96 + saveConfigWithFlag(cfg) 97 + 98 + // If toggled on, we should initialize it immediately 99 + if cfg.SanitizeLinks { 100 + InitSanitizer(cfg) 101 + } 86 102 }) 87 103 88 104 return toolbarView
+8 -3
src/window_settings.go
··· 185 185 return toolbarView 186 186 } 187 187 188 + var ( 189 + configMonitor *gio.FileMonitor 190 + ) 191 + 188 192 func watchConfigFile(cfg *Config, onChange func()) { 189 193 configFile := gio.NewFileForPath(configPath()) 190 194 monitorIface, err := configFile.Monitor(context.Background(), gio.FileMonitorNone) 191 195 if err == nil && monitorIface != nil { 192 - monitor := gio.BaseFileMonitor(monitorIface) 193 - if monitor != nil { 194 - monitor.ConnectChanged(func(file, otherFile gio.Filer, eventType gio.FileMonitorEvent) { 196 + configMonitor = gio.BaseFileMonitor(monitorIface) 197 + if configMonitor != nil { 198 + configMonitor.ConnectChanged(func(file, otherFile gio.Filer, eventType gio.FileMonitorEvent) { 195 199 if eventType == gio.FileMonitorEventChanged || eventType == gio.FileMonitorEventCreated { 196 200 savingMux.Lock() 197 201 saving := isSaving ··· 207 211 cfg.ShowAppNames = newCfg.ShowAppNames 208 212 cfg.ForceDarkMode = newCfg.ForceDarkMode 209 213 cfg.StayAlive = newCfg.StayAlive 214 + cfg.SanitizeLinks = newCfg.SanitizeLinks 210 215 cfg.HiddenBrowsers = newCfg.HiddenBrowsers 211 216 cfg.Redirections = newCfg.Redirections 212 217 cfg.Rules = newCfg.Rules