summaryrefslogtreecommitdiff
path: root/cmd/client/app.go
diff options
context:
space:
mode:
Diffstat (limited to 'cmd/client/app.go')
-rw-r--r--cmd/client/app.go729
1 files changed, 729 insertions, 0 deletions
diff --git a/cmd/client/app.go b/cmd/client/app.go
new file mode 100644
index 0000000..6d26318
--- /dev/null
+++ b/cmd/client/app.go
@@ -0,0 +1,729 @@
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "math/rand"
+ "net/http"
+ "os"
+ "os/exec"
+ "runtime"
+ "time"
+
+ wailsRuntime "github.com/wailsapp/wails/v2/pkg/runtime"
+
+ "vpnem/internal/config"
+ "vpnem/internal/engine"
+ "vpnem/internal/models"
+ "vpnem/internal/state"
+ syncpkg "vpnem/internal/sync"
+)
+
+const Version = "2.0.11"
+
+// App is the Wails backend.
+type App struct {
+ ctx context.Context
+ engine *engine.Engine
+ watchdog *engine.Watchdog
+ fetcher *syncpkg.Fetcher
+ updater *syncpkg.Updater
+ state *state.Store
+ log *engine.RingLog
+
+ catalog *models.CatalogV2
+ servers []models.Server
+ ruleSets []models.RuleSet
+ policy *models.RoutingPolicy
+
+ recommendedServerIP string
+ recommendedNodeID string
+ recommendReason string
+ studioClients int
+}
+
+// NewApp creates a new App instance.
+func NewApp(dataDir, apiURL string) *App {
+ eng := engine.New(dataDir)
+ fetcher := syncpkg.NewFetcher(apiURL)
+ rl := engine.NewRingLog(200, dataDir)
+ return &App{
+ engine: eng,
+ watchdog: engine.NewWatchdog(eng, engine.DefaultWatchdogConfig()),
+ fetcher: fetcher,
+ updater: syncpkg.NewUpdater(fetcher, Version, dataDir),
+ state: state.NewStore(dataDir),
+ log: rl,
+ }
+}
+
+// startup is called when the app starts. Must not block — Wails UI won't render until this returns.
+func (a *App) startup(ctx context.Context) {
+ a.ctx = ctx
+ _ = a.state.Load()
+ a.logEvent("vpnem " + Version + " started")
+
+ // Sync + recommendation synchronously — UI won't render until done
+ if err := a.Sync(); err != nil {
+ a.logEvent("initial sync failed: " + err.Error())
+ a.reportError("initial sync failed: " + err.Error())
+ } else {
+ a.logEvent("initial sync ok")
+ }
+
+ // Fetch recommendation synchronously — blocks until ready
+ a.fetchRecommendation()
+
+ // Periodic refresh in background
+ go func() {
+ ticker := time.NewTicker(30 * time.Minute)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if err := a.Sync(); err != nil {
+ a.logEvent("sync failed: " + err.Error())
+ }
+ a.fetchRecommendation()
+ case <-ctx.Done():
+ return
+ }
+ }
+ }()
+}
+
+// shutdown is called when the app closes.
+func (a *App) shutdown(ctx context.Context) {
+ a.logEvent("shutting down")
+ a.watchdog.StopWatching()
+ if err := a.engine.Stop(); err != nil {
+ a.logEvent("engine stop error: " + err.Error())
+ }
+ // Fallback: kill any orphaned sing-box
+ killSingbox()
+ _ = a.state.Save()
+ a.logEvent("shutdown complete")
+ a.log.Close()
+}
+
+func killSingbox() {
+ if runtime.GOOS == "windows" {
+ exec.Command("taskkill", "/F", "/IM", "sing-box.exe").Run()
+ } else {
+ exec.Command("pkill", "-f", "sing-box").Run()
+ }
+}
+
+func (a *App) logEvent(msg string) {
+ line := time.Now().Format("15:04:05") + " " + msg
+ a.log.Add(line)
+ log.Println(msg)
+}
+
+// --- Wails bindings ---
+
+// Sync fetches servers and rulesets from the API.
+func (a *App) Sync() error {
+ catalog, err := a.fetcher.FetchCatalog()
+ if err != nil {
+ return fmt.Errorf("sync catalog: %w", err)
+ }
+ a.catalog = catalog
+ a.servers = syncpkg.CatalogToServers(catalog)
+
+ rsResp, err := a.fetcher.FetchRuleSets()
+ if err != nil {
+ return fmt.Errorf("sync rulesets: %w", err)
+ }
+
+ dataDir := a.state.DataDir()
+ a.ruleSets, err = a.fetcher.DownloadRuleSets(rsResp.RuleSets, dataDir)
+ if err != nil {
+ return fmt.Errorf("download rule-sets: %w", err)
+ }
+
+ policy, err := a.fetcher.FetchRoutingPolicy()
+ if err != nil {
+ return fmt.Errorf("sync routing policy: %w", err)
+ }
+ a.policy = policy
+
+ a.state.SetLastSync(time.Now())
+ _ = a.state.Save()
+ a.logEvent(fmt.Sprintf("synced: %d servers, %d rulesets, routing policy %s", len(a.servers), len(a.ruleSets), a.policy.Version))
+
+ // Notify frontend to refresh
+ if a.ctx != nil {
+ wailsRuntime.EventsEmit(a.ctx, "synced")
+ }
+ return nil
+}
+
+func (a *App) applyProfile(serverTag, modeName string, logAction string) error {
+ server := a.findServer(serverTag)
+ if server == nil {
+ return fmt.Errorf("server not found: %s", serverTag)
+ }
+ mode := config.ModeByName(modeName)
+ if mode == nil {
+ return fmt.Errorf("mode not found: %s", modeName)
+ }
+
+ serverIPs := syncpkg.ServerIPs(a.servers)
+ activeRuleSets := a.activeRuleSets(*mode)
+ customBypass := a.state.GetCustomBypass()
+ localProxyPort, err := engine.ResolveLocalProxyPort()
+ if err != nil {
+ return fmt.Errorf("allocate local proxy port: %w", err)
+ }
+
+ a.logEvent(logAction + ": " + serverTag + " [" + modeName + "]")
+
+ // Flush DNS cache before applying a new profile (Windows caches poisoned responses).
+ flushDNS()
+
+ if err := a.engine.RestartFull(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy); err != nil {
+ a.logEvent(logAction + " failed: " + err.Error())
+ return err
+ }
+
+ a.watchdog.StartWatching(*server, *mode, activeRuleSets, serverIPs, customBypass, localProxyPort, a.policy)
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ a.state.SetLocalProxyPort(localProxyPort)
+ _ = a.state.Save()
+ a.logEvent("connected: " + serverTag)
+
+ if a.ctx != nil {
+ wailsRuntime.EventsEmit(a.ctx, "connected", serverTag)
+ }
+
+ // Report connection to server for recommendation tracking
+ go a.ReportConnection(serverTag, server.Server, a.extractNodeID(serverTag))
+
+ go a.validateConnection()
+ return nil
+}
+
+// Connect starts the VPN with the given server and mode.
+func (a *App) Connect(serverTag, modeName string) error {
+ return a.applyProfile(serverTag, modeName, "connecting")
+}
+
+// ApplyProfile switches the active server/mode live when connected.
+// If the engine is not running yet, it only persists the selection for the next connect.
+func (a *App) ApplyProfile(serverTag, modeName string) error {
+ server := a.findServer(serverTag)
+ if server == nil {
+ return fmt.Errorf("server not found: %s", serverTag)
+ }
+ mode := config.ModeByName(modeName)
+ if mode == nil {
+ return fmt.Errorf("mode not found: %s", modeName)
+ }
+
+ current := a.state.Get()
+ if !a.engine.IsRunning() {
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ _ = a.state.Save()
+ a.logEvent("profile selected: " + serverTag + " [" + modeName + "]")
+ return nil
+ }
+
+ if current.SelectedServer == serverTag && current.SelectedMode == modeName {
+ return nil
+ }
+
+ a.watchdog.StopWatching()
+ if err := a.applyProfile(serverTag, modeName, "switching profile"); err != nil {
+ return err
+ }
+
+ a.state.SetServer(serverTag)
+ a.state.SetMode(modeName)
+ _ = a.state.Save()
+ return nil
+}
+
+func flushDNS() {
+ if runtime.GOOS == "windows" {
+ exec.Command("ipconfig", "/flushdns").Run()
+ log.Println("DNS cache flushed")
+ }
+}
+
+func (a *App) validateConnection() {
+ time.Sleep(3 * time.Second)
+ if !a.engine.IsRunning() {
+ return
+ }
+
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = a.state.Get().LocalProxyPort
+ }
+ client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 10*time.Second)
+ if err != nil {
+ a.logEvent("validation setup failed — " + err.Error())
+ return
+ }
+
+ mode := config.ModeByName(a.state.Get().SelectedMode)
+ requiresExitIP := mode != nil && engine.ModeRequiresExitIP(*mode)
+
+ ip := getExitIP(client)
+ switch {
+ case ip != "":
+ a.logEvent("exit IP: " + ip)
+ case requiresExitIP:
+ a.logEvent("WARNING: could not verify exit IP")
+ default:
+ a.logEvent("validation: exit IP skipped for direct-final mode")
+ }
+
+ statusCode, err := engine.ProbeBlockedSite(localProxyPort, engine.DefaultBlockedSiteProbeURL, 10*time.Second)
+ if err == nil {
+ a.logEvent(fmt.Sprintf("validation: rutracker.org → %d OK", statusCode))
+ } else {
+ a.logEvent("validation: rutracker.org FAILED — " + err.Error())
+ }
+}
+
+// Disconnect stops the VPN and clears system proxy.
+func (a *App) Disconnect() error {
+ // Report disconnect before stopping
+ if a.engine.IsRunning() {
+ st := a.state.Get()
+ server := a.findServer(st.SelectedServer)
+ if server != nil {
+ nodeID := a.extractNodeID(st.SelectedServer)
+ go a.ReportDisconnect(server.Server, nodeID)
+ }
+ }
+
+ a.watchdog.StopWatching()
+ clearSystemProxy()
+ a.logEvent("disconnected")
+ return a.engine.Stop()
+}
+
+// SetSystemProxy sets Windows system SOCKS5 proxy directly (no TUN needed).
+// Fallback for when TUN/sing-box doesn't work with browser.
+func (a *App) SetSystemProxy(serverTag string) error {
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = a.state.Get().LocalProxyPort
+ }
+ if !a.engine.IsRunning() || localProxyPort == 0 {
+ return fmt.Errorf("connect first to start the local proxy")
+ }
+ addr := fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort)
+ if runtime.GOOS == "windows" {
+ // Route Windows system proxy through the local SOCKS5 inbound.
+ exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
+ "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "1", "/f").Run()
+ exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
+ "/v", "ProxyServer", "/t", "REG_SZ", "/d", "socks="+addr, "/f").Run()
+ a.logEvent("system proxy set to local SOCKS5 inbound: " + addr)
+ }
+ return nil
+}
+
+func clearSystemProxy() {
+ if runtime.GOOS == "windows" {
+ exec.Command("reg", "add", `HKCU\Software\Microsoft\Windows\CurrentVersion\Internet Settings`,
+ "/v", "ProxyEnable", "/t", "REG_DWORD", "/d", "0", "/f").Run()
+ log.Println("system proxy cleared")
+ }
+}
+
+// GetServers returns the server list grouped by region.
+func (a *App) GetServers() []models.Server {
+ if a.servers == nil {
+ return []models.Server{}
+ }
+ return a.servers
+}
+
+// GetRecommendedServerTag returns the tag of the recommended MULTI server.
+// Only MULTI tags are returned (vless-reality + hysteria2). SOCKS5-only tags are never recommended.
+// If the recommended IP doesn't match any MULTI server, falls back to first MULTI.
+func (a *App) GetRecommendedServerTag() string {
+ // If we have a recommended IP, try to find a MULTI server for it
+ if a.recommendedServerIP != "" && len(a.servers) > 0 {
+ // Find MULTI server matching recommended IP
+ for _, s := range a.servers {
+ if s.Server == a.recommendedServerIP && isMultiServer(s) {
+ a.logEvent("GetRecommendedServerTag: MATCH " + s.Tag)
+ return s.Tag
+ }
+ }
+ a.logEvent("GetRecommendedServerTag: no MULTI match for IP " + a.recommendedServerIP)
+ }
+
+ // Fallback: pick first MULTI server from the list
+ for _, s := range a.servers {
+ if isMultiServer(s) {
+ a.logEvent("GetRecommendedServerTag: fallback to " + s.Tag)
+ return s.Tag
+ }
+ }
+
+ a.logEvent("GetRecommendedServerTag: no MULTI servers found")
+ return ""
+}
+
+// isMultiServer checks if a server is a MULTI node (split TCP/UDP routing).
+func isMultiServer(s models.Server) bool {
+ return s.Type == "multi" || len(s.Companions) > 0
+}
+
+// IsServerRecommended checks if a given server tag matches the recommendation.
+func (a *App) IsServerRecommended(tag string) bool {
+ recTag := a.GetRecommendedServerTag()
+ return recTag != "" && tag == recTag
+}
+
+// GetRecommendationReason returns the human-readable reason for the recommendation.
+func (a *App) GetRecommendationReason() string {
+ return a.recommendReason
+}
+
+// GetCatalog returns the current canonical server catalog.
+func (a *App) GetCatalog() *models.CatalogV2 {
+ if a.catalog == nil {
+ return &models.CatalogV2{Version: "uninitialized", Nodes: []models.CatalogNode{}}
+ }
+ return a.catalog
+}
+
+// GetModes returns all available mode names.
+func (a *App) GetModes() []string {
+ return config.ModeNames()
+}
+
+// GetStatus returns the current connection status.
+func (a *App) GetStatus() map[string]any {
+ st := a.state.Get()
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = st.LocalProxyPort
+ }
+ return map[string]any{
+ "connected": a.engine.IsRunning(),
+ "server": st.SelectedServer,
+ "mode": st.SelectedMode,
+ "lastSync": st.LastSync,
+ "autoConnect": st.AutoConnect,
+ "localProxyHost": config.LocalProxyHost,
+ "localProxyPort": localProxyPort,
+ "localProxyURL": fmt.Sprintf("%s:%d", config.LocalProxyHost, localProxyPort),
+ "localProxyScheme": "socks5",
+ }
+}
+
+// GetExitIP checks the actual exit IP through the proxy.
+func (a *App) GetExitIP() string {
+ localProxyPort := a.engine.LocalProxyPort()
+ if localProxyPort == 0 {
+ localProxyPort = a.state.Get().LocalProxyPort
+ }
+ client, err := engine.HTTPClientViaSOCKS5(config.LocalProxyHost, localProxyPort, 5*time.Second)
+ if err != nil {
+ return ""
+ }
+ return getExitIP(client)
+}
+
+func getExitIP(client *http.Client) string {
+ resp, err := client.Get("http://ifconfig.me/ip")
+ if err != nil {
+ return ""
+ }
+ defer resp.Body.Close()
+ buf := make([]byte, 64)
+ n, _ := resp.Body.Read(buf)
+ return string(buf[:n])
+}
+
+// SetAutoConnect updates the auto-connect setting.
+func (a *App) SetAutoConnect(v bool) {
+ a.state.SetAutoConnect(v)
+ _ = a.state.Save()
+}
+
+// GetRuleSets returns all rule-sets with their enabled status.
+func (a *App) GetRuleSets() []map[string]any {
+ result := make([]map[string]any, 0)
+ for _, rs := range a.ruleSets {
+ enabled := !rs.Optional || a.state.IsRuleSetEnabled(rs.Tag)
+ result = append(result, map[string]any{
+ "tag": rs.Tag,
+ "description": rs.Description,
+ "type": rs.Type,
+ "optional": rs.Optional,
+ "enabled": enabled,
+ })
+ }
+ return result
+}
+
+// SetRuleSetEnabled enables or disables an optional rule-set.
+func (a *App) SetRuleSetEnabled(tag string, enabled bool) {
+ a.state.SetRuleSetEnabled(tag, enabled)
+ _ = a.state.Save()
+}
+
+// GetBypassProcesses returns default + custom bypass processes.
+func (a *App) GetBypassProcesses() map[string]any {
+ policy := config.EffectiveRoutingPolicy(a.policy)
+ return map[string]any{
+ "default": policy.AlwaysDirectProcesses,
+ "custom": a.state.GetCustomBypass(),
+ }
+}
+
+// AddBypassProcess adds a custom bypass process.
+func (a *App) AddBypassProcess(name string) {
+ current := a.state.GetCustomBypass()
+ for _, p := range current {
+ if p == name {
+ return
+ }
+ }
+ a.state.SetCustomBypass(append(current, name))
+ _ = a.state.Save()
+}
+
+// RemoveBypassProcess removes a custom bypass process.
+func (a *App) RemoveBypassProcess(name string) {
+ current := a.state.GetCustomBypass()
+ var filtered []string
+ for _, p := range current {
+ if p != name {
+ filtered = append(filtered, p)
+ }
+ }
+ a.state.SetCustomBypass(filtered)
+ _ = a.state.Save()
+}
+
+// MeasureLatency pings all servers and returns sorted results.
+func (a *App) MeasureLatency() []syncpkg.LatencyResult {
+ a.logEvent("measuring latency...")
+ results := syncpkg.MeasureLatency(a.servers, 3*time.Second)
+ for _, r := range results {
+ if r.Latency >= 0 {
+ a.logEvent(fmt.Sprintf(" %s: %dms", r.Tag, r.Latency))
+ }
+ }
+ return results
+}
+
+// GetLogs returns the last N log lines.
+func (a *App) GetLogs() []string {
+ return a.log.Lines()
+}
+
+// GetGeneratedConfig returns the current sing-box config JSON for diagnostics.
+func (a *App) GetGeneratedConfig() string {
+ path := a.engine.ConfigPath()
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return ""
+ }
+ return string(data)
+}
+
+// CheckUpdate checks if a new version is available.
+func (a *App) CheckUpdate() (*syncpkg.UpdateInfo, error) {
+ return a.updater.Check()
+}
+
+// DownloadUpdate downloads the new binary. Returns "restart_pending" on success.
+// The caller should then shut down Wails gracefully — the OS will restart the app.
+func (a *App) DownloadUpdate() (string, error) {
+ result, err := a.updater.Download()
+ if err != nil {
+ a.logEvent("update download failed: " + err.Error())
+ return "", err
+ }
+ if result == "restart_pending" {
+ a.logEvent("update installed, restarting...")
+ // Shut down Wails gracefully — the OS Scheduled Task will relaunch
+ go func() {
+ time.Sleep(2 * time.Second)
+ wailsRuntime.Quit(a.ctx)
+ }()
+ }
+ return result, nil
+}
+
+// RandomNLServer picks a random non-RU server tag.
+func (a *App) RandomNLServer() string {
+ var candidates []string
+ for _, s := range a.servers {
+ if s.Region != "RU" {
+ candidates = append(candidates, s.Tag)
+ }
+ }
+ if len(candidates) == 0 {
+ return ""
+ }
+ return candidates[rand.Intn(len(candidates))]
+}
+
+// fetchRecommendation fetches a recommended server from the API.
+// Server auto-detects client real IP from X-Forwarded-For header.
+func (a *App) fetchRecommendation() {
+ a.logEvent("fetchRecommendation: starting")
+
+ // Check if we have a recent recommendation
+ recServer, recNodeID, recTime := a.state.GetRecommendation()
+ if recServer != "" && time.Since(recTime) < 15*time.Minute {
+ a.recommendedServerIP = recServer
+ a.recommendedNodeID = recNodeID
+ a.recommendReason = "cached"
+ a.logEvent("fetchRecommendation: using cached " + recServer)
+ return
+ }
+
+ // Server detects client IP via X-Forwarded-For
+ rec, err := a.fetcher.GetRecommendation()
+ if err != nil {
+ a.logEvent("fetchRecommendation: error — " + err.Error())
+ // Fallback: keep stale recommendation if available
+ if recServer != "" {
+ a.recommendedServerIP = recServer
+ a.recommendedNodeID = recNodeID
+ a.recommendReason = "stale (server unreachable)"
+ a.logEvent("fetchRecommendation: using stale " + recServer)
+ }
+ return
+ }
+
+ if rec.RecommendedServerIP == "" {
+ a.logEvent("fetchRecommendation: empty recommendation from server")
+ return
+ }
+
+ a.recommendedServerIP = rec.RecommendedServerIP
+ a.recommendedNodeID = rec.RecommendedNodeID
+ a.recommendReason = rec.Reason
+ if rec.IsRebalance {
+ a.recommendReason += " (ребалансировка)"
+ }
+
+ a.state.SetRecommendation(rec.RecommendedServerIP, rec.RecommendedNodeID)
+ _ = a.state.Save()
+
+ a.logEvent("fetchRecommendation: set " + rec.RecommendedServerIP + " — " + rec.Reason)
+ if rec.LoadInfo != "" {
+ a.logEvent(" load: " + rec.LoadInfo)
+ }
+}
+
+// GetRecommendation returns the current recommendation data.
+func (a *App) GetRecommendation() map[string]any {
+ return map[string]any{
+ "server_ip": a.recommendedServerIP,
+ "node_id": a.recommendedNodeID,
+ "reason": a.recommendReason,
+ "studio_clients": a.studioClients,
+ }
+}
+
+// ReportConnection sends a connect notification to the server.
+// Server auto-detects client real IP from X-Forwarded-For.
+func (a *App) ReportConnection(serverTag, serverIP, nodeID string) {
+ a.logEvent("report connect: " + serverTag + " → " + serverIP)
+
+ resp, err := a.fetcher.ReportConnect(serverIP, nodeID)
+ if err != nil {
+ a.logEvent("report connect error: " + err.Error())
+ return
+ }
+
+ // Update recommendation if server returned a different one
+ if resp.RecommendedServerIP != "" && resp.RecommendedServerIP != a.recommendedServerIP {
+ old := a.recommendedServerIP
+ a.recommendedServerIP = resp.RecommendedServerIP
+ a.recommendedNodeID = resp.RecommendedNodeID
+ a.recommendReason = resp.Reason
+ if resp.IsRebalance {
+ a.recommendReason += " (ребалансировка)"
+ }
+ a.state.SetRecommendation(resp.RecommendedServerIP, resp.RecommendedNodeID)
+ _ = a.state.Save()
+ a.logEvent("recommendation updated: " + old + " → " + resp.RecommendedServerIP)
+ }
+}
+
+// ReportDisconnect notifies the server of disconnection.
+func (a *App) ReportDisconnect(serverIP, nodeID string) {
+ a.logEvent("report disconnect: " + serverIP)
+ if err := a.fetcher.ReportDisconnect(serverIP, nodeID); err != nil {
+ a.logEvent("report disconnect error: " + err.Error())
+ }
+}
+
+func (a *App) extractNodeID(serverTag string) string {
+ // Tags like "nl-198-multi", "nl-198-vless-reality", "nl-198-socks5" -> "nl-198"
+ for _, suffix := range []string{"-multi", "-vless-reality", "-vless", "-vmess", "-shadowsocks", "-hysteria2", "-socks5", "-socks"} {
+ if len(serverTag) > len(suffix) && serverTag[len(serverTag)-len(suffix):] == suffix {
+ return serverTag[:len(serverTag)-len(suffix)]
+ }
+ }
+ return serverTag
+}
+
+func (a *App) findServer(tag string) *models.Server {
+ for _, s := range a.servers {
+ if s.Tag == tag {
+ return &s
+ }
+ }
+ return nil
+}
+
+// activeRuleSets returns rule-sets for the mode PLUS domain rule-sets
+// always needed for DNS anti-poisoning (refilter-domains etc).
+func (a *App) activeRuleSets(mode config.Mode) []models.RuleSet {
+ needed := make(map[string]bool)
+
+ // Rule-sets referenced by route rules
+ for _, r := range mode.Rules {
+ for _, tag := range r.RuleSet {
+ needed[tag] = true
+ }
+ }
+
+ // Always include domain-type rule-sets for DNS rules
+ // (prevents ISP DNS poisoning for blocked domains)
+ for _, rs := range a.ruleSets {
+ if !rs.Optional && rs.Type == "domain" {
+ needed[rs.Tag] = true
+ }
+ }
+
+ var result []models.RuleSet
+ for _, rs := range a.ruleSets {
+ if needed[rs.Tag] {
+ result = append(result, rs)
+ }
+ }
+ return result
+}
+
+func (a *App) reportError(msg string) {
+ if a.fetcher == nil {
+ return
+ }
+ osName := "linux"
+ if runtime.GOOS == "windows" {
+ osName = "windows"
+ }
+ go a.fetcher.ReportError(Version, osName, a.log.Lines())
+}