// config.go: lectura de config.toml. // // Parser de TOML hecho a mano y a proposito ACOTADO. La razon de no usar // una libreria (BurntSushi/toml o similar) es que el entorno donde se // compila esto no tiene acceso al proxy de modulos de Go, y ademas // mantiene el programa como un unico .exe sin dependencias. // // Soporta: comentarios (#), cabeceras de seccion ([algo]), y claves // "clave = valor" donde el valor es un string ("..." o '...'), un // booleano o un entero. NO soporta arrays, tablas inline, strings // multilinea ni claves con puntos. Cualquier cosa fuera de ese subconjunto // se reporta con numero de linea en vez de ignorarse en silencio: es // preferible enterarse al arrancar y no a mitad de un stream. package main import ( "crypto/rand" "encoding/hex" "fmt" "log" "os" "path/filepath" "strconv" "strings" ) const ( // Escucha en todas las interfaces: OBS suele correr en otra PC de la // LAN, asi que 127.0.0.1 (solo local) no alcanza. listenAddr = "0.0.0.0:47822" buildTag = "build-20-auth-obligatoria" ) type config struct { Name string // como se muestra este jugador; vacio = nombre del personaje Mode string // "hub" o "peer" Listen string // donde se sirve el panel/overlay Hub string // modo peer: direccion del hub Token string // secreto compartido opcional Partner string // opcional: fuerza el modo coop reservando este nombre Language string // idioma de la interfaz: "auto", "en", "es", ... clientID string // no viene del archivo: ver clientID() } func defaultConfig() config { return config{ Mode: "hub", Listen: listenAddr, Language: "auto", } } func configPath() string { exe, err := os.Executable() if err != nil { return "config.toml" } return filepath.Join(filepath.Dir(exe), "config.toml") } // tomlValue es un valor ya parseado: guardamos el texto crudo porque // todas nuestras claves son strings, pero validamos el tipo igual. type tomlValue struct { str string line int } // parseTOML devuelve el mapa de claves de la tabla raiz. Las claves // dentro de una seccion quedan como "seccion.clave". func parseTOML(src string) (map[string]tomlValue, []string) { out := map[string]tomlValue{} var problems []string section := "" for i, raw := range strings.Split(src, "\n") { lineNo := i + 1 line := strings.TrimSpace(raw) if line == "" || strings.HasPrefix(line, "#") { continue } if strings.HasPrefix(line, "[") { if !strings.HasSuffix(line, "]") || strings.HasPrefix(line, "[[") { problems = append(problems, fmt.Sprintf("linea %d: cabecera de seccion que no entiendo: %s", lineNo, line)) continue } section = strings.TrimSpace(line[1 : len(line)-1]) continue } key, rest, ok := strings.Cut(line, "=") if !ok { problems = append(problems, fmt.Sprintf("linea %d: esperaba \"clave = valor\" y encontre: %s", lineNo, line)) continue } key = strings.TrimSpace(key) if key == "" { problems = append(problems, fmt.Sprintf("linea %d: falta el nombre de la clave", lineNo)) continue } if section != "" { key = section + "." + key } val, err := parseTOMLValue(strings.TrimSpace(rest)) if err != nil { problems = append(problems, fmt.Sprintf("linea %d (%s): %v", lineNo, key, err)) continue } out[strings.ToLower(key)] = tomlValue{str: val, line: lineNo} } return out, problems } func parseTOMLValue(s string) (string, error) { if s == "" { return "", fmt.Errorf("falta el valor") } switch s[0] { case '"': return parseQuoted(s, '"', true) case '\'': return parseQuoted(s, '\'', false) case '[', '{': return "", fmt.Errorf("este programa no soporta listas ni tablas inline") } // Sin comillas: booleano o entero. Cortamos un comentario al final. if idx := strings.Index(s, "#"); idx >= 0 { s = strings.TrimSpace(s[:idx]) } switch strings.ToLower(s) { case "true": return "true", nil case "false": return "false", nil } if _, err := strconv.ParseInt(s, 10, 64); err == nil { return s, nil } return "", fmt.Errorf("valor sin comillas que no es booleano ni entero: %q (¿te faltaron las comillas?)", s) } // parseQuoted lee un string entre comillas y descarta lo que venga // despues si es un comentario. Con comillas dobles interpreta los // escapes mas comunes; con simples el contenido es literal, como en TOML. func parseQuoted(s string, quote byte, escapes bool) (string, error) { var sb strings.Builder for i := 1; i < len(s); i++ { c := s[i] if escapes && c == '\\' { if i+1 >= len(s) { return "", fmt.Errorf("la barra invertida final no escapa nada") } i++ switch s[i] { case 'n': sb.WriteByte('\n') case 't': sb.WriteByte('\t') case 'r': sb.WriteByte('\r') case '"': sb.WriteByte('"') case '\\': sb.WriteByte('\\') default: return "", fmt.Errorf("escape no soportado: \\%c", s[i]) } continue } if c == quote { trailing := strings.TrimSpace(s[i+1:]) if trailing != "" && !strings.HasPrefix(trailing, "#") { return "", fmt.Errorf("sobra texto despues del valor: %q", trailing) } return sb.String(), nil } sb.WriteByte(c) } return "", fmt.Errorf("falta la comilla de cierre") } var knownKeys = map[string]bool{ "name": true, "mode": true, "listen": true, "hub": true, "token": true, "partner": true, "language": true, } func loadConfig() config { cfg := defaultConfig() data, err := os.ReadFile(configPath()) if err != nil { if !os.IsNotExist(err) { log.Printf("no pude leer config.toml (%v): sigo con los valores por defecto", err) } return cfg } values, problems := parseTOML(string(data)) for _, p := range problems { log.Printf("config.toml: %s", p) } for k, v := range values { if !knownKeys[k] { log.Printf("config.toml: clave desconocida %q (linea %d), la ignoro", k, v.line) continue } switch k { case "name": cfg.Name = v.str case "mode": cfg.Mode = strings.ToLower(v.str) case "listen": if v.str != "" { cfg.Listen = v.str } case "hub": cfg.Hub = v.str case "token": cfg.Token = v.str case "partner": cfg.Partner = v.str case "language": cfg.Language = v.str } } if cfg.Mode != "hub" && cfg.Mode != "peer" { log.Printf("config.toml: mode = %q no es ni \"hub\" ni \"peer\", uso \"hub\"", cfg.Mode) cfg.Mode = "hub" } return cfg } const sampleConfig = `# DeathWatch - Elden Ring death counter # Edit this file and restart the program to apply changes. # Name shown in the overlay. # Leave it empty to use your in-game character name. name = "" # "hub" = this PC serves the overlay to OBS and receives partners. # "peer" = this PC only reads its own game and pushes to the hub. mode = "hub" # Address the panel and overlay listen on. listen = "0.0.0.0:47822" # Peer mode only: the hub's address, e.g. "100.101.102.103:47822" hub = "" # Connection secret. REQUIRED for co-op. # The hub generates one on first run and saves it to token.txt; it is # also printed in the console. Peers must set that exact value here. # The token itself never travels over the network: the hub sends a random # challenge and the peer answers with an HMAC of it. token = "" # Interface language. "auto" follows your Windows language and falls # back to English. Any file in locales/ is a valid value, e.g. "en", "es". language = "auto" # Optional. Hub only: your partner's name. Setting it forces the co-op # layout from startup, with their slot reserved before they connect. # Leave it empty to switch to co-op automatically when they show up. partner = "" ` // clientID devuelve un identificador estable de ESTA instalacion, // guardado en client-id.txt al lado del programa. // // Existe porque el nombre no sirve para identificar a un jugador: desde // que lo leemos del personaje, cambia cada vez que cargan otra partida. // Con un id propio, el hub sigue viendo al mismo compañero aunque cambie // de personaje, se reconecte o reinicie el programa. func clientID() string { path := "client-id.txt" if exe, err := os.Executable(); err == nil { path = filepath.Join(filepath.Dir(exe), "client-id.txt") } if data, err := os.ReadFile(path); err == nil { if id := strings.TrimSpace(string(data)); id != "" { return id } } var b [8]byte if _, err := rand.Read(b[:]); err != nil { // Sin aleatoriedad igual devolvemos algo estable dentro de la corrida. return fmt.Sprintf("pid-%d", os.Getpid()) } id := hex.EncodeToString(b[:]) if err := os.WriteFile(path, []byte(id), 0644); err != nil { log.Printf("no pude guardar client-id.txt (%v): uso un id nuevo en cada arranque", err) } return id } // writeSampleConfig deja un config.toml comentado la primera vez. func writeSampleConfig() { path := configPath() if _, err := os.Stat(path); err == nil { return } if err := os.WriteFile(path, []byte(sampleConfig), 0644); err != nil { log.Printf("no pude escribir el config.toml de ejemplo: %v", err) return } log.Printf("dejé un config.toml de ejemplo al lado del programa") }