// Package config 管理 frpc-client 的运行配置与文件资源。 // // 它负责三件事: // // 1. frpc.ini 的存在性检查与导入交互 // 2. frpc.exe 的释放(优先使用 EXE 同目录的本地版,其次用内嵌版) // 3. 各种路径(日志目录、配置目录)的一致性 package config import ( "embed" "errors" "fmt" "io" "io/fs" "os" "path/filepath" "strings" "frpc-client-win/internal/logger" ) // Config 持有运行期所需的全部路径与资源。 type Config struct { ExeDir string // 程序自身所在目录 FrpcPath string // 释放后的 frpc.exe 完整路径 IniPath string // 主配置(frpc.ini)完整路径 - 优先 TomlPath string // 备选配置(frpc.toml)完整路径 LogDir string // 日志目录 Log *logger.Log FS embed.FS // 内嵌资源 FrpcName string // 内嵌的 frpc 二进制文件名 } // New 构造一个 Config。 func New(exeDir, frpcName string, frpcFS embed.FS, log *logger.Log) *Config { c := &Config{ ExeDir: exeDir, FrpcPath: filepath.Join(exeDir, frpcName), IniPath: filepath.Join(exeDir, "frpc.ini"), TomlPath: filepath.Join(exeDir, "frpc.toml"), LogDir: filepath.Join(exeDir, "logs"), Log: log, FS: frpcFS, FrpcName: frpcName, } return c } // EnsureFrpcBinary 确保 frpc.exe 在本地可用。 // // 优先级: // // 本地已存在 -> 直接使用 // 内嵌版本 -> 释放到本地 // // 当内嵌版本也缺失(开发期没打包),返回 ErrFrpcMissing, // 由调用方决定是否尝试网络下载。 func (c *Config) EnsureFrpcBinary() error { if _, err := os.Stat(c.FrpcPath); err == nil { c.Log.Info("using local frpc binary at %s", c.FrpcPath) return nil } data, err := fs.ReadFile(c.FS, "assets/"+c.FrpcName) if err != nil { if errors.Is(err, fs.ErrNotExist) { return ErrFrpcMissing } return fmt.Errorf("read embedded frpc: %w", err) } if err := os.WriteFile(c.FrpcPath, data, 0o755); err != nil { return fmt.Errorf("extract frpc to %s: %w", c.FrpcPath, err) } c.Log.Info("extracted embedded frpc (%d bytes) to %s", len(data), c.FrpcPath) return nil } // ErrFrpcMissing 表示内嵌和本地都没有 frpc.exe。 var ErrFrpcMissing = errors.New("frpc binary not found: neither local nor embedded") // EnsureIni 检查 frpc 配置文件是否存在,并选出实际使用的路径。 // // 优先级: // // 1. frpc.ini (固定名) // 2. frpc.toml (固定名) // 3. 同目录任意唯一的 *.ini / *.toml 文件(适用于客户用 frpc-mike.ini 这类自定义名) // // 注意:不是把客户的 frpc-mike.ini 改名复制,而是直接把绝对路径传给 frpc -c, // 这样客户原始文件名保持不变(便于版本管理/多客户多份配置共存)。 func (c *Config) EnsureIni() error { if _, _, err := c.findConfig(); err != nil { return err } return nil } // findConfig 实现三段式查找,返回(选中的文件路径, 来源标签, error)。 // // 来源标签: // // "frpc.ini" -> 固定名 frpc.ini // "frpc.toml" -> 固定名 frpc.toml // "scan" -> 同目录扫描到的唯一 .ini/.toml // "" -> 未找到 func (c *Config) findConfig() (string, string, error) { // 1. frpc.ini if _, err := os.Stat(c.IniPath); err == nil { return c.IniPath, "frpc.ini", nil } // 2. frpc.toml if _, err := os.Stat(c.TomlPath); err == nil { return c.TomlPath, "frpc.toml", nil } // 3. 同目录扫描 candidates, err := c.scanConfigs() if err != nil { return "", "", fmt.Errorf("scan configs: %w", err) } switch len(candidates) { case 0: return "", "", ErrIniMissing case 1: return candidates[0], "scan", nil default: // 多个 - 让用户选,而不是猜 return "", "", fmt.Errorf("%w: found multiple candidates in %s: %s", ErrIniAmbiguous, c.ExeDir, strings.Join(candidates, ", ")) } } // scanConfigs 扫描 ExeDir 下所有 .ini/.toml 文件,返回绝对路径列表。 func (c *Config) scanConfigs() ([]string, error) { entries, err := os.ReadDir(c.ExeDir) if err != nil { return nil, err } var found []string for _, e := range entries { if e.IsDir() { continue } ext := strings.ToLower(filepath.Ext(e.Name())) if ext == ".ini" || ext == ".toml" { found = append(found, filepath.Join(c.ExeDir, e.Name())) } } return found, nil } // EffectiveConfigPath 返回实际可用的配置文件路径。 // 由 Start 在 EnsureIni 之后调用。 func (c *Config) EffectiveConfigPath() string { p, _, _ := c.findConfig() return p } // ConfigSource 返回当前配置的来源标签(用于日志/排错)。 func (c *Config) ConfigSource() string { _, src, _ := c.findConfig() return src } // ErrIniMissing 表示 frpc 配置不存在。 var ErrIniMissing = errors.New("frpc config not found (expected frpc.ini / frpc.toml / or any *.ini *.toml in the same directory as frpc-client.exe)") // ErrIniAmbiguous 表示找到多个配置文件且无法判定使用哪个。 var ErrIniAmbiguous = errors.New("ambiguous frpc config") // ImportInteractive 交互式导入 frpc 配置。 // // 服务模式下不应该调用,所以这里只支持控制台提示; // 用户拖入一个 .ini / .toml / .yaml 文件,我们复制到 EXE 同目录。 func ImportInteractive(c *Config) error { fmt.Println("== frpc config import ==") fmt.Printf("当前目录: %s\n", c.ExeDir) fmt.Printf("目标文件: %s (或 frpc.toml)\n", c.IniPath) if _, err := os.Stat(c.IniPath); err == nil { fmt.Println("frpc.ini 已存在,无需导入") return nil } if _, err := os.Stat(c.TomlPath); err == nil { fmt.Println("frpc.toml 已存在,无需导入") return nil } fmt.Print("请拖入 frpc.ini / frpc.toml 文件(或粘贴完整路径),回车确认:\n> ") var src string if _, err := fmt.Scanln(&src); err != nil { return fmt.Errorf("read input: %w", err) } src = filepath.Clean(stripQuotes(src)) // 根据扩展名决定目标文件 dst := c.IniPath switch strings.ToLower(filepath.Ext(src)) { case ".toml": dst = c.TomlPath case ".yaml", ".yml": // frpc 不直接支持 yaml 配置文件 - 提示用户转换 return fmt.Errorf("frpc 不直接支持 yaml 配置;请改用 .ini 或 .toml 格式") } return copyFile(src, dst) } // stripQuotes 去掉用户拖入文件路径时常见的包裹引号。 func stripQuotes(s string) string { if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' { return s[1 : len(s)-1] } return s } func copyFile(src, dst string) error { in, err := os.Open(src) if err != nil { return fmt.Errorf("open source: %w", err) } defer in.Close() out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) if err != nil { return fmt.Errorf("create dest: %w", err) } defer out.Close() if _, err := io.Copy(out, in); err != nil { return fmt.Errorf("copy: %w", err) } return nil }