From 34efbaf26d3cf6afbb01514e7091f8a67a2d4dba Mon Sep 17 00:00:00 2001 From: Peter Edley Date: Sat, 19 Sep 2026 08:35:07 +0100 Subject: [PATCH] feat: support custom --config path in CLI and GUI Add config.LoadFrom/SaveTo for arbitrary paths and thread a --config flag through both entry points: - CLI: --config loads a file first; explicit flags override file values - GUI: --config selects the settings file used for load and save - tests for LoadFrom/SaveTo and default fallback --- cmd/teleportfling-gui/main.go | 11 ++++++- cmd/teleportfling/main.go | 56 ++++++++++++++++++++++++++++------ internal/config/config.go | 25 ++++++++++----- internal/config/config_test.go | 30 ++++++++++++++++++ internal/gui/gui.go | 33 +++++++++++++++----- 5 files changed, 128 insertions(+), 27 deletions(-) diff --git a/cmd/teleportfling-gui/main.go b/cmd/teleportfling-gui/main.go index 53ef864..26e623b 100644 --- a/cmd/teleportfling-gui/main.go +++ b/cmd/teleportfling-gui/main.go @@ -3,12 +3,21 @@ // // The streaming engine lives in internal/flinger; this command only wires the // GUI and tray around it. Headless use is handled by cmd/teleportfling. +// +// Usage: +// +// teleportfling-gui [--config PATH] package main import ( + "flag" + "teleportfling/internal/gui" ) func main() { - gui.Run() + configPath := flag.String("config", "", "config file path (default: ~/.config/teleportfling/config.json)") + flag.Parse() + + gui.Run(*configPath) } diff --git a/cmd/teleportfling/main.go b/cmd/teleportfling/main.go index d1b7b89..9581734 100644 --- a/cmd/teleportfling/main.go +++ b/cmd/teleportfling/main.go @@ -14,10 +14,15 @@ // teleportfling [--name NAME] [--port PORT] [--quality 1..100] // [--fps N] [--source screen|pattern] [--audio] // [--stream-index N] [--duration SECONDS] +// [--config PATH] // // --source pattern selects the M1 synthetic test pattern (colour bars with a // moving box) instead of real screen capture, which is useful for testing // without granting screen-share permission. +// +// --config loads a saved config file first; any flag given explicitly on the +// command line overrides the file value. When running under a service manager +// (e.g. a systemd user unit) use --config to point at the daemon's profile. package main import ( @@ -28,6 +33,7 @@ import ( "syscall" "time" + "teleportfling/internal/config" "teleportfling/internal/flinger" ) @@ -42,19 +48,33 @@ func main() { noAnnounce = flag.Bool("no-announce", false, "do not announce on the LAN (receiver must connect by IP)") streamIndex = flag.Int("stream-index", 0, "monitor index to capture (screen source)") duration = flag.Duration("duration", 0, "stream duration (0 = run until interrupted)") + configPath = flag.String("config", "", "config file path (default: ~/.config/teleportfling/config.json)") ) flag.Parse() - cfg := flinger.Config{ - Name: *name, - Port: *port, - Quality: *quality, - FPS: *fps, - Source: *source, - Audio: *withAudio, - StreamIndex: *streamIndex, - Announce: !*noAnnounce, - } + // Base config: loaded from file (or defaults when absent), then overridden + // by any flag the user explicitly set. + cfg := loadCLIConfig(*configPath) + flag.Visit(func(f *flag.Flag) { + switch f.Name { + case "name": + cfg.Name = *name + case "port": + cfg.Port = *port + case "quality": + cfg.Quality = *quality + case "fps": + cfg.FPS = *fps + case "source": + cfg.Source = *source + case "audio": + cfg.Audio = *withAudio + case "no-announce": + cfg.Announce = !*noAnnounce + case "stream-index": + cfg.StreamIndex = *streamIndex + } + }) eng, err := flinger.New(cfg) if err != nil { @@ -85,3 +105,19 @@ func main() { eng.Stop() log.Printf("teleportfling: stopped") } + +// loadCLIConfig returns the base flinger config. With a --config path it reads +// that file; otherwise it reads the default user config. Missing files fall +// back to defaults. +func loadCLIConfig(path string) flinger.Config { + p := path + if p == "" { + p = config.Path() + } + c, err := config.LoadFrom(p) + if err != nil { + log.Printf("teleportfling: config %s: %v (using defaults)", p, err) + return flinger.DefaultConfig() + } + return c.ToFlinger() +} diff --git a/internal/config/config.go b/internal/config/config.go index 978e9d7..ead9682 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,15 +71,20 @@ var pathVar = func() string { return filepath.Join(dir, "teleportfling", "config.json") }() -// Path returns the config file location. +// Path returns the default config file location. func Path() string { return pathVar } -// Load reads the config file, returning Default when it does not exist. +// Load reads the default config file, returning Default when it does not exist. func Load() (Config, error) { - p := Path() - data, err := os.ReadFile(p) + return LoadFrom(Path()) +} + +// LoadFrom reads the config file at path, returning Default when it does not +// exist. This lets the CLI and GUI support custom --config paths. +func LoadFrom(path string) (Config, error) { + data, err := os.ReadFile(path) if err != nil { if errors.Is(err, os.ErrNotExist) { return Default(), nil @@ -113,15 +118,19 @@ func Load() (Config, error) { return c, nil } -// Save writes the config file, creating the directory if needed. +// Save writes the default config file, creating the directory if needed. func Save(c Config) error { - p := Path() - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return SaveTo(Path(), c) +} + +// SaveTo writes the config file at path, creating the directory if needed. +func SaveTo(path string, c Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { return err } data, err := json.MarshalIndent(c, "", " ") if err != nil { return err } - return os.WriteFile(p, data, 0o600) + return os.WriteFile(path, data, 0o600) } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 5fc65e5..cf85780 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -92,3 +92,33 @@ func TestToFlingerAnnounceDefault(t *testing.T) { t.Error("ToFlinger announce default should be true") } } + +// TestLoadFromMissing returns defaults for a non-existent custom path. +func TestLoadFromMissing(t *testing.T) { + c, err := LoadFrom(filepath.Join(t.TempDir(), "missing", "config.json")) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if c.Port != 9756 { + t.Errorf("default port = %d, want 9756", c.Port) + } +} + +// TestSaveToLoadFromRoundTrip uses an explicit path independent of pathVar. +func TestSaveToLoadFromRoundTrip(t *testing.T) { + p := filepath.Join(t.TempDir(), "custom", "daemon.json") + + announce := false + want := Config{Name: "Daemon", Port: 9901, Quality: 90, FPS: 30, Source: "screen", Audio: true, Announce: &announce} + if err := SaveTo(p, want); err != nil { + t.Fatalf("SaveTo: %v", err) + } + + got, err := LoadFrom(p) + if err != nil { + t.Fatalf("LoadFrom: %v", err) + } + if got.Name != want.Name || got.Port != want.Port || *got.Announce != false { + t.Errorf("round trip mismatch: got %+v want %+v", got, want) + } +} diff --git a/internal/gui/gui.go b/internal/gui/gui.go index 77477ec..bf72301 100644 --- a/internal/gui/gui.go +++ b/internal/gui/gui.go @@ -43,6 +43,9 @@ type App struct { win fyne.Window desk desktop.App + // configPath overrides the default config location ("" = default). + configPath string + cfg config.Config eng *flinger.Engine lock bool // serialises start/stop against UI actions @@ -63,9 +66,10 @@ type App struct { statsDone chan struct{} } -// Run starts the GUI and blocks until the app exits. -func Run() { - g := &App{} +// Run starts the GUI and blocks until the app exits. configPath selects a +// non-default settings file ("" uses the default location). +func Run(configPath string) { + g := &App{configPath: configPath} g.fyneApp = app.NewWithID(appID) g.win = g.fyneApp.NewWindow("TeleportFling") @@ -74,7 +78,7 @@ func Run() { g.iconActive = newTrayResource(color.NRGBA{R: 46, G: 125, B: 50, A: 255}, color.NRGBA{R: 150, G: 220, B: 140, A: 255}) // Load persisted settings (falling back to defaults). - g.cfg = mustLoadConfig() + g.cfg = g.mustLoadConfig() g.buildUI() @@ -89,9 +93,18 @@ func Run() { g.win.ShowAndRun() } -// mustLoadConfig loads the config, logging and falling back to defaults. -func mustLoadConfig() config.Config { - c, err := config.Load() +// mustLoadConfig loads the config (from the configured path, or the default), +// logging and falling back to defaults on error. +func (g *App) mustLoadConfig() config.Config { + var ( + c config.Config + err error + ) + if g.configPath != "" { + c, err = config.LoadFrom(g.configPath) + } else { + c, err = config.Load() + } if err != nil { log.Printf("gui: config load: %v (using defaults)", err) return config.Default() @@ -201,7 +214,11 @@ func (g *App) start() { StreamIndex: g.cfg.StreamIndex, Announce: &announce, } - if err := config.Save(g.cfg); err != nil { + if g.configPath != "" { + if err := config.SaveTo(g.configPath, g.cfg); err != nil { + log.Printf("gui: config save: %v", err) + } + } else if err := config.Save(g.cfg); err != nil { log.Printf("gui: config save: %v", err) }