package config import ( "encoding/json" "errors" "fmt" "os" "path/filepath" "time" ) const configFileName = "config.json" type diskConfig struct { Version int `json:"version"` General struct { StartCaptions bool `json:"start_captions"` MainWindowAlwaysOnTop bool `json:"main_window_always_on_top"` } `json:"general"` Recognition struct { Mode Mode `json:"mode"` ChunkDuration string `json:"chunk_duration"` ModelsDir string `json:"models_directory"` Threads int `json:"threads"` PreviewThresholdDB float64 `json:"preview_threshold_dbfs"` VADThreshold float64 `json:"vad_threshold"` ModelAutoRestart bool `json:"model_auto_restart"` ModelTimeout string `json:"model_timeout"` ModelShutdownTimeout string `json:"model_shutdown_timeout"` } `json:"recognition"` Outputs struct { Overlay bool `json:"overlay"` History bool `json:"history"` StdoutCaptions bool `json:"stdout_captions"` StderrDiagnostics bool `json:"stderr_diagnostics"` } `json:"outputs"` Overlay struct { Backend OverlayBackend `json:"backend"` Opacity float64 `json:"opacity"` FontSize int `json:"font_size"` FontFamily string `json:"font_family"` BottomMargin int `json:"bottom_margin"` MaxWidth int `json:"max_width"` Monitor string `json:"monitor"` FinalTimeout string `json:"final_timeout"` ClickThrough bool `json:"click_through"` } `json:"overlay"` TranscriptLog struct { Enabled bool `json:"enabled"` Directory string `json:"directory"` } `json:"transcript_log"` } func ConfigPath() (string, error) { base, err := os.UserConfigDir() if err != nil { return "", fmt.Errorf("find user configuration directory: %w", err) } return filepath.Join(base, "captioneer", configFileName), nil } func DefaultTranscriptDirectory() string { if stateHome := os.Getenv("XDG_STATE_HOME"); filepath.IsAbs(stateHome) { return filepath.Join(stateHome, "captioneer", "transcripts") } home, err := os.UserHomeDir() if err != nil { return filepath.Join("captioneer", "transcripts") } return filepath.Join(home, ".local", "state", "captioneer", "transcripts") } func Load(path string) (AppConfig, error) { defaults := DefaultAppConfig() data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return defaults, nil } if err != nil { return defaults, fmt.Errorf("read configuration: %w", err) } disk := diskFromConfig(defaults) if err := json.Unmarshal(data, &disk); err != nil { return defaults, fmt.Errorf("parse configuration: %w", err) } loaded, err := disk.toConfig() if err != nil { return defaults, err } if err := loaded.Validate(); err != nil { return defaults, fmt.Errorf("validate configuration: %w", err) } return loaded, nil } func Save(path string, config AppConfig) error { config.Version = CurrentConfigVersion if err := config.Validate(); err != nil { return fmt.Errorf("validate configuration: %w", err) } data, err := json.MarshalIndent(diskFromConfig(config), "", " ") if err != nil { return fmt.Errorf("encode configuration: %w", err) } data = append(data, '\n') directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0o700); err != nil { return fmt.Errorf("create configuration directory: %w", err) } temporary, err := os.CreateTemp(directory, ".config-*.tmp") if err != nil { return fmt.Errorf("create temporary configuration: %w", err) } temporaryPath := temporary.Name() defer os.Remove(temporaryPath) if err := temporary.Chmod(0o600); err != nil { temporary.Close() return fmt.Errorf("secure temporary configuration: %w", err) } if _, err := temporary.Write(data); err != nil { temporary.Close() return fmt.Errorf("write temporary configuration: %w", err) } if err := temporary.Sync(); err != nil { temporary.Close() return fmt.Errorf("sync temporary configuration: %w", err) } if err := temporary.Close(); err != nil { return fmt.Errorf("close temporary configuration: %w", err) } if err := os.Rename(temporaryPath, path); err != nil { return fmt.Errorf("replace configuration: %w", err) } return nil } func diskFromConfig(config AppConfig) diskConfig { var disk diskConfig disk.Version = config.Version disk.General.StartCaptions = config.GUI.StartCaptions disk.General.MainWindowAlwaysOnTop = config.GUI.MainWindowAlwaysOnTop disk.Recognition.Mode = config.Settings.Mode disk.Recognition.ChunkDuration = config.Settings.ChunkDuration.String() disk.Recognition.ModelsDir = config.Settings.ModelsDir disk.Recognition.Threads = config.Settings.Threads disk.Recognition.PreviewThresholdDB = config.Settings.PreviewThresholdDB disk.Recognition.VADThreshold = config.Settings.VADThreshold disk.Recognition.ModelAutoRestart = config.Settings.ModelAutoRestart disk.Recognition.ModelTimeout = config.Settings.ModelTimeout.String() disk.Recognition.ModelShutdownTimeout = config.Settings.ModelShutdownTimeout.String() disk.Outputs.Overlay = config.Outputs.Overlay disk.Outputs.History = config.Outputs.History disk.Outputs.StdoutCaptions = config.Outputs.StdoutCaptions disk.Outputs.StderrDiagnostics = config.Outputs.StderrDiagnostics disk.Overlay.Backend = config.Overlay.Backend disk.Overlay.Opacity = config.Overlay.Opacity disk.Overlay.FontSize = config.Overlay.FontSize disk.Overlay.FontFamily = config.Overlay.FontFamily disk.Overlay.BottomMargin = config.Overlay.BottomMargin disk.Overlay.MaxWidth = config.Overlay.MaxWidth disk.Overlay.Monitor = config.Overlay.Monitor disk.Overlay.FinalTimeout = config.Overlay.FinalTimeout.String() disk.Overlay.ClickThrough = config.Overlay.ClickThrough disk.TranscriptLog.Enabled = config.TranscriptLog.Enabled disk.TranscriptLog.Directory = config.TranscriptLog.Directory return disk } func (disk diskConfig) toConfig() (AppConfig, error) { chunkDuration, err := parseStoredDuration("chunk duration", disk.Recognition.ChunkDuration) if err != nil { return AppConfig{}, err } modelTimeout, err := parseStoredDuration("model timeout", disk.Recognition.ModelTimeout) if err != nil { return AppConfig{}, err } shutdownTimeout, err := parseStoredDuration("model shutdown timeout", disk.Recognition.ModelShutdownTimeout) if err != nil { return AppConfig{}, err } finalTimeout, err := parseStoredDuration("overlay final timeout", disk.Overlay.FinalTimeout) if err != nil { return AppConfig{}, err } return AppConfig{ Version: disk.Version, GUI: GUISettings{ StartCaptions: disk.General.StartCaptions, MainWindowAlwaysOnTop: disk.General.MainWindowAlwaysOnTop, }, Settings: Settings{ Mode: disk.Recognition.Mode, ChunkDuration: chunkDuration, ModelsDir: disk.Recognition.ModelsDir, Threads: disk.Recognition.Threads, PreviewThresholdDB: disk.Recognition.PreviewThresholdDB, VADThreshold: disk.Recognition.VADThreshold, ModelAutoRestart: disk.Recognition.ModelAutoRestart, ModelTimeout: modelTimeout, ModelShutdownTimeout: shutdownTimeout, }, Outputs: OutputSettings{ Overlay: disk.Outputs.Overlay, History: disk.Outputs.History, StdoutCaptions: disk.Outputs.StdoutCaptions, StderrDiagnostics: disk.Outputs.StderrDiagnostics, }, Overlay: OverlaySettings{ Backend: disk.Overlay.Backend, Opacity: disk.Overlay.Opacity, FontSize: disk.Overlay.FontSize, FontFamily: disk.Overlay.FontFamily, BottomMargin: disk.Overlay.BottomMargin, MaxWidth: disk.Overlay.MaxWidth, Monitor: disk.Overlay.Monitor, FinalTimeout: finalTimeout, ClickThrough: disk.Overlay.ClickThrough, }, TranscriptLog: TranscriptLogSettings{ Enabled: disk.TranscriptLog.Enabled, Directory: disk.TranscriptLog.Directory, }, }, nil } func parseStoredDuration(name, value string) (time.Duration, error) { duration, err := time.ParseDuration(value) if err != nil { return 0, fmt.Errorf("parse %s %q: %w", name, value, err) } return duration, nil }