package cdp import ( "errors" "fmt" "io" "os" "path/filepath" "runtime" "strings" ) // 浏览器 profile 的发现、体检与复制。 // // 为什么需要这些:Chrome 的**登录态(cookies)与扩展都存在 user-data-dir 里**。 // 以前 profile 为空就用 os.MkdirTemp 建个空目录,于是每次启动都像一台从没登录过的 // 新机器——而且那个临时目录**从不清理**,退出后在 /tmp 留下几百 MB。 // // 想复用已有登录态,就把 browser.profile 指到一份**已登录的** user-data-dir(通常是 // 系统默认目录的副本)。体检负责把几个常见坑说清楚:指到了 Default 子目录、指到了 // Chrome 默认目录、或者那份 profile 正被一个运行中的 Chrome 占用。 // DefaultUserDataDir 返回当前平台 Chrome 的默认用户数据目录(那份带登录态与扩展的)。 // 找不到时返回候选列表供人肉判断。 func DefaultUserDataDir() (string, error) { for _, c := range defaultUserDataDirCandidates() { if st, err := os.Stat(c); err == nil && st.IsDir() { return c, nil } } return "", fmt.Errorf("找不到默认的 Chrome 用户数据目录(找过:%s)", strings.Join(defaultUserDataDirCandidates(), ", ")) } // defaultUserDataDirCandidates 列出各平台可能的默认 user-data-dir。 func defaultUserDataDirCandidates() []string { home, _ := os.UserHomeDir() switch runtime.GOOS { case "darwin": return []string{ filepath.Join(home, "Library", "Application Support", "Google", "Chrome"), filepath.Join(home, "Library", "Application Support", "Chromium"), filepath.Join(home, "Library", "Application Support", "Microsoft Edge"), } case "windows": base := os.Getenv("LOCALAPPDATA") if base == "" { return nil } return []string{ filepath.Join(base, "Google", "Chrome", "User Data"), filepath.Join(base, "Microsoft", "Edge", "User Data"), } default: return []string{ filepath.Join(home, ".config", "google-chrome"), filepath.Join(home, ".config", "chromium"), filepath.Join(home, ".config", "microsoft-edge"), } } } // PersistentProfileDir 是 AutoGo 自己的持久 profile 目录。 // // 用它而不是临时目录:临时目录意味着"登录一次也留不住"(每次进程启动都是新的空 // profile),而且没人清理。持久目录下登录一次就长期有效。 func PersistentProfileDir() (string, error) { home, err := os.UserHomeDir() if err != nil { return "", err } return filepath.Join(home, ".autogo", "browser-profile"), nil } // IsChromeDefaultDir 报告 dir 是不是(或位于)系统默认的 Chrome user-data-dir。 // 是的话要提醒:Chrome 136+ 起出于安全**禁止在默认 profile 上开启远程调试**,必须用 // 非默认的 user-data-dir;而且默认目录通常被日常 Chrome 占着(单实例锁)。 func IsChromeDefaultDir(dir string) bool { abs, err := filepath.Abs(dir) if err != nil { return false } for _, c := range defaultUserDataDirCandidates() { if abs == c { return true } } return false } // looksLikeProfileDir 判断 dir 像是"某个 profile 本体"(Default/ 里的那种), // 而不是 user-data-dir。这是最常见的配置错误:--user-data-dir 要指向**父目录**。 func looksLikeProfileDir(dir string) bool { for _, f := range []string{"Cookies", "Login Data", "Preferences"} { if st, err := os.Stat(filepath.Join(dir, f)); err == nil && !st.IsDir() { return true } } return false } // CheckProfile 对配置里的 profile 目录做体检,返回若干条**人类可读的告警**(可以为空)。 // 不存在之类属于致命问题,由调用方按错误处理——这里只做"能启动但可能不符合预期"的提醒。 func CheckProfile(dir string) []string { var warns []string if dir == "" { return nil } st, err := os.Stat(dir) if err != nil || !st.IsDir() { return []string{fmt.Sprintf("profile 目录不存在:%s(它不会被自动创建;先确认路径,或用 `a2go browser profile-copy` 复制一份)", dir)} } if looksLikeProfileDir(dir) { warns = append(warns, fmt.Sprintf( "profile 指向的像是**profile 本体**(里面有 Cookies/Preferences):%s\n"+ " --user-data-dir 要指向它的**父目录**,否则 Chrome 会在里面另建一套结构,你的登录态不会生效。", dir)) } if IsChromeDefaultDir(dir) { warns = append(warns, fmt.Sprintf( "profile 是 Chrome 的**默认目录**:%s\n"+ " Chrome 136+ 起禁止在默认 profile 上开启远程调试,而且日常 Chrome 通常正占着它。\n"+ " 建议改用副本:a2go browser profile-copy", dir)) } if _, err := os.Lstat(filepath.Join(dir, "SingletonLock")); err == nil { warns = append(warns, fmt.Sprintf( "该 profile 里有 SingletonLock:%s\n"+ " 说明可能有一个正在运行的 Chrome 在用这份 profile。同一个 user-data-dir 起第二个实例通常\n"+ " 拿不到调试端口(会被转交给已运行的实例)。请先完全退出 Chrome,或改用副本。", dir)) } return warns } // profileCacheDirs 是复制时跳过的缓存目录:体积大、且与登录态无关(含扩展的缓存)。 // 注意 **不跳过 Extensions** —— 复用方案里扩展正是要带过去的东西之一。 var profileCacheDirs = map[string]bool{ "Cache": true, "Code Cache": true, "GPUCache": true, "ShaderCache": true, "GrShaderCache": true, "DawnCache": true, "DawnGraphiteCache": true, "GraphiteDawnCache": true, "Media Cache": true, "Service Worker": true, "Crashpad": true, "BrowserMetrics": true, "component_crx_cache": true, "extensions_crx_cache": true, "Safe Browsing": true, } // profileSkipFile 判断某个文件是否属于"复制时跳过"(单实例锁等,带过去反而会误导 Chrome)。 func profileSkipFile(name string) bool { return strings.HasPrefix(name, "Singleton") } // CopyProfile 把 src 里的登录态与扩展复制到 dst。 // // 复制前会拒绝"源正被占用"的情况:Chrome 运行中复制的 profile 可能不一致,而且它的 // 单实例锁文件会一起被带过去。 func CopyProfile(src, dst string) (int64, error) { st, err := os.Stat(src) if err != nil || !st.IsDir() { return 0, fmt.Errorf("源 profile 不存在或不是目录:%s", src) } if looksLikeProfileDir(src) { return 0, fmt.Errorf("源指向的像是 profile 本体(含 Cookies/Preferences):%s\n要复制的是它的**父目录**(user-data-dir)", src) } if _, err := os.Lstat(filepath.Join(src, "SingletonLock")); err == nil { return 0, fmt.Errorf("源 profile 正被占用(有 SingletonLock):%s\n请先完全退出 Chrome 再复制——运行中复制的 profile 可能不一致", src) } if err := os.MkdirAll(dst, 0o755); err != nil { return 0, err } var total int64 err = filepath.WalkDir(src, func(path string, d os.DirEntry, err error) error { if err != nil { return nil // 个别文件读不了(权限/正在写)不该中断整次复制 } rel, err := filepath.Rel(src, path) if err != nil { return nil } if rel == "." { return nil } // 跳过缓存目录(整个子树)、单实例锁、以及 *-journal 之类的临时文件。 for _, part := range strings.Split(rel, string(filepath.Separator)) { if profileCacheDirs[part] { return filepath.SkipDir } } if profileSkipFile(d.Name()) { return nil } target := filepath.Join(dst, rel) if d.IsDir() { return os.MkdirAll(target, 0o755) } if !d.Type().IsRegular() { return nil // 符号链接等一律跳过 } n, err := copyFile(path, target) total += n return err }) if err != nil { return total, err } // 目标里也清一次:万一 dst 是之前从运行中的 Chrome 拷来的。 for _, f := range []string{"SingletonLock", "SingletonSocket", "SingletonCookie"} { _ = os.Remove(filepath.Join(dst, f)) } return total, nil } func copyFile(src, dst string) (int64, error) { in, err := os.Open(src) if err != nil { return 0, nil // 读不了就跳过,不中断 } defer in.Close() if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return 0, err } out, err := os.Create(dst) if err != nil { return 0, err } defer out.Close() n, err := io.Copy(out, in) if err != nil { return n, nil } return n, nil } // LooksLikeBrowserProfile 判断 dir 是否像一份浏览器 user-data-dir(用于 --force 前的安全校验: // 只有确认目标确实是 profile 才允许覆盖删除,避免手滑删掉无关目录)。 func LooksLikeBrowserProfile(dir string) bool { for _, f := range []string{"Local State", "Default", "First Run"} { if _, err := os.Stat(filepath.Join(dir, f)); err == nil { return true } } return false } // ErrProfileMissing 是 profile 不存在时的哨兵(调用方据此给出配置建议)。 var ErrProfileMissing = errors.New("browser profile not found")