| package config |
|
|
| import ( |
| "encoding/json" |
| "fmt" |
| "os" |
| "path/filepath" |
| "strconv" |
| "strings" |
| ) |
|
|
| |
| |
| func SetConfigValue(fc *FileConfig, path string, value string) error { |
| parts := strings.SplitN(path, ".", 2) |
| if len(parts) != 2 { |
| return fmt.Errorf("invalid path %q (expected section.field, e.g., server.port)", path) |
| } |
|
|
| section, field := parts[0], parts[1] |
|
|
| switch section { |
| case "server": |
| return setServerField(&fc.Server, field, value) |
| case "browser": |
| return setBrowserField(&fc.Browser, field, value) |
| case "instanceDefaults": |
| return setInstanceDefaultsField(&fc.InstanceDefaults, field, value) |
| case "security": |
| return setSecurityField(&fc.Security, field, value) |
| case "profiles": |
| return setProfilesField(&fc.Profiles, field, value) |
| case "multiInstance": |
| return setMultiInstanceField(&fc.MultiInstance, field, value) |
| case "timeouts": |
| return setTimeoutsField(&fc.Timeouts, field, value) |
| default: |
| return fmt.Errorf("unknown section %q (valid: server, browser, instanceDefaults, security, profiles, multiInstance, timeouts)", section) |
| } |
| } |
|
|
| func setServerField(s *ServerConfig, field, value string) error { |
| switch field { |
| case "port": |
| s.Port = value |
| case "bind": |
| s.Bind = value |
| case "token": |
| s.Token = value |
| case "stateDir": |
| s.StateDir = value |
| default: |
| return fmt.Errorf("unknown field server.%s", field) |
| } |
| return nil |
| } |
|
|
| func setBrowserField(b *BrowserConfig, field, value string) error { |
| switch field { |
| case "version": |
| b.ChromeVersion = value |
| case "binary": |
| b.ChromeBinary = value |
| case "extraFlags": |
| b.ChromeExtraFlags = value |
| default: |
| return fmt.Errorf("unknown field browser.%s", field) |
| } |
| return nil |
| } |
|
|
| func setInstanceDefaultsField(c *InstanceDefaultsConfig, field, value string) error { |
| switch field { |
| case "mode": |
| c.Mode = value |
| case "noRestore": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.noRestore: %w", err) |
| } |
| c.NoRestore = &b |
| case "timezone": |
| c.Timezone = value |
| case "blockImages": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.blockImages: %w", err) |
| } |
| c.BlockImages = &b |
| case "blockMedia": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.blockMedia: %w", err) |
| } |
| c.BlockMedia = &b |
| case "blockAds": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.blockAds: %w", err) |
| } |
| c.BlockAds = &b |
| case "maxTabs": |
| n, err := strconv.Atoi(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.maxTabs must be a number: %w", err) |
| } |
| c.MaxTabs = &n |
| case "maxParallelTabs": |
| n, err := strconv.Atoi(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.maxParallelTabs must be a number: %w", err) |
| } |
| c.MaxParallelTabs = &n |
| case "userAgent": |
| c.UserAgent = value |
| case "noAnimations": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("instanceDefaults.noAnimations: %w", err) |
| } |
| c.NoAnimations = &b |
| case "stealthLevel": |
| c.StealthLevel = value |
| case "tabEvictionPolicy": |
| c.TabEvictionPolicy = value |
| default: |
| return fmt.Errorf("unknown field instanceDefaults.%s", field) |
| } |
| return nil |
| } |
|
|
| func setSecurityField(s *SecurityConfig, field, value string) error { |
| if strings.HasPrefix(field, "attach.") { |
| return setAttachField(&s.Attach, strings.TrimPrefix(field, "attach."), value) |
| } |
| if strings.HasPrefix(field, "idpi.") { |
| return setIDPIField(&s.IDPI, strings.TrimPrefix(field, "idpi."), value) |
| } |
|
|
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("security.%s: %w", field, err) |
| } |
|
|
| switch field { |
| case "allowEvaluate": |
| s.AllowEvaluate = &b |
| case "allowMacro": |
| s.AllowMacro = &b |
| case "allowScreencast": |
| s.AllowScreencast = &b |
| case "allowDownload": |
| s.AllowDownload = &b |
| case "allowUpload": |
| s.AllowUpload = &b |
| default: |
| return fmt.Errorf("unknown field security.%s", field) |
| } |
| return nil |
| } |
|
|
| func setProfilesField(p *ProfilesConfig, field, value string) error { |
| switch field { |
| case "baseDir": |
| p.BaseDir = value |
| case "defaultProfile": |
| p.DefaultProfile = value |
| default: |
| return fmt.Errorf("unknown field profiles.%s", field) |
| } |
| return nil |
| } |
|
|
| func setMultiInstanceField(o *MultiInstanceConfig, field, value string) error { |
| if strings.HasPrefix(field, "restart.") { |
| return setMultiInstanceRestartField(&o.Restart, strings.TrimPrefix(field, "restart."), value) |
| } |
|
|
| switch field { |
| case "strategy": |
| o.Strategy = value |
| case "allocationPolicy": |
| o.AllocationPolicy = value |
| case "instancePortStart": |
| n, err := strconv.Atoi(value) |
| if err != nil { |
| return fmt.Errorf("multiInstance.instancePortStart must be a number: %w", err) |
| } |
| o.InstancePortStart = &n |
| case "instancePortEnd": |
| n, err := strconv.Atoi(value) |
| if err != nil { |
| return fmt.Errorf("multiInstance.instancePortEnd must be a number: %w", err) |
| } |
| o.InstancePortEnd = &n |
| default: |
| return fmt.Errorf("unknown field multiInstance.%s", field) |
| } |
| return nil |
| } |
|
|
| func setMultiInstanceRestartField(r *MultiInstanceRestartConfig, field, value string) error { |
| n, err := strconv.Atoi(value) |
| if err != nil { |
| return fmt.Errorf("multiInstance.restart.%s must be a number: %w", field, err) |
| } |
|
|
| switch field { |
| case "maxRestarts": |
| r.MaxRestarts = &n |
| case "initBackoffSec": |
| r.InitBackoffSec = &n |
| case "maxBackoffSec": |
| r.MaxBackoffSec = &n |
| case "stableAfterSec": |
| r.StableAfterSec = &n |
| default: |
| return fmt.Errorf("unknown field multiInstance.restart.%s", field) |
| } |
| return nil |
| } |
|
|
| func setTimeoutsField(t *TimeoutsConfig, field, value string) error { |
| n, err := strconv.Atoi(value) |
| if err != nil { |
| return fmt.Errorf("timeouts.%s must be a number: %w", field, err) |
| } |
|
|
| switch field { |
| case "actionSec": |
| t.ActionSec = n |
| case "navigateSec": |
| t.NavigateSec = n |
| case "shutdownSec": |
| t.ShutdownSec = n |
| case "waitNavMs": |
| t.WaitNavMs = n |
| default: |
| return fmt.Errorf("unknown field timeouts.%s", field) |
| } |
| return nil |
| } |
|
|
| func setAttachField(a *AttachConfig, field, value string) error { |
| switch field { |
| case "enabled": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("security.attach.enabled: %w", err) |
| } |
| a.Enabled = &b |
| case "allowHosts": |
| a.AllowHosts = parseCSVList(value) |
| case "allowSchemes": |
| a.AllowSchemes = parseCSVList(value) |
| default: |
| return fmt.Errorf("unknown field security.attach.%s", field) |
| } |
| return nil |
| } |
|
|
| |
| |
| |
| |
| func GetConfigValue(fc *FileConfig, path string) (string, error) { |
| parts := strings.SplitN(path, ".", 2) |
| if len(parts) != 2 { |
| return "", fmt.Errorf("invalid path %q (expected section.field, e.g., server.port)", path) |
| } |
|
|
| section, field := parts[0], parts[1] |
|
|
| switch section { |
| case "server": |
| return getServerField(&fc.Server, field) |
| case "browser": |
| return getBrowserField(&fc.Browser, field) |
| case "instanceDefaults": |
| return getInstanceDefaultsField(&fc.InstanceDefaults, field) |
| case "security": |
| return getSecurityField(&fc.Security, field) |
| case "profiles": |
| return getProfilesField(&fc.Profiles, field) |
| case "multiInstance": |
| return getMultiInstanceField(&fc.MultiInstance, field) |
| case "timeouts": |
| return getTimeoutsField(&fc.Timeouts, field) |
| default: |
| return "", fmt.Errorf("unknown section %q (valid: server, browser, instanceDefaults, security, profiles, multiInstance, timeouts)", section) |
| } |
| } |
|
|
| func getServerField(s *ServerConfig, field string) (string, error) { |
| switch field { |
| case "port": |
| return s.Port, nil |
| case "bind": |
| return s.Bind, nil |
| case "token": |
| return s.Token, nil |
| case "stateDir": |
| return s.StateDir, nil |
| default: |
| return "", fmt.Errorf("unknown field server.%s", field) |
| } |
| } |
|
|
| func getBrowserField(b *BrowserConfig, field string) (string, error) { |
| switch field { |
| case "version": |
| return b.ChromeVersion, nil |
| case "binary": |
| return b.ChromeBinary, nil |
| case "extraFlags": |
| return b.ChromeExtraFlags, nil |
| default: |
| return "", fmt.Errorf("unknown field browser.%s", field) |
| } |
| } |
|
|
| func getInstanceDefaultsField(c *InstanceDefaultsConfig, field string) (string, error) { |
| switch field { |
| case "mode": |
| return c.Mode, nil |
| case "noRestore": |
| return formatBoolPtr(c.NoRestore), nil |
| case "timezone": |
| return c.Timezone, nil |
| case "blockImages": |
| return formatBoolPtr(c.BlockImages), nil |
| case "blockMedia": |
| return formatBoolPtr(c.BlockMedia), nil |
| case "blockAds": |
| return formatBoolPtr(c.BlockAds), nil |
| case "maxTabs": |
| return formatIntPtr(c.MaxTabs), nil |
| case "maxParallelTabs": |
| return formatIntPtr(c.MaxParallelTabs), nil |
| case "userAgent": |
| return c.UserAgent, nil |
| case "noAnimations": |
| return formatBoolPtr(c.NoAnimations), nil |
| case "stealthLevel": |
| return c.StealthLevel, nil |
| case "tabEvictionPolicy": |
| return c.TabEvictionPolicy, nil |
| default: |
| return "", fmt.Errorf("unknown field instanceDefaults.%s", field) |
| } |
| } |
|
|
| func getSecurityField(s *SecurityConfig, field string) (string, error) { |
| if strings.HasPrefix(field, "attach.") { |
| return getAttachField(&s.Attach, strings.TrimPrefix(field, "attach.")) |
| } |
| if strings.HasPrefix(field, "idpi.") { |
| return getIDPIField(&s.IDPI, strings.TrimPrefix(field, "idpi.")) |
| } |
|
|
| switch field { |
| case "allowEvaluate": |
| return formatBoolPtr(s.AllowEvaluate), nil |
| case "allowMacro": |
| return formatBoolPtr(s.AllowMacro), nil |
| case "allowScreencast": |
| return formatBoolPtr(s.AllowScreencast), nil |
| case "allowDownload": |
| return formatBoolPtr(s.AllowDownload), nil |
| case "allowUpload": |
| return formatBoolPtr(s.AllowUpload), nil |
| default: |
| return "", fmt.Errorf("unknown field security.%s", field) |
| } |
| } |
|
|
| func getProfilesField(p *ProfilesConfig, field string) (string, error) { |
| switch field { |
| case "baseDir": |
| return p.BaseDir, nil |
| case "defaultProfile": |
| return p.DefaultProfile, nil |
| default: |
| return "", fmt.Errorf("unknown field profiles.%s", field) |
| } |
| } |
|
|
| func getMultiInstanceField(o *MultiInstanceConfig, field string) (string, error) { |
| if strings.HasPrefix(field, "restart.") { |
| return getMultiInstanceRestartField(&o.Restart, strings.TrimPrefix(field, "restart.")) |
| } |
|
|
| switch field { |
| case "strategy": |
| return o.Strategy, nil |
| case "allocationPolicy": |
| return o.AllocationPolicy, nil |
| case "instancePortStart": |
| return formatIntPtr(o.InstancePortStart), nil |
| case "instancePortEnd": |
| return formatIntPtr(o.InstancePortEnd), nil |
| default: |
| return "", fmt.Errorf("unknown field multiInstance.%s", field) |
| } |
| } |
|
|
| func getMultiInstanceRestartField(r *MultiInstanceRestartConfig, field string) (string, error) { |
| switch field { |
| case "maxRestarts": |
| return formatIntPtr(r.MaxRestarts), nil |
| case "initBackoffSec": |
| return formatIntPtr(r.InitBackoffSec), nil |
| case "maxBackoffSec": |
| return formatIntPtr(r.MaxBackoffSec), nil |
| case "stableAfterSec": |
| return formatIntPtr(r.StableAfterSec), nil |
| default: |
| return "", fmt.Errorf("unknown field multiInstance.restart.%s", field) |
| } |
| } |
|
|
| func getAttachField(a *AttachConfig, field string) (string, error) { |
| switch field { |
| case "enabled": |
| return formatBoolPtr(a.Enabled), nil |
| case "allowHosts": |
| return strings.Join(a.AllowHosts, ","), nil |
| case "allowSchemes": |
| return strings.Join(a.AllowSchemes, ","), nil |
| default: |
| return "", fmt.Errorf("unknown field security.attach.%s", field) |
| } |
| } |
|
|
| func setIDPIField(i *IDPIConfig, field, value string) error { |
| switch field { |
| case "enabled": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("security.idpi.enabled: %w", err) |
| } |
| i.Enabled = b |
| case "allowedDomains": |
| i.AllowedDomains = parseCSVList(value) |
| case "strictMode": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("security.idpi.strictMode: %w", err) |
| } |
| i.StrictMode = b |
| case "scanContent": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("security.idpi.scanContent: %w", err) |
| } |
| i.ScanContent = b |
| case "wrapContent": |
| b, err := parseBool(value) |
| if err != nil { |
| return fmt.Errorf("security.idpi.wrapContent: %w", err) |
| } |
| i.WrapContent = b |
| case "customPatterns": |
| i.CustomPatterns = parseCSVList(value) |
| default: |
| return fmt.Errorf("unknown field security.idpi.%s", field) |
| } |
| return nil |
| } |
|
|
| func getIDPIField(i *IDPIConfig, field string) (string, error) { |
| switch field { |
| case "enabled": |
| return strconv.FormatBool(i.Enabled), nil |
| case "allowedDomains": |
| return strings.Join(i.AllowedDomains, ","), nil |
| case "strictMode": |
| return strconv.FormatBool(i.StrictMode), nil |
| case "scanContent": |
| return strconv.FormatBool(i.ScanContent), nil |
| case "wrapContent": |
| return strconv.FormatBool(i.WrapContent), nil |
| case "customPatterns": |
| return strings.Join(i.CustomPatterns, ","), nil |
| default: |
| return "", fmt.Errorf("unknown field security.idpi.%s", field) |
| } |
| } |
|
|
| func getTimeoutsField(t *TimeoutsConfig, field string) (string, error) { |
| switch field { |
| case "actionSec": |
| return strconv.Itoa(t.ActionSec), nil |
| case "navigateSec": |
| return strconv.Itoa(t.NavigateSec), nil |
| case "shutdownSec": |
| return strconv.Itoa(t.ShutdownSec), nil |
| case "waitNavMs": |
| return strconv.Itoa(t.WaitNavMs), nil |
| default: |
| return "", fmt.Errorf("unknown field timeouts.%s", field) |
| } |
| } |
|
|
| func formatBoolPtr(b *bool) string { |
| if b == nil { |
| return "" |
| } |
| if *b { |
| return "true" |
| } |
| return "false" |
| } |
|
|
| func formatIntPtr(n *int) string { |
| if n == nil { |
| return "" |
| } |
| return strconv.Itoa(*n) |
| } |
|
|
| func parseBool(s string) (bool, error) { |
| switch strings.ToLower(strings.TrimSpace(s)) { |
| case "true", "1", "yes", "on": |
| return true, nil |
| case "false", "0", "no", "off": |
| return false, nil |
| default: |
| return false, fmt.Errorf("invalid boolean %q (use true/false)", s) |
| } |
| } |
|
|
| func parseCSVList(s string) []string { |
| if strings.TrimSpace(s) == "" { |
| return nil |
| } |
| raw := strings.Split(s, ",") |
| out := make([]string, 0, len(raw)) |
| for _, item := range raw { |
| item = strings.TrimSpace(item) |
| if item != "" { |
| out = append(out, item) |
| } |
| } |
| return out |
| } |
|
|
| |
| |
| func PatchConfigJSON(fc *FileConfig, jsonPatch string) error { |
| |
| current, err := json.Marshal(fc) |
| if err != nil { |
| return fmt.Errorf("failed to serialize current config: %w", err) |
| } |
|
|
| |
| var currentMap map[string]interface{} |
| if err := json.Unmarshal(current, ¤tMap); err != nil { |
| return fmt.Errorf("failed to parse current config: %w", err) |
| } |
|
|
| |
| var patchMap map[string]interface{} |
| if err := json.Unmarshal([]byte(jsonPatch), &patchMap); err != nil { |
| return fmt.Errorf("invalid JSON patch: %w", err) |
| } |
|
|
| |
| merged := deepMerge(currentMap, patchMap) |
|
|
| |
| mergedJSON, err := json.Marshal(merged) |
| if err != nil { |
| return fmt.Errorf("failed to serialize merged config: %w", err) |
| } |
|
|
| |
| if err := json.Unmarshal(mergedJSON, fc); err != nil { |
| return fmt.Errorf("failed to parse merged config: %w", err) |
| } |
|
|
| return nil |
| } |
|
|
| |
| func deepMerge(dst, src map[string]interface{}) map[string]interface{} { |
| result := make(map[string]interface{}) |
|
|
| |
| for k, v := range dst { |
| result[k] = v |
| } |
|
|
| |
| for k, v := range src { |
| if srcMap, ok := v.(map[string]interface{}); ok { |
| if dstMap, ok := result[k].(map[string]interface{}); ok { |
| result[k] = deepMerge(dstMap, srcMap) |
| continue |
| } |
| } |
| result[k] = v |
| } |
|
|
| return result |
| } |
|
|
| |
| |
| func LoadFileConfig() (*FileConfig, string, error) { |
| configPath := envOr("PINCHTAB_CONFIG", filepath.Join(userConfigDir(), "config.json")) |
|
|
| data, err := os.ReadFile(configPath) |
| if err != nil { |
| if os.IsNotExist(err) { |
| |
| return &FileConfig{}, configPath, nil |
| } |
| return nil, configPath, fmt.Errorf("failed to read config file: %w", err) |
| } |
|
|
| var fc *FileConfig |
|
|
| if isLegacyConfig(data) { |
| var lc legacyFileConfig |
| if err := json.Unmarshal(data, &lc); err != nil { |
| return nil, configPath, fmt.Errorf("failed to parse legacy config: %w", err) |
| } |
| defaults := DefaultFileConfig() |
| fc = &defaults |
| legacy := convertLegacyConfig(&lc) |
| if legacy.Server.Port != "" { |
| fc.Server.Port = legacy.Server.Port |
| } |
| if legacy.Server.Token != "" { |
| fc.Server.Token = legacy.Server.Token |
| } |
| if legacy.Server.StateDir != "" { |
| fc.Server.StateDir = legacy.Server.StateDir |
| } |
| if legacy.InstanceDefaults.Mode != "" { |
| fc.InstanceDefaults.Mode = legacy.InstanceDefaults.Mode |
| } |
| if legacy.InstanceDefaults.NoRestore != nil { |
| fc.InstanceDefaults.NoRestore = legacy.InstanceDefaults.NoRestore |
| } |
| if legacy.InstanceDefaults.MaxTabs != nil { |
| fc.InstanceDefaults.MaxTabs = legacy.InstanceDefaults.MaxTabs |
| } |
| if legacy.Profiles.BaseDir != "" { |
| fc.Profiles.BaseDir = legacy.Profiles.BaseDir |
| } |
| if legacy.Profiles.DefaultProfile != "" { |
| fc.Profiles.DefaultProfile = legacy.Profiles.DefaultProfile |
| } |
| if legacy.Security.AllowEvaluate != nil { |
| fc.Security.AllowEvaluate = legacy.Security.AllowEvaluate |
| } |
| if legacy.Security.AllowMacro != nil { |
| fc.Security.AllowMacro = legacy.Security.AllowMacro |
| } |
| if legacy.Security.AllowScreencast != nil { |
| fc.Security.AllowScreencast = legacy.Security.AllowScreencast |
| } |
| if legacy.Security.AllowDownload != nil { |
| fc.Security.AllowDownload = legacy.Security.AllowDownload |
| } |
| if legacy.Security.AllowUpload != nil { |
| fc.Security.AllowUpload = legacy.Security.AllowUpload |
| } |
| if legacy.Timeouts.ActionSec != 0 { |
| fc.Timeouts.ActionSec = legacy.Timeouts.ActionSec |
| } |
| if legacy.Timeouts.NavigateSec != 0 { |
| fc.Timeouts.NavigateSec = legacy.Timeouts.NavigateSec |
| } |
| if legacy.MultiInstance.InstancePortStart != nil { |
| fc.MultiInstance.InstancePortStart = legacy.MultiInstance.InstancePortStart |
| } |
| if legacy.MultiInstance.InstancePortEnd != nil { |
| fc.MultiInstance.InstancePortEnd = legacy.MultiInstance.InstancePortEnd |
| } |
| } else { |
| defaults := DefaultFileConfig() |
| defaults.ConfigVersion = "" |
| fc = &defaults |
| if err := json.Unmarshal(data, fc); err != nil { |
| return nil, configPath, fmt.Errorf("failed to parse config: %w", err) |
| } |
| } |
|
|
| return fc, configPath, nil |
| } |
|
|
| |
| func SaveFileConfig(fc *FileConfig, path string) error { |
| |
| if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { |
| return fmt.Errorf("failed to create config directory: %w", err) |
| } |
|
|
| data, err := json.MarshalIndent(fc, "", " ") |
| if err != nil { |
| return fmt.Errorf("failed to serialize config: %w", err) |
| } |
|
|
| |
| data = append(data, '\n') |
|
|
| if err := os.WriteFile(path, data, 0644); err != nil { |
| return fmt.Errorf("failed to write config file: %w", err) |
| } |
|
|
| return nil |
| } |
|
|