// Copyright 2024, Command Line Inc. // SPDX-License-Identifier: Apache-2.0 package wconfig import ( "bytes" "encoding/json" "fmt" "os" "path/filepath" "reflect" "sort" "strings" "github.com/wavetermdev/waveterm/pkg/util/utilfn" "github.com/wavetermdev/waveterm/pkg/waveobj" "github.com/wavetermdev/waveterm/pkg/wconfig/defaultconfig" ) const SettingsFile = "settings.json" type MetaSettingsType struct { waveobj.MetaMapType } func (m *MetaSettingsType) UnmarshalJSON(data []byte) error { var metaMap waveobj.MetaMapType decoder := json.NewDecoder(bytes.NewReader(data)) decoder.UseNumber() if err := decoder.Decode(&metaMap); err != nil { return err } *m = MetaSettingsType{MetaMapType: metaMap} return nil } func (m MetaSettingsType) MarshalJSON() ([]byte, error) { return json.Marshal(m.MetaMapType) } type SettingsType struct { AiClear bool `json:"ai:*,omitempty"` AiBaseURL string `json:"ai:baseurl,omitempty"` AiApiToken string `json:"ai:apitoken,omitempty"` AiName string `json:"ai:name,omitempty"` AiModel string `json:"ai:model,omitempty"` AiMaxTokens float64 `json:"ai:maxtokens,omitempty"` AiTimeoutMs float64 `json:"ai:timeoutms,omitempty"` TermClear bool `json:"term:*,omitempty"` TermFontSize float64 `json:"term:fontsize,omitempty"` TermFontFamily string `json:"term:fontfamily,omitempty"` TermDisableWebGl bool `json:"term:disablewebgl,omitempty"` TermLocalShellPath string `json:"term:localshellpath,omitempty"` TermLocalShellOpts []string `json:"term:localshellopts,omitempty"` EditorMinimapEnabled bool `json:"editor:minimapenabled,omitempty"` EditorStickyScrollEnabled bool `json:"editor:stickyscrollenabled,omitempty"` WebClear bool `json:"web:*,omitempty"` WebOpenLinksInternally bool `json:"web:openlinksinternally,omitempty"` WebDefaultUrl string `json:"web:defaulturl,omitempty"` WebDefaultSearch string `json:"web:defaultsearch,omitempty"` BlockHeaderClear bool `json:"blockheader:*,omitempty"` BlockHeaderShowBlockIds bool `json:"blockheader:showblockids,omitempty"` AutoUpdateClear bool `json:"autoupdate:*,omitempty"` AutoUpdateEnabled bool `json:"autoupdate:enabled,omitempty"` AutoUpdateIntervalMs float64 `json:"autoupdate:intervalms,omitempty"` AutoUpdateInstallOnQuit bool `json:"autoupdate:installonquit,omitempty"` AutoUpdateChannel string `json:"autoupdate:channel,omitempty"` WidgetClear bool `json:"widget:*,omitempty"` WidgetShowHelp bool `json:"widget:showhelp,omitempty"` WindowClear bool `json:"window:*,omitempty"` WindowTransparent bool `json:"window:transparent,omitempty"` WindowBlur bool `json:"window:blur,omitempty"` WindowOpacity *float64 `json:"window:opacity,omitempty"` WindowBgColor string `json:"window:bgcolor,omitempty"` WindowReducedMotion bool `json:"window:reducedmotion,omitempty"` WindowTileGapSize *int64 `json:"window:tilegapsize,omitempty"` WindowNativeTitleBar bool `json:"window:nativetitlebar,omitempty"` WindowDisableHardwareAcceleration bool `json:"window:disablehardwareacceleration,omitempty"` TelemetryClear bool `json:"telemetry:*,omitempty"` TelemetryEnabled bool `json:"telemetry:enabled,omitempty"` } type ConfigError struct { File string `json:"file"` Err string `json:"err"` } type FullConfigType struct { Settings SettingsType `json:"settings" merge:"meta"` MimeTypes map[string]MimeTypeConfigType `json:"mimetypes"` DefaultWidgets map[string]WidgetConfigType `json:"defaultwidgets"` Widgets map[string]WidgetConfigType `json:"widgets"` Presets map[string]waveobj.MetaMapType `json:"presets"` TermThemes map[string]TermThemeType `json:"termthemes"` ConfigErrors []ConfigError `json:"configerrors" configfile:"-"` } func readConfigHelper(fileName string, barr []byte, readErr error) (waveobj.MetaMapType, []ConfigError) { var cerrs []ConfigError if readErr != nil && !os.IsNotExist(readErr) { cerrs = append(cerrs, ConfigError{File: "defaults:" + fileName, Err: readErr.Error()}) } if len(barr) == 0 { return nil, cerrs } var rtn waveobj.MetaMapType err := json.Unmarshal(barr, &rtn) if err != nil { cerrs = append(cerrs, ConfigError{File: "defaults:" + fileName, Err: err.Error()}) } return rtn, cerrs } func ReadDefaultsConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { barr, readErr := defaultconfig.ConfigFS.ReadFile(fileName) return readConfigHelper("defaults:"+fileName, barr, readErr) } func ReadWaveHomeConfigFile(fileName string) (waveobj.MetaMapType, []ConfigError) { fullFileName := filepath.Join(configDirAbsPath, fileName) barr, err := os.ReadFile(fullFileName) return readConfigHelper(fullFileName, barr, err) } func WriteWaveHomeConfigFile(fileName string, m waveobj.MetaMapType) error { fullFileName := filepath.Join(configDirAbsPath, fileName) barr, err := jsonMarshalConfigInOrder(m) if err != nil { return err } return os.WriteFile(fullFileName, barr, 0644) } // simple merge that overwrites func mergeMetaMapSimple(m waveobj.MetaMapType, toMerge waveobj.MetaMapType) waveobj.MetaMapType { if m == nil { return toMerge } if toMerge == nil { return m } for k, v := range toMerge { if v == nil { delete(m, k) continue } m[k] = v } if len(m) == 0 { return nil } return m } func ReadConfigPart(partName string, simpleMerge bool) (waveobj.MetaMapType, []ConfigError) { defConfig, cerrs1 := ReadDefaultsConfigFile(partName) userConfig, cerrs2 := ReadWaveHomeConfigFile(partName) allErrs := append(cerrs1, cerrs2...) if simpleMerge { return mergeMetaMapSimple(defConfig, userConfig), allErrs } else { return waveobj.MergeMeta(defConfig, userConfig, true), allErrs } } func ReadFullConfig() FullConfigType { var fullConfig FullConfigType configRType := reflect.TypeOf(fullConfig) configRVal := reflect.ValueOf(&fullConfig).Elem() for fieldIdx := 0; fieldIdx < configRType.NumField(); fieldIdx++ { field := configRType.Field(fieldIdx) if field.PkgPath != "" { continue } configFile := field.Tag.Get("configfile") if configFile == "-" { continue } jsonTag := utilfn.GetJsonTag(field) if jsonTag == "-" || jsonTag == "" { continue } simpleMerge := field.Tag.Get("merge") == "" fileName := jsonTag + ".json" configPart, cerrs := ReadConfigPart(fileName, simpleMerge) fullConfig.ConfigErrors = append(fullConfig.ConfigErrors, cerrs...) if configPart != nil { fieldPtr := configRVal.Field(fieldIdx).Addr().Interface() utilfn.ReUnmarshal(fieldPtr, configPart) } } return fullConfig } func getConfigKeyType(configKey string) reflect.Type { ctype := reflect.TypeOf(SettingsType{}) for i := 0; i < ctype.NumField(); i++ { field := ctype.Field(i) jsonTag := utilfn.GetJsonTag(field) if jsonTag == configKey { return field.Type } } return nil } func getConfigKeyNamespace(key string) string { colonIdx := strings.Index(key, ":") if colonIdx == -1 { return "" } return key[:colonIdx] } func orderConfigKeys(m waveobj.MetaMapType) []string { keys := make([]string, 0, len(m)) for k := range m { keys = append(keys, k) } sort.Slice(keys, func(i, j int) bool { k1 := keys[i] k2 := keys[j] k1ns := getConfigKeyNamespace(k1) k2ns := getConfigKeyNamespace(k2) if k1ns != k2ns { return k1ns < k2ns } return k1 < k2 }) return keys } func reindentJson(barr []byte, indentStr string) []byte { if len(barr) < 2 { return barr } if barr[0] != '{' && barr[0] != '[' { return barr } if bytes.Contains(barr, []byte("\n")) { return barr } outputLines := bytes.Split(barr, []byte("\n")) for i, line := range outputLines { if i == 0 || i == len(outputLines)-1 { continue } outputLines[i] = append([]byte(indentStr), line...) } return bytes.Join(outputLines, []byte("\n")) } func jsonMarshalConfigInOrder(m waveobj.MetaMapType) ([]byte, error) { if len(m) == 0 { return []byte("{}"), nil } var buf bytes.Buffer orderedKeys := orderConfigKeys(m) buf.WriteString("{\n") for idx, key := range orderedKeys { val := m[key] keyBarr, err := json.Marshal(key) if err != nil { return nil, err } valBarr, err := json.MarshalIndent(val, "", " ") if err != nil { return nil, err } valBarr = reindentJson(valBarr, " ") buf.WriteString(" ") buf.Write(keyBarr) buf.WriteString(": ") buf.Write(valBarr) if idx < len(orderedKeys)-1 { buf.WriteString(",") } buf.WriteString("\n") } buf.WriteString("}") return buf.Bytes(), nil } var dummyNumber json.Number func convertJsonNumber(num json.Number, ctype reflect.Type) (interface{}, error) { // ctype might be int64, float64, string, *int64, *float64, *string // switch on ctype first if ctype.Kind() == reflect.Pointer { ctype = ctype.Elem() } if reflect.Int64 == ctype.Kind() { if ival, err := num.Int64(); err == nil { return ival, nil } return nil, fmt.Errorf("invalid number for int64: %s", num) } if reflect.Float64 == ctype.Kind() { if fval, err := num.Float64(); err == nil { return fval, nil } return nil, fmt.Errorf("invalid number for float64: %s", num) } if reflect.String == ctype.Kind() { return num.String(), nil } return nil, fmt.Errorf("cannot convert number to %s", ctype) } func SetBaseConfigValue(toMerge waveobj.MetaMapType) error { m, cerrs := ReadWaveHomeConfigFile(SettingsFile) if len(cerrs) > 0 { return fmt.Errorf("error reading config file: %v", cerrs[0]) } if m == nil { m = make(waveobj.MetaMapType) } for configKey, val := range toMerge { ctype := getConfigKeyType(configKey) if ctype == nil { return fmt.Errorf("invalid config key: %s", configKey) } if val == nil { delete(m, configKey) } else { rtype := reflect.TypeOf(val) if rtype == reflect.TypeOf(dummyNumber) { convertedVal, err := convertJsonNumber(val.(json.Number), ctype) if err != nil { return fmt.Errorf("cannot convert %s: %v", configKey, err) } val = convertedVal rtype = reflect.TypeOf(val) } if rtype != ctype { if ctype == reflect.PointerTo(rtype) { m[configKey] = &val } else { return fmt.Errorf("invalid value type for %s: %T", configKey, val) } } m[configKey] = val } } return WriteWaveHomeConfigFile(SettingsFile, m) } type WidgetConfigType struct { DisplayOrder float64 `json:"display:order,omitempty"` Icon string `json:"icon,omitempty"` Color string `json:"color,omitempty"` Label string `json:"label,omitempty"` Description string `json:"description,omitempty"` BlockDef waveobj.BlockDef `json:"blockdef"` } type MimeTypeConfigType struct { Icon string `json:"icon"` Color string `json:"color"` } type TermThemeType struct { DisplayName string `json:"display:name"` DisplayOrder float64 `json:"display:order"` Black string `json:"black"` Red string `json:"red"` Green string `json:"green"` Yellow string `json:"yellow"` Blue string `json:"blue"` Magenta string `json:"magenta"` Cyan string `json:"cyan"` White string `json:"white"` BrightBlack string `json:"brightBlack"` BrightRed string `json:"brightRed"` BrightGreen string `json:"brightGreen"` BrightYellow string `json:"brightYellow"` BrightBlue string `json:"brightBlue"` BrightMagenta string `json:"brightMagenta"` BrightCyan string `json:"brightCyan"` BrightWhite string `json:"brightWhite"` Gray string `json:"gray"` CmdText string `json:"cmdtext"` Foreground string `json:"foreground"` SelectionBackground string `json:"selectionBackground"` Background string `json:"background"` CursorAccent string `json:"cursorAccent"` }