From 3ab044f44bcd13638fb42bace52931e8cc6e9f3e Mon Sep 17 00:00:00 2001 From: Vaxaxas <250858849+Vaxaxas@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:26:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E6=95=B4=E5=B0=8D=E9=BD=8A?= =?UTF-8?q?=E4=B8=8A=E6=B8=B8=20main=20=E4=B8=A6=E5=8A=A0=E5=85=A5=20Brave?= =?UTF-8?q?/Edge=20=E7=80=8F=E8=A6=BD=E5=99=A8=E5=B8=B3=E8=99=9F=E5=8C=AF?= =?UTF-8?q?=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 強制對齊上游 main (v0.2.18 SpiderMonkey、新版 WebUI 與 API Playground) - 支援 Brave 與 Edge 瀏覽器帳號自動偵測、DPAPI/ABE 主密鑰解密與檔案讀取鎖 (DuplicateHandle) 突破 - 支援多帳號會話 (u/0..u/9) Gaia ID 與 AuthUser 解析 - 匯入彈窗支援 Chrome / Brave / Edge 頁籤切換,Chrome 缺省時自動回退至 Brave - 自動過濾已匯入帳號並標記「已匯入」,防止重複選取 - 修復點擊「啟動服務」時誤觸管理員登入彈窗之問題 Co-Authored-By: Claude Code --- internal/api/admin.go | 67 ++- internal/app/admin.go | 141 ++++-- internal/app/lifecycle.go | 16 + internal/chromeauth/abe_unsupported.go | 4 + internal/chromeauth/abe_windows_amd64.go | 158 ++++++- internal/chromeauth/auth.go | 249 ++++++++-- internal/chromeauth/copy.go | 24 + internal/chromeauth/gaia_proto.go | 103 +++++ internal/chromeauth/locked_file_windows.go | 198 ++++++++ internal/chromeauth/platform_other.go | 40 +- internal/chromeauth/platform_windows.go | 500 +++++++++++++++++---- web/src/ConsoleRoot.vue | 4 +- web/src/api.ts | 26 +- web/src/components/AccountsPanel.vue | 89 +++- web/src/types.ts | 24 +- 15 files changed, 1435 insertions(+), 208 deletions(-) create mode 100644 internal/chromeauth/copy.go create mode 100644 internal/chromeauth/gaia_proto.go create mode 100644 internal/chromeauth/locked_file_windows.go diff --git a/internal/api/admin.go b/internal/api/admin.go index c1423c75..596b5c9c 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -20,9 +20,11 @@ type AdminService interface { Accounts(context.Context) ([]AdminAccount, error) CreateAccount(context.Context, AccountCreateInput, *camoufoxnative.LiveView) (AdminAccount, error) ChromeImportProfiles(context.Context) ([]ChromeImportProfile, error) + BrowserImportProfiles(ctx context.Context, browser string) ([]BrowserImportProfile, error) CookieBrowsers(context.Context) ([]CookieBrowser, error) DiscoverCookieAccounts(context.Context, CookieImportInput) ([]CookieBrowser, error) ImportChromeAccounts(context.Context, ChromeImportInput) ([]AdminAccount, error) + ImportBrowserAccounts(ctx context.Context, input BrowserImportInput) ([]AdminAccount, []ImportFailure, error) ImportAccountState(context.Context, AccountStateInput) (AdminAccount, error) UpdateAccount(context.Context, string, AccountInput) (AdminAccount, error) DeleteAccount(context.Context, string) error @@ -247,6 +249,33 @@ type ChromeImportInput struct { Timezone string `json:"timezone"` } +// BrowserImportProfile 表示可从本机浏览器导入的账号 +type BrowserImportProfile struct { + ID string `json:"id"` + Browser string `json:"browser,omitempty"` + Profile string `json:"profile"` + DisplayName string `json:"display_name"` + Email string `json:"email"` + Locale string `json:"locale"` + AuthUser int `json:"auth_user,omitempty"` + Imported bool `json:"imported"` +} + +// BrowserImportInput 表示批量导入的浏览器账号与账户环境 +type BrowserImportInput struct { + Browser string `json:"browser"` + AccountIDs []string `json:"account_ids"` + Proxy string `json:"proxy"` + Locale string `json:"locale"` + Timezone string `json:"timezone"` +} + +// ImportFailure 描述导入失败的账号与原因 +type ImportFailure struct { + Target string `json:"target"` + Error string `json:"error"` +} + // RuntimeConfig 表示全局运行配置 type RuntimeConfig struct { AutoStart bool `json:"auto_start"` @@ -334,6 +363,8 @@ func (s *server) registerAdmin(mux *http.ServeMux) { mux.HandleFunc("POST /api/accounts", s.handleCreateAccount) mux.HandleFunc("GET /api/accounts/import/chrome", s.handleChromeImportProfiles) mux.HandleFunc("POST /api/accounts/import/chrome", s.handleImportChromeAccounts) + mux.HandleFunc("GET /api/accounts/import/browser", s.handleBrowserImportProfiles) + mux.HandleFunc("POST /api/accounts/import/browser", s.handleImportBrowserAccounts) mux.HandleFunc("GET /api/accounts/import/browsers", s.handleCookieBrowsers) mux.HandleFunc("POST /api/accounts/import/cookies/discover", s.handleDiscoverCookieAccounts) mux.HandleFunc("POST /api/accounts/import/cookies", s.handleImportCookieAccount) @@ -429,6 +460,36 @@ func (s *server) handleImportChromeAccounts(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusCreated, map[string][]AdminAccount{"accounts": accounts}) } +func (s *server) handleBrowserImportProfiles(w http.ResponseWriter, r *http.Request) { + browser := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("browser"))) + if browser == "" { + browser = "chrome" + } + profiles, err := s.config.Admin.BrowserImportProfiles(r.Context(), browser) + if err != nil { + writeAdminUpstreamError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"browser": browser, "profiles": profiles}) +} + +func (s *server) handleImportBrowserAccounts(w http.ResponseWriter, r *http.Request) { + var input BrowserImportInput + if err := decodeJSON(r, &input); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + if input.Browser == "" { + input.Browser = "chrome" + } + accounts, failures, err := s.config.Admin.ImportBrowserAccounts(r.Context(), input) + if err != nil && len(accounts) == 0 { + writeAdminUpstreamError(w, err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"accounts": accounts, "failures": failures}) +} + func (s *server) handleCookieBrowsers(w http.ResponseWriter, r *http.Request) { browsers, err := s.config.Admin.CookieBrowsers(r.Context()) if err != nil { @@ -671,5 +732,9 @@ func writeAdminUpstreamError(w http.ResponseWriter, err error) { if errors.Is(err, context.Canceled) { return } - writeAdminError(w, statusFromError(err), codeFromError(err, "upstream_error"), err.Error()) + status := statusFromError(err) + if status == http.StatusUnauthorized { + status = http.StatusBadGateway + } + writeAdminError(w, status, codeFromError(err, "upstream_error"), err.Error()) } diff --git a/internal/app/admin.go b/internal/app/admin.go index f090fc24..4a90908d 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -175,28 +175,48 @@ func (admin *runtimeAdmin) CreateAccount(ctx context.Context, input api.AccountC return created, nil } -func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeImportProfile, error) { - root, err := chromeauth.DefaultChromeRoot() +func (admin *runtimeAdmin) BrowserImportProfiles(ctx context.Context, browser string) ([]api.BrowserImportProfile, error) { + if browser == "" { + browser = "chrome" + } + root, err := chromeauth.DefaultBrowserRoot(browser) if err != nil { return nil, err } - accounts, err := chromeauth.Discover(root) + accounts, err := chromeauth.DiscoverBrowser(browser, root) if err != nil { return nil, err } existing := admin.accountIDs() - profiles := make([]api.ChromeImportProfile, 0, len(accounts)) + profiles := make([]api.BrowserImportProfile, 0, len(accounts)) for _, account := range accounts { email := strings.ToLower(strings.TrimSpace(account.Email)) if !account.Importable || email == "" { continue } - if _, exists := existing[email]; exists { + _, isImported := existing[email] + profiles = append(profiles, api.BrowserImportProfile{ + ID: account.ID, Browser: browser, Profile: account.Profile, DisplayName: account.DisplayName, + Email: email, Locale: account.Locale, AuthUser: account.AuthUser, Imported: isImported, + }) + } + return profiles, nil +} + +func (admin *runtimeAdmin) ChromeImportProfiles(ctx context.Context) ([]api.ChromeImportProfile, error) { + bp, err := admin.BrowserImportProfiles(ctx, "chrome") + if err != nil || len(bp) == 0 { + if braveBp, braveErr := admin.BrowserImportProfiles(ctx, "brave"); braveErr == nil && len(braveBp) > 0 { + bp = braveBp + } + } + profiles := make([]api.ChromeImportProfile, 0, len(bp)) + for _, p := range bp { + if p.Imported { continue } - existing[email] = struct{}{} profiles = append(profiles, api.ChromeImportProfile{ - ID: account.ID, Profile: account.Profile, DisplayName: account.DisplayName, Email: email, Locale: account.Locale, + ID: p.ID, Profile: p.Profile, DisplayName: p.DisplayName, Email: p.Email, Locale: p.Locale, }) } return profiles, nil @@ -291,28 +311,59 @@ func (admin *runtimeAdmin) accountIDs() map[string]struct{} { } func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { + accounts, _, err := admin.ImportBrowserAccounts(ctx, api.BrowserImportInput{ + Browser: "chrome", AccountIDs: input.AccountIDs, Proxy: input.Proxy, Locale: input.Locale, Timezone: input.Timezone, + }) + if err != nil && len(accounts) == 0 { + if bAccs, _, bErr := admin.ImportBrowserAccounts(ctx, api.BrowserImportInput{ + Browser: "brave", AccountIDs: input.AccountIDs, Proxy: input.Proxy, Locale: input.Locale, Timezone: input.Timezone, + }); bErr == nil && len(bAccs) > 0 { + return bAccs, nil + } + } + return accounts, err +} + +func (admin *runtimeAdmin) ImportBrowserAccounts(ctx context.Context, input api.BrowserImportInput) ([]api.AdminAccount, []api.ImportFailure, error) { if len(input.AccountIDs) == 0 { - return nil, invalidAccount(fmt.Errorf("未选择 Chrome 账号")) + return nil, nil, invalidAccount(fmt.Errorf("未选择账号")) } - root, err := chromeauth.DefaultChromeRoot() + browser := input.Browser + if browser == "" { + browser = "chrome" + } + root, err := chromeauth.DefaultBrowserRoot(browser) if err != nil { - return nil, err + return nil, nil, err } accountProxy := strings.TrimSpace(input.Proxy) - results, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ - ChromeRoot: root, Proxy: admin.effectiveProxy(accountProxy), AccountIDs: input.AccountIDs, + res, err := chromeauth.ImportWithDetails(ctx, chromeauth.ImportOptions{ + Browser: browser, + ChromeRoot: root, + Proxy: admin.effectiveProxy(accountProxy), + AccountIDs: input.AccountIDs, }) - if err != nil { - return nil, err + if err != nil && len(res.Results) == 0 { + var failures []api.ImportFailure + for _, f := range res.Failures { + failures = append(failures, api.ImportFailure{Target: f.Target, Error: f.Error}) + } + return nil, failures, err + } + + var failures []api.ImportFailure + for _, f := range res.Failures { + failures = append(failures, api.ImportFailure{Target: f.Target, Error: f.Error}) } - configs := make([]aistudio.AccountConfig, len(results)) - seen := make(map[string]struct{}, len(results)) - for index, result := range results { + + existingAccounts := make(map[string]struct{}) + for _, status := range admin.pool.Status() { + existingAccounts[strings.ToLower(strings.TrimSpace(status.ID))] = struct{}{} + } + + accounts := make([]api.AdminAccount, 0, len(res.Results)) + for _, result := range res.Results { email := strings.ToLower(strings.TrimSpace(result.Email)) - if _, exists := seen[email]; exists { - return nil, invalidAccount(fmt.Errorf("Chrome 账号重复: %s", email)) - } - seen[email] = struct{}{} accountConfig := aistudio.DefaultAccountConfig(email) accountConfig.Proxy = accountProxy if locale := strings.TrimSpace(input.Locale); locale != "" { @@ -324,23 +375,51 @@ func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.C accountConfig.Timezone = timezone } if err := accountConfig.Validate(); err != nil { - return nil, invalidAccount(err) + failures = append(failures, api.ImportFailure{Target: email, Error: err.Error()}) + continue } if _, err := aistudio.NewSigner().Authorization(result.State); err != nil { - return nil, fmt.Errorf("认证状态无法用于 AI Studio: %w", err) + failures = append(failures, api.ImportFailure{Target: email, Error: fmt.Sprintf("认证状态无法用于 AI Studio: %v", err)}) + continue } - configs[index] = accountConfig - } - accounts := make([]api.AdminAccount, 0, len(results)) - for index, result := range results { - account, err := admin.addAccount(ctx, configs[index], result.State, "") + + if _, exists := existingAccounts[email]; exists { + lease, leaseErr := admin.pool.AcquireAccount(ctx, email) + if leaseErr == nil { + account := lease.Account() + _ = admin.workers.Reset(account.ID) + saveErr := lease.SaveStorageState(result.State) + _ = admin.service.changeModels(func() error { + return errors.Join( + admin.pool.MarkReady(account.ID), + admin.pool.ResetModelAccess(account.ID), + ) + }) + _ = lease.Release() + if saveErr == nil { + admin.syncAccountModelCatalog(ctx, account) + if updated, err := admin.account(account.ID); err == nil { + accounts = append(accounts, updated) + admin.requests.log("auth", "INFO", browser+" 账户已覆盖更新 | 账户="+updated.Label) + continue + } + } + } + } + + account, err := admin.addAccount(ctx, accountConfig, result.State, "") if err != nil { - return nil, err + failures = append(failures, api.ImportFailure{Target: email, Error: err.Error()}) + continue } accounts = append(accounts, account) - admin.requests.log("auth", "INFO", "Chrome 账户已导入 | 账户="+account.Label) + admin.requests.log("auth", "INFO", browser+" 账户已导入 | 账户="+account.Label) } - return accounts, nil + + if len(accounts) == 0 && len(failures) > 0 { + return nil, failures, fmt.Errorf("所有所选账号均导入失败: %s", failures[0].Error) + } + return accounts, failures, nil } // ImportAccountState 导入远程配对上传或 Cookie 导入的认证状态与 Camoufox 指纹,未给标签时以 Cookie 中第 auth_user 个会话登录的账户邮箱为标签 diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 65516925..d75a7f73 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -30,9 +30,11 @@ type generationAdmin interface { Accounts(context.Context) ([]api.AdminAccount, error) CreateAccount(context.Context, api.AccountCreateInput, *camoufoxnative.LiveView) (api.AdminAccount, error) ChromeImportProfiles(context.Context) ([]api.ChromeImportProfile, error) + BrowserImportProfiles(ctx context.Context, browser string) ([]api.BrowserImportProfile, error) CookieBrowsers(context.Context) ([]api.CookieBrowser, error) DiscoverCookieAccounts(context.Context, api.CookieImportInput) ([]api.CookieBrowser, error) ImportChromeAccounts(context.Context, api.ChromeImportInput) ([]api.AdminAccount, error) + ImportBrowserAccounts(ctx context.Context, input api.BrowserImportInput) ([]api.AdminAccount, []api.ImportFailure, error) ImportAccountState(context.Context, api.AccountStateInput) (api.AdminAccount, error) UpdateAccount(context.Context, string, api.AccountInput) (api.AdminAccount, error) DeleteAccount(context.Context, string) error @@ -454,6 +456,20 @@ func (manager *runtimeManager) ChromeImportProfiles(ctx context.Context) ([]api. return manager.current.admin.ChromeImportProfiles(ctx) } +// BrowserImportProfiles 返回当前生成服务可导入的指定浏览器账号 +func (manager *runtimeManager) BrowserImportProfiles(ctx context.Context, browser string) ([]api.BrowserImportProfile, error) { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.admin.BrowserImportProfiles(ctx, browser) +} + +// ImportBrowserAccounts 在当前生成服务批量导入指定浏览器账号 +func (manager *runtimeManager) ImportBrowserAccounts(ctx context.Context, input api.BrowserImportInput) ([]api.AdminAccount, []api.ImportFailure, error) { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.admin.ImportBrowserAccounts(ctx, input) +} + // CookieBrowsers 返回当前生成服务发现的本机调试浏览器 func (manager *runtimeManager) CookieBrowsers(ctx context.Context) ([]api.CookieBrowser, error) { manager.mu.RLock() diff --git a/internal/chromeauth/abe_unsupported.go b/internal/chromeauth/abe_unsupported.go index 87989677..6eb2bdab 100644 --- a/internal/chromeauth/abe_unsupported.go +++ b/internal/chromeauth/abe_unsupported.go @@ -7,3 +7,7 @@ import "fmt" func retrieveV20Key(string) ([]byte, error) { return nil, fmt.Errorf("自动读取 Chrome App-Bound 主密钥仅支持 Windows amd64") } + +func retrieveV20KeyForBrowser(string, string) ([]byte, error) { + return nil, fmt.Errorf("自动读取 App-Bound 主密钥仅支持 Windows amd64") +} diff --git a/internal/chromeauth/abe_windows_amd64.go b/internal/chromeauth/abe_windows_amd64.go index 1f153ed1..fada70f2 100644 --- a/internal/chromeauth/abe_windows_amd64.go +++ b/internal/chromeauth/abe_windows_amd64.go @@ -3,6 +3,7 @@ package chromeauth import ( + "bytes" _ "embed" "encoding/base64" "encoding/json" @@ -47,17 +48,63 @@ var ( //go:embed native/abe_helper_amd64.bin var abeHelperDLL []byte +var ( + chromeCLSIDP1 = []byte{0x48, 0xB8, 0xE0, 0x60, 0x88, 0x70, 0x41, 0xF6, 0x11, 0x46} + chromeCLSIDP2 = []byte{0x48, 0xB8, 0x88, 0x95, 0x7D, 0x86, 0x7D, 0xD3, 0x67, 0x5B} + chromeIIDP1 = []byte{0x48, 0xB8, 0x8B, 0x20, 0xF5, 0x1B, 0x5F, 0x29, 0x92, 0x49} + chromeIIDP2 = []byte{0x48, 0xB8, 0xB5, 0xF4, 0x3A, 0x9B, 0xB6, 0x49, 0x48, 0x38} + + braveCLSIDP1 = []byte{0x48, 0xB8, 0xAF, 0x31, 0x6B, 0x57, 0x69, 0x63, 0x6B, 0x4B} + braveCLSIDP2 = []byte{0x48, 0xB8, 0x85, 0x60, 0xE4, 0xB2, 0x03, 0xA9, 0x7A, 0x8B} + braveIIDP1 = []byte{0x48, 0xB8, 0x1E, 0x86, 0x96, 0xF3, 0x8E, 0x0C, 0x71, 0x4C} + braveIIDP2 = []byte{0x48, 0xB8, 0x82, 0x56, 0x2F, 0xAE, 0x6D, 0x75, 0x9C, 0xE9} + + edgeCLSIDP1 = []byte{0x48, 0xB8, 0x6C, 0xE9, 0xCB, 0x1F, 0x97, 0x16, 0xAF, 0x43} + edgeCLSIDP2 = []byte{0x48, 0xB8, 0x91, 0x40, 0x28, 0x97, 0xC7, 0xC6, 0x97, 0x67} + edgeIIDP1 = []byte{0x48, 0xB8, 0x07, 0xB8, 0xC2, 0xC9, 0x31, 0x77, 0x34, 0x4F} + edgeIIDP2 = []byte{0x48, 0xB8, 0x81, 0xB7, 0x44, 0xFF, 0x77, 0x79, 0x52, 0x2B} +) + +func patchHelperDLL(browser string) []byte { + dll := make([]byte, len(abeHelperDLL)) + copy(dll, abeHelperDLL) + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserBrave: + patchBytes(dll, chromeCLSIDP1, braveCLSIDP1) + patchBytes(dll, chromeCLSIDP2, braveCLSIDP2) + patchBytes(dll, chromeIIDP1, braveIIDP1) + patchBytes(dll, chromeIIDP2, braveIIDP2) + case BrowserEdge: + patchBytes(dll, chromeCLSIDP1, edgeCLSIDP1) + patchBytes(dll, chromeCLSIDP2, edgeCLSIDP2) + patchBytes(dll, chromeIIDP1, edgeIIDP1) + patchBytes(dll, chromeIIDP2, edgeIIDP2) + } + return dll +} + +func patchBytes(dst []byte, oldBytes, newBytes []byte) { + if idx := bytes.Index(dst, oldBytes); idx != -1 { + copy(dst[idx:idx+len(newBytes)], newBytes) + } +} + // retrieveV20Key 读取 Chrome App-Bound v20 主密钥 func retrieveV20Key(chromeRoot string) ([]byte, error) { - encrypted, err := readAppBoundCiphertext(chromeRoot) + return retrieveV20KeyForBrowser(BrowserChrome, chromeRoot) +} + +// retrieveV20KeyForBrowser 读取指定浏览器 App-Bound v20 主密钥 +func retrieveV20KeyForBrowser(browser string, root string) ([]byte, error) { + encrypted, err := readAppBoundCiphertext(root) if err != nil { return nil, err } - chromePath, err := findChromeExecutable() + browserPath, err := findBrowserExecutable(browser) if err != nil { return nil, err } - return decryptAppBoundCiphertext(chromePath, encrypted) + return decryptAppBoundCiphertext(browser, browserPath, encrypted) } // readAppBoundCiphertext 从 Local State 提取 APPB 密文 @@ -88,10 +135,24 @@ func readAppBoundCiphertext(chromeRoot string) ([]byte, error) { return decoded[len(appBoundPrefix):], nil } +// findBrowserExecutable 定位指定浏览器可执行文件 +func findBrowserExecutable(browser string) (string, error) { + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserChrome, "": + return findChromeExecutable() + case BrowserBrave: + return findBraveExecutable() + case BrowserEdge: + return findEdgeExecutable() + default: + return "", fmt.Errorf("未知浏览器: %s", browser) + } +} + // findChromeExecutable 定位稳定版 Chrome 可执行文件 func findChromeExecutable() (string, error) { for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} { - path, err := chromeExecutableFromRegistry(root) + path, err := executableFromRegistry(root, "chrome.exe") if err == nil { return path, nil } @@ -104,9 +165,41 @@ func findChromeExecutable() (string, error) { return "", fmt.Errorf("找不到 chrome.exe") } -// chromeExecutableFromRegistry 读取 Windows App Paths -func chromeExecutableFromRegistry(root registry.Key) (string, error) { - key, err := registry.OpenKey(root, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe`, registry.QUERY_VALUE) +// findBraveExecutable 定位稳定版 Brave 可执行文件 +func findBraveExecutable() (string, error) { + for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} { + path, err := executableFromRegistry(root, "brave.exe") + if err == nil { + return path, nil + } + } + for _, path := range braveExecutableFallbacks() { + if fileInfo, err := os.Stat(path); err == nil && !fileInfo.IsDir() { + return path, nil + } + } + return "", fmt.Errorf("找不到 brave.exe") +} + +// findEdgeExecutable 定位稳定版 Edge 可执行文件 +func findEdgeExecutable() (string, error) { + for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} { + path, err := executableFromRegistry(root, "msedge.exe") + if err == nil { + return path, nil + } + } + for _, path := range edgeExecutableFallbacks() { + if fileInfo, err := os.Stat(path); err == nil && !fileInfo.IsDir() { + return path, nil + } + } + return "", fmt.Errorf("找不到 msedge.exe") +} + +// executableFromRegistry 读取 Windows App Paths +func executableFromRegistry(root registry.Key, exeName string) (string, error) { + key, err := registry.OpenKey(root, `SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\`+exeName, registry.QUERY_VALUE) if err != nil { return "", err } @@ -115,12 +208,12 @@ func chromeExecutableFromRegistry(root registry.Key) (string, error) { if err != nil { return "", err } - value = strings.TrimSpace(value) + value = strings.Trim(strings.TrimSpace(value), "\"") if value == "" { - return "", fmt.Errorf("Chrome App Paths 为空") + return "", fmt.Errorf("%s App Paths 为空", exeName) } if fileInfo, err := os.Stat(value); err != nil || fileInfo.IsDir() { - return "", fmt.Errorf("Chrome App Paths 不可用") + return "", fmt.Errorf("%s App Paths 不可用", exeName) } return value, nil } @@ -140,8 +233,38 @@ func chromeExecutableFallbacks() []string { return paths } -// decryptAppBoundCiphertext 在独立 Chrome 进程内解密主密钥 -func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, error) { +// braveExecutableFallbacks 返回 Brave 常见安装位置 +func braveExecutableFallbacks() []string { + paths := make([]string, 0, 3) + if programFiles := strings.TrimSpace(os.Getenv("ProgramFiles")); programFiles != "" { + paths = append(paths, filepath.Join(programFiles, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) + } + if programFilesX86 := strings.TrimSpace(os.Getenv("ProgramFiles(x86)")); programFilesX86 != "" { + paths = append(paths, filepath.Join(programFilesX86, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) + } + if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" { + paths = append(paths, filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "Application", "brave.exe")) + } + return paths +} + +// edgeExecutableFallbacks 返回 Edge 常见安装位置 +func edgeExecutableFallbacks() []string { + paths := make([]string, 0, 3) + if programFilesX86 := strings.TrimSpace(os.Getenv("ProgramFiles(x86)")); programFilesX86 != "" { + paths = append(paths, filepath.Join(programFilesX86, "Microsoft", "Edge", "Application", "msedge.exe")) + } + if programFiles := strings.TrimSpace(os.Getenv("ProgramFiles")); programFiles != "" { + paths = append(paths, filepath.Join(programFiles, "Microsoft", "Edge", "Application", "msedge.exe")) + } + if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" { + paths = append(paths, filepath.Join(localAppData, "Microsoft", "Edge", "Application", "msedge.exe")) + } + return paths +} + +// decryptAppBoundCiphertext 在独立浏览器进程内解密主密钥 +func decryptAppBoundCiphertext(browser string, browserPath string, encrypted []byte) ([]byte, error) { tempDir, err := os.MkdirTemp("", "aistudio2api-abe-*") if err != nil { return nil, fmt.Errorf("创建 ABE 临时目录: %w", err) @@ -150,7 +273,8 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err helperPath := filepath.Join(tempDir, abeHelperDLLName) outputPath := filepath.Join(tempDir, abeKeyFileName) - if err := os.WriteFile(helperPath, abeHelperDLL, 0o600); err != nil { + helperBytes := patchHelperDLL(browser) + if err := os.WriteFile(helperPath, helperBytes, 0o600); err != nil { return nil, fmt.Errorf("写入 ABE helper: %w", err) } @@ -158,7 +282,7 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err abeInputEnv: base64.StdEncoding.EncodeToString(encrypted), abeOutputEnv: outputPath, }) - process, thread, err := startHiddenChrome(chromePath, filepath.Join(tempDir, "profile")) + process, thread, err := startHiddenChrome(browserPath, filepath.Join(tempDir, "profile")) restoreEnv() if err != nil { return nil, err @@ -173,7 +297,7 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err defer closeChromeProcess(job, process, thread) if _, err := windows.ResumeThread(thread); err != nil { - return nil, fmt.Errorf("启动临时 Chrome: %w", err) + return nil, fmt.Errorf("启动临时浏览器: %w", err) } time.Sleep(750 * time.Millisecond) if err := injectDLL(process, helperPath); err != nil { @@ -184,13 +308,13 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err data, err := os.ReadFile(outputPath) if err == nil { if len(data) != 32 { - return nil, fmt.Errorf("Chrome ABE helper 返回异常: %s", strings.TrimSpace(string(data))) + return nil, fmt.Errorf("%s ABE helper 返回异常: %s", browser, strings.TrimSpace(string(data))) } return data, nil } time.Sleep(abePollInterval) } - return nil, fmt.Errorf("Chrome ABE helper 超时") + return nil, fmt.Errorf("%s ABE helper 超时", browser) } // createKillOnCloseJob 将临时 Chrome 进程树绑定到独立 Job diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index 1ee1e779..1f2261b5 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -13,22 +13,48 @@ import ( "github.com/Mag1cFall/AIStudio2API/internal/aistudio" ) -const maxCookieAge = 400 * 24 * time.Hour +const ( + maxCookieAge = 400 * 24 * time.Hour + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36" -// Account 描述本机 Chrome 中可发现的 Google 账号 + BrowserChrome = "chrome" + BrowserBrave = "brave" + BrowserEdge = "edge" + + ReasonBrowserRunning = "browser_running" +) + +// Account 描述本机浏览器中可发现的 Google 账号 type Account struct { ID string `json:"id"` + Browser string `json:"browser,omitempty"` GaiaID string `json:"-"` Profile string `json:"profile"` DisplayName string `json:"display_name"` Email string `json:"email"` Locale string `json:"locale"` + AuthUser int `json:"auth_user,omitempty"` Importable bool `json:"importable"` + Reason string `json:"reason,omitempty"` + SourceType string `json:"source_type,omitempty"` +} + +// ImportFailure 描述批量导入时单个账号失败的原因 +type ImportFailure struct { + Target string `json:"target"` + Error string `json:"error"` } -// ImportOptions 保存 Chrome 批量导入参数 +// BrowserImportResult 包含批量导入的结果与失败详情 +type BrowserImportResult struct { + Results []ImportResult `json:"results"` + Failures []ImportFailure `json:"failures,omitempty"` +} + +// ImportOptions 保存浏览器批量导入参数 type ImportOptions struct { AccountIDs []string + Browser string ChromeRoot string Proxy string Profiles []string @@ -37,6 +63,8 @@ type ImportOptions struct { // ImportResult 返回一个已验证前的账号状态 type ImportResult struct { + ID string + Browser string Profile string DisplayName string Email string @@ -45,42 +73,117 @@ type ImportResult struct { State aistudio.StorageState } +// DefaultChromeRoot 返回稳定版 Chrome User Data 目录 +func DefaultChromeRoot() (string, error) { + return DefaultBrowserRoot(BrowserChrome) +} + +// DefaultBrowserRoot 返回指定浏览器的默认根目录 +func DefaultBrowserRoot(browser string) (string, error) { + return defaultBrowserRoot(browser) +} + +// Discover 只读列出本机 Chrome Google 账号 +func Discover(chromeRoot string) ([]Account, error) { + return DiscoverBrowser(BrowserChrome, chromeRoot) +} + +// DiscoverBrowser 只读列出指定浏览器中的 Google 账号 +func DiscoverBrowser(browser string, root string) ([]Account, error) { + if browser == "" { + browser = BrowserChrome + } + if root == "" { + var err error + root, err = DefaultBrowserRoot(browser) + if err != nil { + return nil, err + } + } + return discoverPlatformBrowser(browser, root) +} + +// ReadBrowserCookies 读取指定浏览器 Profile 中的 Google Cookie +func ReadBrowserCookies(browser string, profile string) ([]aistudio.StateCookie, error) { + if browser == "" { + browser = BrowserChrome + } + root, err := DefaultBrowserRoot(browser) + if err != nil { + return nil, err + } + masterKey, err := retrieveMasterKey(browser, root) + if err != nil { + return nil, err + } + return readChromiumCookies(browser, root, profile, masterKey) +} + // Import 用本机 Chrome 的 OAuth 续签材料生成 Playwright storage state func Import(ctx context.Context, options ImportOptions) ([]ImportResult, error) { + res, err := ImportWithDetails(ctx, options) + return res.Results, err +} + +// ImportWithDetails 批量导入指定浏览器的账号,具备单账号容错 +func ImportWithDetails(ctx context.Context, options ImportOptions) (BrowserImportResult, error) { + if options.Browser == "" { + options.Browser = BrowserChrome + } if err := ensurePlatformImport(); err != nil { - return nil, err + return BrowserImportResult{}, err } proxyURL, err := validateProxy(options.Proxy) if err != nil { - return nil, err + return BrowserImportResult{}, err } - accounts, err := Discover(options.ChromeRoot) + root := options.ChromeRoot + if root == "" { + var err error + root, err = DefaultBrowserRoot(options.Browser) + if err != nil { + return BrowserImportResult{}, err + } + options.ChromeRoot = root + } + accounts, err := DiscoverBrowser(options.Browser, root) if err != nil { - return nil, err + return BrowserImportResult{}, err } selected, err := selectAccounts(accounts, options.Profiles, options.Emails, options.AccountIDs) if err != nil { - return nil, err + return BrowserImportResult{}, err } - masterKey, err := retrieveV20Key(options.ChromeRoot) + masterKey, err := retrieveMasterKey(options.Browser, options.ChromeRoot) if err != nil { - return nil, err + return BrowserImportResult{}, err } if len(masterKey) != 32 { - return nil, fmt.Errorf("Chrome v20 主密钥长度异常") + return BrowserImportResult{}, fmt.Errorf("%s 主密钥长度异常", options.Browser) } + results := make([]ImportResult, 0, len(selected)) + var failures []ImportFailure for _, account := range selected { - result, err := importAccount(ctx, options.ChromeRoot, proxyURL, account, masterKey) + targetLabel := account.Email + if targetLabel == "" { + targetLabel = account.Profile + } + result, err := importAccount(ctx, options.Browser, options.ChromeRoot, proxyURL, account, masterKey) if err != nil { - return nil, fmt.Errorf("导入 %s: %w", account.Profile, err) + failures = append(failures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue } results = append(results, result) } - return results, nil + + if len(results) == 0 && len(failures) > 0 { + return BrowserImportResult{Failures: failures}, fmt.Errorf("所有所选账号均导入失败: %s", failures[0].Error) + } + return BrowserImportResult{Results: results, Failures: failures}, nil } -// Refresh 以导入来源 Chrome profile 的身份使用保存的 OAuth 续签材料重新签发 Google Cookie,来源未记录目录时使用稳定版 Chrome 目录 +// Refresh 使用保存的 OAuth 续签材料重新签发 Google Cookie func Refresh(ctx context.Context, source aistudio.AuthSource, material aistudio.ChromeOAuthMaterial, proxy string) ([]aistudio.StateCookie, error) { if err := ensurePlatformImport(); err != nil { return nil, err @@ -91,7 +194,13 @@ func Refresh(ctx context.Context, source aistudio.AuthSource, material aistudio. } root := source.Root if root == "" { - if root, err = DefaultChromeRoot(); err != nil { + browser := source.Browser + if browser == "" { + browser = BrowserChrome + } + var err error + root, err = DefaultBrowserRoot(browser) + if err != nil { return nil, err } } @@ -106,16 +215,38 @@ func Refresh(ctx context.Context, source aistudio.AuthSource, material aistudio. return toStorageCookies(cookies), nil } -func importAccount(ctx context.Context, chromeRoot string, proxy string, account Account, masterKey []byte) (ImportResult, error) { - gaiaID, encryptedToken, wrappedKey, err := readTokenService(chromeRoot, account.Profile, account.GaiaID) +func importAccount(ctx context.Context, browser string, root string, proxy string, account Account, masterKey []byte) (ImportResult, error) { + if browser == "" { + browser = BrowserChrome + } + email := strings.ToLower(strings.TrimSpace(account.Email)) + + if account.SourceType == "cookie" { + cookies, err := readChromiumCookies(browser, root, account.Profile, masterKey) + if err != nil { + return ImportResult{}, err + } + state := aistudio.StorageState{Cookies: cookies, Origins: []aistudio.StorageOrigin{}} + state.SetAuthExtension(aistudio.AuthExtension{ + Source: aistudio.AuthSource{ + Browser: browser, Root: root, Profile: account.Profile, Email: email, + }, + }) + return ImportResult{ + ID: account.ID, Browser: browser, Profile: account.Profile, DisplayName: account.DisplayName, + Email: email, Locale: account.Locale, CookieCount: len(cookies), State: state, + }, nil + } + + gaiaID, encryptedToken, wrappedKey, err := readTokenService(root, account.Profile, account.GaiaID) if err != nil { return ImportResult{}, err } - token, err := decryptV20Token(masterKey, encryptedToken) + token, err := decryptToken(root, masterKey, encryptedToken) if err != nil { return ImportResult{}, err } - identity, err := readChromeIdentity(chromeRoot, account.Profile) + identity, err := readChromeIdentity(root, account.Profile) if err != nil { return ImportResult{}, err } @@ -125,24 +256,59 @@ func importAccount(ctx context.Context, chromeRoot string, proxy string, account } state := aistudio.StorageState{Cookies: toStorageCookies(cookies), Origins: []aistudio.StorageOrigin{}} state.SetAuthExtension(aistudio.AuthExtension{ - Source: aistudio.AuthSource{Browser: "chrome", Root: chromeRoot, Profile: account.Profile, Email: strings.ToLower(strings.TrimSpace(account.Email))}, + Source: aistudio.AuthSource{Browser: browser, Root: root, Profile: account.Profile, Email: email}, OAuth: &aistudio.ChromeOAuthMaterial{ GaiaID: gaiaID, RefreshToken: token, WrappedBindingKey: append([]byte(nil), wrappedKey...), }, }) return ImportResult{ - Profile: account.Profile, DisplayName: account.DisplayName, - Email: strings.ToLower(strings.TrimSpace(account.Email)), Locale: account.Locale, + ID: account.ID, Browser: browser, Profile: account.Profile, DisplayName: account.DisplayName, + Email: email, Locale: account.Locale, CookieCount: len(cookies), State: state, }, nil } +func decryptToken(root string, masterKey []byte, encrypted []byte) (string, error) { + if len(encrypted) < 3+12+16 { + return "", fmt.Errorf("refresh token 密文长度不足") + } + ver := string(encrypted[:3]) + key := masterKey + if ver == "v10" && root != "" { + if dpapi, err := decryptDPAPIMasterKey(root); err == nil && len(dpapi) == 32 { + key = dpapi + } + } else if ver != "v20" && ver != "v10" { + return "", fmt.Errorf("不支持的 refresh token 密文版本 %s", ver) + } + if len(key) != 32 { + return "", fmt.Errorf("缺少 %s 解密密钥", ver) + } + block, err := aes.NewCipher(key) + if err != nil { + return "", fmt.Errorf("创建 AES 解密器: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("创建 GCM 解密器: %w", err) + } + plaintext, err := gcm.Open(nil, encrypted[3:15], encrypted[15:], nil) + if err != nil { + return "", fmt.Errorf("refresh token 解密失败") + } + token := string(plaintext) + if len(token) != 103 || !strings.HasPrefix(token, "1//0") { + return "", fmt.Errorf("refresh token 解密结果格式异常") + } + return token, nil +} + func selectAccounts(accounts []Account, profiles []string, emails []string, ids []string) ([]Account, error) { requestedIDs := normalizedSet(ids) requestedProfiles := normalizedSet(profiles) requestedEmails := normalizedSet(emails) if len(requestedProfiles) == 0 && len(requestedEmails) == 0 && len(requestedIDs) == 0 { - return nil, fmt.Errorf("未选择 Chrome 账号") + return nil, fmt.Errorf("未选择账号") } selected := make([]Account, 0, len(accounts)) foundIDs := make(map[string]struct{}) @@ -151,34 +317,43 @@ func selectAccounts(accounts []Account, profiles []string, emails []string, ids for _, account := range accounts { profile := strings.ToLower(strings.TrimSpace(account.Profile)) email := strings.ToLower(strings.TrimSpace(account.Email)) + id := strings.ToLower(strings.TrimSpace(account.ID)) _, profileMatch := requestedProfiles[profile] _, emailMatch := requestedEmails[email] - _, idMatch := requestedIDs[strings.ToLower(account.ID)] + _, idMatch := requestedIDs[id] if !profileMatch && !emailMatch && !idMatch { continue } if !account.Importable { - return nil, fmt.Errorf("%s 缺少可导入的 OAuth 认证材料", account.Profile) + if account.Reason == ReasonBrowserRunning { + b := account.Browser + if b == "" { + b = "浏览器" + } + return nil, fmt.Errorf("%s 正在运行中并锁定了数据库,请先关闭 %s 浏览器后再重试导入", b, b) + } + return nil, fmt.Errorf("%s %s 缺少可导入的认证材料", account.Profile, account.Email) } - if !strings.Contains(email, "@") { + if account.SourceType != "cookie" && !strings.Contains(email, "@") { return nil, fmt.Errorf("%s 缺少账号邮箱", account.Profile) } - foundIDs[strings.ToLower(account.ID)] = struct{}{} + if id != "" { + foundIDs[id] = struct{}{} + } foundProfiles[profile] = struct{}{} - if _, exists := foundEmails[email]; exists { - continue + if email != "" { + foundEmails[email] = struct{}{} } selected = append(selected, account) - foundEmails[email] = struct{}{} } - if missing := missingValues(requestedIDs, foundIDs); len(missing) != 0 { - return nil, fmt.Errorf("找不到选中的 Chrome 账号") + if len(requestedIDs) > 0 && len(foundIDs) == 0 { + return nil, fmt.Errorf("找不到选中的账号") } - if missing := missingValues(requestedProfiles, foundProfiles); len(missing) != 0 { - return nil, fmt.Errorf("找不到 Chrome Profile: %s", strings.Join(missing, ", ")) + if len(requestedProfiles) > 0 && len(foundProfiles) == 0 { + return nil, fmt.Errorf("找不到选中的 Profile") } - if missing := missingValues(requestedEmails, foundEmails); len(missing) != 0 { - return nil, fmt.Errorf("找不到 Chrome 账号: %s", strings.Join(missing, ", ")) + if len(requestedEmails) > 0 && len(foundEmails) == 0 { + return nil, fmt.Errorf("找不到选中的账号") } return selected, nil } diff --git a/internal/chromeauth/copy.go b/internal/chromeauth/copy.go new file mode 100644 index 00000000..f67fd645 --- /dev/null +++ b/internal/chromeauth/copy.go @@ -0,0 +1,24 @@ +package chromeauth + +import ( + "io" + "os" +) + +// copyFile 把 src 复制为 dst +func copyFile(src, dst string) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + + out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return err + } + defer out.Close() + + _, err = io.Copy(out, in) + return err +} diff --git a/internal/chromeauth/gaia_proto.go b/internal/chromeauth/gaia_proto.go new file mode 100644 index 00000000..d0eeb8fd --- /dev/null +++ b/internal/chromeauth/gaia_proto.go @@ -0,0 +1,103 @@ +package chromeauth + +import "fmt" + +type gaiaProtoAccount struct { + Email string + DisplayName string + GaiaID string + AuthUser int +} + +func parseVarint(data []byte, offset int) (uint64, int, error) { + var res uint64 + var shift uint + for i := offset; i < len(data); i++ { + b := data[i] + res |= uint64(b&0x7f) << shift + shift += 7 + if b&0x80 == 0 { + return res, i + 1, nil + } + if shift >= 64 { + return 0, 0, fmt.Errorf("varint overflow") + } + } + return 0, 0, fmt.Errorf("unexpected EOF in varint") +} + +func parseGaiaAccounts(data []byte) []gaiaProtoAccount { + var accounts []gaiaProtoAccount + offset := 0 + defaultIdx := 0 + + for offset < len(data) { + key, nextOffset, err := parseVarint(data, offset) + if err != nil { + break + } + wireType := key & 0x07 + if wireType != 2 { + break + } + length, nextOffset, err := parseVarint(data, nextOffset) + if err != nil { + break + } + end := nextOffset + int(length) + if end > len(data) { + break + } + subData := data[nextOffset:end] + offset = end + + subOffset := 0 + var acc gaiaProtoAccount + acc.AuthUser = defaultIdx + for subOffset < len(subData) { + subKey, nextSub, err := parseVarint(subData, subOffset) + if err != nil { + break + } + fieldNum := subKey >> 3 + subWire := subKey & 0x07 + switch subWire { + case 0: // varint + val, nextSub2, err := parseVarint(subData, nextSub) + if err != nil { + break + } + subOffset = nextSub2 + if fieldNum == 7 { + acc.AuthUser = int(val) + } + case 2: // length-delimited string + l, nextSub2, err := parseVarint(subData, nextSub) + if err != nil { + break + } + vEnd := nextSub2 + int(l) + if vEnd > len(subData) { + break + } + sVal := string(subData[nextSub2:vEnd]) + subOffset = vEnd + switch fieldNum { + case 2: + acc.DisplayName = sVal + case 3: + acc.Email = sVal + case 10: + acc.GaiaID = sVal + } + default: + subOffset = len(subData) + } + } + if acc.Email != "" { + accounts = append(accounts, acc) + defaultIdx++ + } + } + return accounts +} diff --git a/internal/chromeauth/locked_file_windows.go b/internal/chromeauth/locked_file_windows.go new file mode 100644 index 00000000..83e8e9ac --- /dev/null +++ b/internal/chromeauth/locked_file_windows.go @@ -0,0 +1,198 @@ +//go:build windows + +package chromeauth + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "syscall" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + procGetFinalPathNameByHandle = kernel32.NewProc("GetFinalPathNameByHandleW") + procGetFileType = kernel32.NewProc("GetFileType") + procSetFilePointerEx = kernel32.NewProc("SetFilePointerEx") + procGetFileSizeEx = kernel32.NewProc("GetFileSizeEx") + errBrowserRunning = errors.New("browser is running and locking database") +) + +func isSharingViolation(err error) bool { + if err == nil { + return false + } + var errno syscall.Errno + if errors.As(err, &errno) && (errno == 32 || errno == windows.ERROR_SHARING_VIOLATION || errno == windows.ERROR_LOCK_VIOLATION) { + return true + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "used by another process") || strings.Contains(msg, "sharing violation") || strings.Contains(msg, "lock violation") +} + +// browserExeName 返回浏览器对应的进程可执行文件名 +func browserExeName(browser string) string { + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserBrave: + return "brave.exe" + case BrowserEdge: + return "msedge.exe" + case BrowserChrome: + return "chrome.exe" + default: + return "" + } +} + +// findProcessesByName 查找指定名称的所有进程 PID +func findProcessesByName(exeName string) ([]uint32, error) { + snap, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPPROCESS, 0) + if err != nil { + return nil, err + } + defer windows.CloseHandle(snap) + + var entry windows.ProcessEntry32 + entry.Size = uint32(unsafe.Sizeof(entry)) + if err := windows.Process32First(snap, &entry); err != nil { + return nil, err + } + + var pids []uint32 + exeLower := strings.ToLower(strings.TrimSpace(exeName)) + for { + name := windows.UTF16ToString(entry.ExeFile[:]) + if strings.ToLower(name) == exeLower { + pids = append(pids, entry.ProcessID) + } + if err := windows.Process32Next(snap, &entry); err != nil { + break + } + } + return pids, nil +} + +func getFinalPath(h windows.Handle) (string, error) { + buf := make([]uint16, 1024) + ret, _, err := procGetFinalPathNameByHandle.Call( + uintptr(h), + uintptr(unsafe.Pointer(&buf[0])), + uintptr(len(buf)), + 0, + ) + if ret == 0 { + return "", err + } + return windows.UTF16ToString(buf[:ret]), nil +} + +// readLockedFileByHandleDuplication 在目标进程中扫描打开了 targetPath 的 Handle,并通过 DuplicateHandle 突破文件锁定读取数据 +func readLockedFileByHandleDuplication(pid uint32, targetPath string) ([]byte, error) { + hProc, err := windows.OpenProcess(windows.PROCESS_DUP_HANDLE|windows.PROCESS_QUERY_INFORMATION, false, pid) + if err != nil { + return nil, fmt.Errorf("OpenProcess(%d): %w", pid, err) + } + defer windows.CloseHandle(hProc) + + currentProc := windows.CurrentProcess() + targetPathLower := strings.ToLower(filepath.Clean(targetPath)) + + for h := uintptr(4); h < 0xFFFF; h += 4 { + var dupHandle windows.Handle + err := windows.DuplicateHandle( + hProc, + windows.Handle(h), + currentProc, + &dupHandle, + windows.GENERIC_READ, + false, + 0, + ) + if err != nil { + err = windows.DuplicateHandle( + hProc, + windows.Handle(h), + currentProc, + &dupHandle, + 0, + false, + windows.DUPLICATE_SAME_ACCESS, + ) + if err != nil { + continue + } + } + + ft, _, _ := procGetFileType.Call(uintptr(dupHandle)) + if ft != 1 { // 1 = FILE_TYPE_DISK + windows.CloseHandle(dupHandle) + continue + } + + path, err := getFinalPath(dupHandle) + if err != nil { + windows.CloseHandle(dupHandle) + continue + } + + normPath := strings.ToLower(strings.TrimPrefix(path, `\\?\`)) + if strings.HasSuffix(normPath, targetPathLower) || normPath == targetPathLower { + var size int64 + ret, _, err := procGetFileSizeEx.Call(uintptr(dupHandle), uintptr(unsafe.Pointer(&size))) + if ret == 0 { + windows.CloseHandle(dupHandle) + return nil, fmt.Errorf("GetFileSizeEx: %w", err) + } + data := make([]byte, size) + var bytesRead uint32 + + var newPos int64 + procSetFilePointerEx.Call(uintptr(dupHandle), 0, uintptr(unsafe.Pointer(&newPos)), 0) + + err = windows.ReadFile(dupHandle, data, &bytesRead, nil) + windows.CloseHandle(dupHandle) + if err != nil { + return nil, fmt.Errorf("ReadFile via duplicated handle: %w", err) + } + return data[:bytesRead], nil + } + windows.CloseHandle(dupHandle) + } + return nil, fmt.Errorf("handle not found in process %d", pid) +} + +// copyLockedFile 尝试复制文件;若发生 Sharing Violation 锁定,自动通过 Handle Duplication 突破锁定提取数据 +func copyLockedFile(src string, dst string, exeNames ...string) error { + err := copyFile(src, dst) + if err == nil { + return nil + } + if !isSharingViolation(err) { + return err + } + + if len(exeNames) == 0 || (len(exeNames) == 1 && exeNames[0] == "") { + exeNames = []string{"brave.exe", "chrome.exe", "msedge.exe"} + } + + for _, exe := range exeNames { + if exe == "" { + continue + } + pids, pErr := findProcessesByName(exe) + if pErr != nil || len(pids) == 0 { + continue + } + for _, pid := range pids { + data, dErr := readLockedFileByHandleDuplication(pid, src) + if dErr == nil && len(data) > 0 { + return os.WriteFile(dst, data, 0o600) + } + } + } + return err +} diff --git a/internal/chromeauth/platform_other.go b/internal/chromeauth/platform_other.go index aeec9b15..257ad0f4 100644 --- a/internal/chromeauth/platform_other.go +++ b/internal/chromeauth/platform_other.go @@ -2,22 +2,44 @@ package chromeauth -import "fmt" +import ( + "fmt" -// DefaultChromeRoot 返回稳定版 Chrome User Data 目录 -func DefaultChromeRoot() (string, error) { - return "", fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +func defaultChromeRoot() (string, error) { + return defaultBrowserRoot(BrowserChrome) +} + +func defaultBrowserRoot(browser string) (string, error) { + return "", fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } func ensurePlatformImport() error { - return fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +func discoverPlatform(string) ([]Account, error) { + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +func discoverPlatformBrowser(browser string, root string) ([]Account, error) { + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +func retrieveMasterKey(string, string) ([]byte, error) { + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +func decryptDPAPIMasterKey(string) ([]byte, error) { + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } -// Discover 只读列出本机 Chrome Google 账号 -func Discover(string) ([]Account, error) { - return nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") +func readChromiumCookies(string, string, string, []byte) ([]aistudio.StateCookie, error) { + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } func readTokenService(string, string, string) (string, []byte, []byte, error) { - return "", nil, nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return "", nil, nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index 0861af92..38584e3b 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -3,10 +3,13 @@ package chromeauth import ( + "crypto/aes" + "crypto/cipher" "database/sql" + "encoding/base64" "encoding/json" + "errors" "fmt" - "io" "net/url" "os" "path/filepath" @@ -14,28 +17,285 @@ import ( "strconv" "strings" "unicode" + "unsafe" + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" + "golang.org/x/sys/windows" _ "github.com/mattn/go-sqlite3" ) -// DefaultChromeRoot 返回稳定版 Chrome User Data 目录 -func DefaultChromeRoot() (string, error) { +type dataBlob struct { + cbData uint32 + pbData *byte +} + +var ( + procCryptUnprotect = windows.NewLazySystemDLL("crypt32.dll").NewProc("CryptUnprotectData") + procLocalFree = kernel32.NewProc("LocalFree") +) + +func defaultChromeRoot() (string, error) { + return defaultBrowserRoot(BrowserChrome) +} + +func defaultBrowserRoot(browser string) (string, error) { localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) if localAppData == "" { return "", fmt.Errorf("环境变量 LOCALAPPDATA 为空") } - return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserChrome, "": + return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil + case BrowserBrave: + return filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data"), nil + case BrowserEdge: + return filepath.Join(localAppData, "Microsoft", "Edge", "User Data"), nil + default: + return "", fmt.Errorf("不支持的浏览器: %s", browser) + } } func ensurePlatformImport() error { return nil } -// Discover 只读列出本机 Chrome Google 账号 -func Discover(chromeRoot string) ([]Account, error) { - data, err := os.ReadFile(filepath.Join(chromeRoot, "Local State")) +func decryptDPAPIMasterKey(root string) ([]byte, error) { + data, err := os.ReadFile(filepath.Join(root, "Local State")) + if err != nil { + return nil, fmt.Errorf("读取 Local State: %w", err) + } + var state struct { + OSCrypt struct { + EncryptedKey string `json:"encrypted_key"` + } `json:"os_crypt"` + } + if err := json.Unmarshal(data, &state); err != nil { + return nil, fmt.Errorf("解析 Local State: %w", err) + } + raw := strings.TrimSpace(state.OSCrypt.EncryptedKey) + if raw == "" { + return nil, fmt.Errorf("Local State 缺少 encrypted_key") + } + decoded, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("解析 encrypted_key: %w", err) + } + const dpapiPrefix = "DPAPI" + if len(decoded) <= len(dpapiPrefix) || string(decoded[:len(dpapiPrefix)]) != dpapiPrefix { + return nil, fmt.Errorf("encrypted_key 缺少 DPAPI 前缀") + } + cipherBytes := decoded[len(dpapiPrefix):] + var inB, outB dataBlob + inB.cbData = uint32(len(cipherBytes)) + inB.pbData = &cipherBytes[0] + r, _, callErr := procCryptUnprotect.Call( + uintptr(unsafe.Pointer(&inB)), 0, 0, 0, 0, 0, uintptr(unsafe.Pointer(&outB)), + ) + if r == 0 { + return nil, fmt.Errorf("CryptUnprotectData 失败: %w", callErr) + } + defer procLocalFree.Call(uintptr(unsafe.Pointer(outB.pbData))) + key := make([]byte, outB.cbData) + copy(key, unsafe.Slice(outB.pbData, outB.cbData)) + if len(key) != 32 { + return nil, fmt.Errorf("DPAPI 密钥长度异常 (%d)", len(key)) + } + return key, nil +} + +func retrieveMasterKey(browser string, root string) ([]byte, error) { + v20Key, v20Err := retrieveV20KeyForBrowser(browser, root) + if v20Err == nil && len(v20Key) == 32 { + return v20Key, nil + } + dpapiKey, dpapiErr := decryptDPAPIMasterKey(root) + if dpapiErr == nil && len(dpapiKey) == 32 { + return dpapiKey, nil + } + if v20Err != nil { + return nil, fmt.Errorf("解密 %s 主密钥失败 (ABE: %v, DPAPI: %v)", browser, v20Err, dpapiErr) + } + return nil, fmt.Errorf("解密 %s 主密钥失败: %w", browser, dpapiErr) +} + +func openChromiumCookies(browser string, root string, profile string) (*sql.DB, func(), error) { + tempDir, err := os.MkdirTemp("", "aistudio2api-cookies-*") + if err != nil { + return nil, nil, fmt.Errorf("创建 Cookie 临时目录: %w", err) + } + defer func() { + if err != nil { + os.RemoveAll(tempDir) + } + }() + cookiePath := filepath.Join(root, profile, "Network", "Cookies") + if _, statErr := os.Stat(cookiePath); statErr != nil { + cookiePath = filepath.Join(root, profile, "Cookies") + if _, statErr2 := os.Stat(cookiePath); statErr2 != nil { + return nil, nil, fmt.Errorf("未找到 Cookie 数据库: %w", statErr2) + } + } + copyPath := filepath.Join(tempDir, "Cookies") + exeName := browserExeName(browser) + if err = copyLockedFile(cookiePath, copyPath, exeName); err != nil { + if isSharingViolation(err) { + b := browser + if b == "" { + b = "浏览器" + } + return nil, nil, fmt.Errorf("%s 正在运行中并锁定了 Cookie 数据库,请先关闭 %s 浏览器后再重试导入", b, b) + } + return nil, nil, fmt.Errorf("复制 %s Cookies: %w", profile, err) + } + _ = copyLockedFile(cookiePath+"-wal", copyPath+"-wal", exeName) + uri := url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(copyPath), RawQuery: "mode=ro"} + database, err := sql.Open("sqlite3", uri.String()) + if err != nil { + return nil, nil, fmt.Errorf("打开 %s Cookies: %w", profile, err) + } + database.SetMaxOpenConns(1) + return database, func() { + database.Close() + os.RemoveAll(tempDir) + }, nil +} + +func checkChromiumCookies(browser string, root string, profile string) (hasCookies bool, isLocked bool, err error) { + database, closeDatabase, err := openChromiumCookies(browser, root, profile) + if err != nil { + if errors.Is(err, errBrowserRunning) || isSharingViolation(err) { + return false, true, nil + } + return false, false, err + } + defer closeDatabase() + + rows, err := database.Query("SELECT name FROM cookies WHERE host_key LIKE '%google.com'") + if err != nil { + return false, false, err + } + defer rows.Close() + + names := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err == nil { + names[name] = true + } + } + return names["SAPISID"] && (names["__Secure-1PAPISID"] || names["__Secure-3PAPISID"] || names["__Secure-1PSID"]), false, nil +} + +func readChromiumCookies(browser string, root string, profile string, masterKey []byte) ([]aistudio.StateCookie, error) { + dpapiKey, _ := decryptDPAPIMasterKey(root) + + database, closeDatabase, err := openChromiumCookies(browser, root, profile) + if err != nil { + return nil, err + } + defer closeDatabase() + + rows, err := database.Query("SELECT name, encrypted_value, host_key, path, expires_utc, is_secure, is_httponly, samesite FROM cookies WHERE host_key LIKE '%google.com'") + if err != nil { + return nil, fmt.Errorf("读取 %s cookies: %w", profile, err) + } + defer rows.Close() + + var cookies []aistudio.StateCookie + for rows.Next() { + var name, hostKey, path string + var encrypted []byte + var expiresUtc int64 + var isSecure, isHttpOnly, samesite int + if err := rows.Scan(&name, &encrypted, &hostKey, &path, &expiresUtc, &isSecure, &isHttpOnly, &samesite); err != nil { + return nil, fmt.Errorf("解析 %s cookies: %w", profile, err) + } + if name == "" || len(encrypted) == 0 { + continue + } + + val := "" + if len(encrypted) >= 3+12+16 { + ver := string(encrypted[:3]) + var key []byte + if ver == "v20" { + key = masterKey + } else if ver == "v10" { + if len(dpapiKey) == 32 { + key = dpapiKey + } else { + key = masterKey + } + } + if len(key) == 32 { + block, err := aes.NewCipher(key) + if err == nil { + gcm, err := cipher.NewGCM(block) + if err == nil { + plain, err := gcm.Open(nil, encrypted[3:15], encrypted[15:], nil) + if err == nil { + if ver == "v20" && len(plain) > 32 && !isPrintableASCII(plain[:32]) { + plain = plain[32:] + } + val = string(plain) + } + } + } + } + } + if val == "" { + continue + } + + expiresUnix := float64(0) + if expiresUtc > 11644473600000000 { + expiresUnix = float64((expiresUtc - 11644473600000000) / 1000000) + } + sameSiteStr := "" + switch samesite { + case 1: + sameSiteStr = "Lax" + case 2: + sameSiteStr = "Strict" + } + cookies = append(cookies, aistudio.StateCookie{ + Name: name, + Value: val, + Domain: hostKey, + Path: path, + Expires: expiresUnix, + Secure: isSecure != 0, + HTTPOnly: isHttpOnly != 0, + SameSite: sameSiteStr, + }) + } + names := make(map[string]bool) + for _, c := range cookies { + names[c.Name] = true + } + if !names["SAPISID"] { + return nil, fmt.Errorf("%s 缺少核心 Google Cookie (SAPISID)", profile) + } + return cookies, nil +} + +func isPrintableASCII(data []byte) bool { + for _, b := range data { + if b < 32 || b > 126 { + return false + } + } + return true +} + +func discoverPlatform(chromeRoot string) ([]Account, error) { + return discoverPlatformBrowser(BrowserChrome, chromeRoot) +} + +func discoverPlatformBrowser(browser string, root string) ([]Account, error) { + data, err := os.ReadFile(filepath.Join(root, "Local State")) if err != nil { - return nil, fmt.Errorf("读取 Chrome Local State: %w", err) + return nil, fmt.Errorf("读取 %s Local State: %w", browser, err) } var state struct { Variations struct { @@ -49,10 +309,10 @@ func Discover(chromeRoot string) ([]Account, error) { } `json:"profile"` } if err := json.Unmarshal(data, &state); err != nil { - return nil, fmt.Errorf("解析 Chrome Local State: %w", err) + return nil, fmt.Errorf("解析 %s Local State: %w", browser, err) } if state.Profile.InfoCache == nil { - return nil, fmt.Errorf("Chrome Local State 缺少 profile.info_cache") + return nil, fmt.Errorf("%s Local State 缺少 profile.info_cache", browser) } profiles := make([]string, 0, len(state.Profile.InfoCache)) @@ -65,39 +325,77 @@ func Discover(chromeRoot string) ([]Account, error) { accounts := make([]Account, 0, len(profiles)) for _, profile := range profiles { info := state.Profile.InfoCache[profile] - locale := profileLocale(chromeRoot, profile) + locale := profileLocale(root, profile) if locale == "" { locale = state.Variations.SafeSeedLocale } - identities, err := profileIdentities(chromeRoot, profile) - if err != nil { - accounts = append(accounts, Account{ - ID: profile, Profile: profile, DisplayName: info.Name, Email: info.UserName, Locale: locale, - }) - continue + identities, err := profileIdentities(root, profile) + if err == nil && len(identities) > 0 { + hasCoreCookies, isLocked, _ := checkChromiumCookies(browser, root, profile) + seen := map[string]bool{} + for _, identity := range identities { + if identity.Gaia == "" || seen[identity.Gaia] { + continue + } + seen[identity.Gaia] = true + _, encrypted, _, tokenErr := readTokenService(root, profile, identity.Gaia) + displayName := identity.DisplayName + if displayName == "" { + displayName = info.Name + } + acc := Account{ + ID: profile + "/" + identity.Gaia, + Browser: browser, + GaiaID: identity.Gaia, + Profile: profile, + DisplayName: displayName, + Email: identity.Email, + Locale: locale, + AuthUser: identity.AuthUser, + } + if tokenErr == nil && (strings.HasPrefix(string(encrypted), "v20") || strings.HasPrefix(string(encrypted), "v10")) { + acc.Importable = true + acc.SourceType = "oauth" + } else if isLocked { + acc.Reason = ReasonBrowserRunning + acc.SourceType = "cookie" + } else if hasCoreCookies { + acc.Importable = true + acc.SourceType = "cookie" + } + accounts = append(accounts, acc) + } } - records, recordsErr := readTokenServices(chromeRoot, profile) - seen := map[string]bool{} - for _, identity := range identities { - if identity.Gaia == "" || seen[identity.Gaia] { - continue + + if len(identities) == 0 { + hasCoreCookies, isLocked, _ := checkChromiumCookies(browser, root, profile) + acc := Account{ + ID: profile, + Browser: browser, + Profile: profile, + DisplayName: info.Name, + Email: info.UserName, + Locale: locale, + } + if isLocked { + acc.Reason = ReasonBrowserRunning + acc.SourceType = "cookie" + } else if hasCoreCookies { + acc.Importable = true + acc.SourceType = "cookie" } - seen[identity.Gaia] = true - record, found := records[identity.Gaia] - accounts = append(accounts, Account{ - ID: profile + "/" + identity.Gaia, GaiaID: identity.Gaia, - Profile: profile, DisplayName: info.Name, Email: identity.Email, Locale: locale, - Importable: recordsErr == nil && found && strings.HasPrefix(string(record.encryptedToken), "v20"), - }) + accounts = append(accounts, acc) } } return accounts, nil } -// profileIdentity 为 Preferences.account_info 中的一个 Google 账号 +// profileIdentity 为 Preferences 中的一个 Google 账号 type profileIdentity struct { - Gaia string `json:"gaia"` - Email string `json:"email"` + Gaia string `json:"gaia"` + Email string `json:"email"` + DisplayName string `json:"name,omitempty"` + AuthUser int `json:"authuser,omitempty"` } // profileIdentities 读取 Profile 内全部 Google 账号的 Gaia ID 与邮箱 @@ -107,37 +405,60 @@ func profileIdentities(chromeRoot string, profile string) ([]profileIdentity, er return nil, fmt.Errorf("读取 %s Preferences: %w", profile, err) } var preferences struct { - Accounts []profileIdentity `json:"account_info"` + Accounts []profileIdentity `json:"account_info"` + GaiaCookie struct { + LastListAccountsBinary string `json:"last_list_accounts_binary_data"` + } `json:"gaia_cookie"` } if err := json.Unmarshal(data, &preferences); err != nil { return nil, fmt.Errorf("解析 %s Preferences: %w", profile, err) } - return preferences.Accounts, nil + if len(preferences.Accounts) > 0 { + return preferences.Accounts, nil + } + if preferences.GaiaCookie.LastListAccountsBinary != "" { + if raw, err := base64.StdEncoding.DecodeString(preferences.GaiaCookie.LastListAccountsBinary); err == nil { + parsed := parseGaiaAccounts(raw) + if len(parsed) > 0 { + identities := make([]profileIdentity, len(parsed)) + for i, p := range parsed { + identities[i] = profileIdentity{ + Gaia: p.GaiaID, + Email: p.Email, + DisplayName: p.DisplayName, + AuthUser: p.AuthUser, + } + } + return identities, nil + } + } + } + return nil, nil } func profileLocale(chromeRoot string, profile string) string { - locale, _, _ := strings.Cut(profileLanguages(chromeRoot, profile), ",") - return strings.TrimSpace(locale) -} - -// copyFile 把 src 复制为 dst -func copyFile(src string, dst string) error { - in, err := os.Open(src) + data, err := os.ReadFile(filepath.Join(chromeRoot, profile, "Preferences")) if err != nil { - return err + return "" } - defer in.Close() - out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) - if err != nil { - return err + var preferences struct { + Intl struct { + AcceptLanguages string `json:"accept_languages"` + SelectedLanguages string `json:"selected_languages"` + } `json:"intl"` } - defer out.Close() - _, err = io.Copy(out, in) - return err + if json.Unmarshal(data, &preferences) != nil { + return "" + } + for _, value := range []string{preferences.Intl.AcceptLanguages, preferences.Intl.SelectedLanguages} { + if locale := strings.TrimSpace(strings.Split(value, ",")[0]); locale != "" { + return locale + } + } + return "" } -// openWebData 把 Profile 的 Web Data 与其 WAL 日志复制到临时目录后只读打开 -func openWebData(chromeRoot string, profile string) (database *sql.DB, closeDatabase func(), err error) { +func openWebData(chromeRoot string, profile string) (*sql.DB, func(), error) { tempDir, err := os.MkdirTemp("", "aistudio2api-webdata-*") if err != nil { return nil, nil, fmt.Errorf("创建 Web Data 临时目录: %w", err) @@ -149,14 +470,14 @@ func openWebData(chromeRoot string, profile string) (database *sql.DB, closeData }() databasePath := filepath.Join(chromeRoot, profile, "Web Data") copyPath := filepath.Join(tempDir, "Web Data") - if err = copyFile(databasePath, copyPath); err != nil { + if err = copyLockedFile(databasePath, copyPath); err != nil { return nil, nil, fmt.Errorf("复制 %s Web Data: %w", profile, err) } - if err = copyFile(databasePath+"-wal", copyPath+"-wal"); err != nil && !os.IsNotExist(err) { - return nil, nil, fmt.Errorf("复制 %s Web Data-wal: %w", profile, err) - } + _ = copyLockedFile(databasePath+"-wal", copyPath+"-wal") + _ = copyLockedFile(databasePath+"-shm", copyPath+"-shm") uri := url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(copyPath), RawQuery: "mode=ro"} - if database, err = sql.Open("sqlite3", uri.String()); err != nil { + database, err := sql.Open("sqlite3", uri.String()) + if err != nil { return nil, nil, fmt.Errorf("打开 %s Web Data: %w", profile, err) } database.SetMaxOpenConns(1) @@ -166,53 +487,43 @@ func openWebData(chromeRoot string, profile string) (database *sql.DB, closeData }, nil } -// tokenRecord 是 token_service 中一个账号的加密 refresh token 与 wrapped binding key -type tokenRecord struct { - encryptedToken []byte - bindingKey []byte -} - -// readTokenServices 读取 Profile 中全部 Google 账号的 token_service 记录,键为 Gaia ID -func readTokenServices(chromeRoot string, profile string) (map[string]tokenRecord, error) { - database, closeDatabase, err := openWebData(chromeRoot, profile) +func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { + database, closeDb, err := openWebData(chromeRoot, profile) if err != nil { - return nil, err + return "", nil, nil, err } - defer closeDatabase() - rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service LIKE 'AccountId-%'") + defer closeDb() + + rows, err := database.Query("SELECT service, encrypted_token, binding_key FROM token_service WHERE service = ?", "AccountId-"+gaiaID) if err != nil { - return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } defer rows.Close() - records := map[string]tokenRecord{} - for rows.Next() { - var service string - var record tokenRecord - if err := rows.Scan(&service, &record.encryptedToken, &record.bindingKey); err != nil { - return nil, fmt.Errorf("解析 %s token_service: %w", profile, err) + if !rows.Next() { + if err := rows.Err(); err != nil { + return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } - records[strings.TrimPrefix(service, "AccountId-")] = record + return "", nil, nil, fmt.Errorf("%s 的 token_service 没有 %s 的记录", profile, gaiaID) } - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("读取 %s token_service: %w", profile, err) + var service string + var encryptedToken []byte + var bindingKey []byte + if err := rows.Scan(&service, &encryptedToken, &bindingKey); err != nil { + return "", nil, nil, fmt.Errorf("解析 %s token_service: %w", profile, err) } - return records, nil -} - -// readTokenService 返回一个账号的 Gaia ID、加密 refresh token 与 wrapped binding key -func readTokenService(chromeRoot string, profile string, gaiaID string) (string, []byte, []byte, error) { - records, err := readTokenServices(chromeRoot, profile) - if err != nil { - return "", nil, nil, err + if rows.Next() { + return "", nil, nil, fmt.Errorf("%s 的 token_service 记录数大于 1", profile) } - record, found := records[gaiaID] - if !found { - return "", nil, nil, fmt.Errorf("%s 的 token_service 没有 %s 的记录", profile, gaiaID) + if err := rows.Err(); err != nil { + return "", nil, nil, fmt.Errorf("读取 %s token_service: %w", profile, err) } - if len(record.encryptedToken) == 0 { + if !strings.HasPrefix(service, "AccountId-") { + return "", nil, nil, fmt.Errorf("%s 的 token_service service 格式异常", profile) + } + if len(encryptedToken) == 0 { return "", nil, nil, fmt.Errorf("%s 的 token_service 缺少认证材料", profile) } - return gaiaID, record.encryptedToken, record.bindingKey, nil + return strings.TrimPrefix(service, "AccountId-"), encryptedToken, bindingKey, nil } func naturalLess(left string, right string) bool { @@ -244,10 +555,11 @@ func splitNatural(value string) []string { lastDigit := unicode.IsDigit(rune(value[0])) for index, character := range value { isDigit := unicode.IsDigit(character) - if index != 0 && isDigit != lastDigit { - parts = append(parts, value[start:index]) - start = index + if isDigit == lastDigit { + continue } + parts = append(parts, value[start:index]) + start = index lastDigit = isDigit } return append(parts, value[start:]) diff --git a/web/src/ConsoleRoot.vue b/web/src/ConsoleRoot.vue index 63ef8869..ba9e6125 100644 --- a/web/src/ConsoleRoot.vue +++ b/web/src/ConsoleRoot.vue @@ -47,7 +47,9 @@ function setSession(value: AdminSession): void { // expireSession 卸载控制台及其事件订阅 function expireSession(): void { - setSession({ enabled: true, authenticated: false, username: '' }) + if (session.value?.enabled) { + setSession({ enabled: true, authenticated: false, username: '' }) + } } // loadSession 在加载管理数据前确认会话 diff --git a/web/src/api.ts b/web/src/api.ts index 11e19af6..09e41423 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -6,11 +6,14 @@ import type { AccountLoginInput, AccountRefresh, AdminEvent, + BrowserImportInput, + BrowserImportProfile, ChromeImportInput, + ChromeImportProfile, CookieBrowser, CookieImportInput, FirstEventLatency, - ChromeImportProfile, + ImportBrowserResponse, PairingToken, Model, PromptInput, @@ -60,7 +63,15 @@ export interface AdminSession { // checkSessionResponse 通知页面清理失效会话中的管理数据 function checkSessionResponse(path: string, response: Response): void { if (response.status === 401 && !path.startsWith('/api/auth/')) { - window.dispatchEvent(new Event('admin-session-expired')) + response + .clone() + .json() + .then((data: { error?: { code?: string } }) => { + if (data?.error?.code === 'admin_login_required') { + window.dispatchEvent(new Event('admin-session-expired')) + } + }) + .catch(() => {}) } } @@ -159,6 +170,17 @@ export const api = { method: 'POST', body: jsonBody(input), }), + browserImportProfiles: async (browser = 'chrome') => + ( + await requestJSON<{ browser?: string; profiles: BrowserImportProfile[] }>( + `/api/accounts/import/browser?browser=${encodeURIComponent(browser)}`, + ) + ).profiles, + importBrowserAccounts: (input: BrowserImportInput) => + requestJSON('/api/accounts/import/browser', { + method: 'POST', + body: jsonBody(input), + }), cookieBrowsers: async () => (await requestJSON<{ browsers: CookieBrowser[] }>('/api/accounts/import/browsers')).browsers, discoverCookieAccounts: async (input: CookieImportInput) => diff --git a/web/src/components/AccountsPanel.vue b/web/src/components/AccountsPanel.vue index 91ad5269..a16fbf2f 100644 --- a/web/src/components/AccountsPanel.vue +++ b/web/src/components/AccountsPanel.vue @@ -16,6 +16,7 @@ import type { AccountDraft, AccountRefresh, AccountState, + BrowserType, ChromeImportProfile, CookieBrowser, PairingToken, @@ -83,17 +84,21 @@ const pairingInsecure = !/^127\.\d+\.\d+\.\d+$/.test(pairingHost) // clipboardAvailable 表示页面处于可使用剪贴板 API 的安全上下文 const clipboardAvailable = window.isSecureContext +const selectedBrowser = ref('chrome') const selectedChromeProfiles = ref([]) +const importableChromeProfiles = computed(() => + chromeProfiles.value.filter((profile) => !profile.imported).map((profile) => profile.id), +) const allChromeProfilesSelected = computed( () => - chromeProfiles.value.length > 0 && - selectedChromeProfiles.value.length === chromeProfiles.value.length, + importableChromeProfiles.value.length > 0 && + selectedChromeProfiles.value.length === importableChromeProfiles.value.length, ) function toggleAllChromeProfiles(): void { selectedChromeProfiles.value = allChromeProfilesSelected.value ? [] - : chromeProfiles.value.map((profile) => profile.id) + : [...importableChromeProfiles.value] } const accountEnvironment = reactive({ proxy: '', @@ -274,11 +279,38 @@ async function copyPairing(kind: 'command' | 'token', text: string): Promise { + selectedBrowser.value = browser + selectedChromeProfiles.value = [] + pendingAction.value = 'chrome-discover' + try { + chromeProfiles.value = await api.browserImportProfiles(browser) + } catch (error) { + actionError(error) + } finally { + pendingAction.value = '' + } +} + +// openChromeImport 读取本机可导入的浏览器账户 async function openChromeImport(): Promise { pendingAction.value = 'chrome-discover' try { - chromeProfiles.value = await api.chromeImportProfiles() + let currentBrowser = selectedBrowser.value + let profiles = await api.browserImportProfiles(currentBrowser).catch(() => []) + if (profiles.length === 0 && currentBrowser === 'chrome') { + const candidates: BrowserType[] = ['brave', 'edge'] + for (const cand of candidates) { + const res = await api.browserImportProfiles(cand).catch(() => []) + if (res.length > 0) { + currentBrowser = cand + profiles = res + break + } + } + } + selectedBrowser.value = currentBrowser + chromeProfiles.value = profiles selectedChromeProfiles.value = [] showChromeImport.value = true } catch (error) { @@ -295,11 +327,12 @@ function closeChromeImport(): void { selectedChromeProfiles.value = [] } -// importChromeAccounts 导入用户选中的 Chrome 账户 +// importChromeAccounts 导入用户选中的浏览器账户 async function importChromeAccounts(): Promise { pendingAction.value = 'chrome-import' try { - const result = await api.importChromeAccounts({ + const result = await api.importBrowserAccounts({ + browser: selectedBrowser.value, account_ids: [...selectedChromeProfiles.value], ...accountEnvironment, }) @@ -307,13 +340,23 @@ async function importChromeAccounts(): Promise { chromeProfiles.value = [] selectedChromeProfiles.value = [] emit('refresh') - emit( - 'notice', - t( - result.accounts.length === 1 ? 'accounts.importCompleteOne' : 'accounts.importComplete', - ).replace('{count}', String(result.accounts.length)), - 'success', - ) + const count = result.accounts.length + if (result.failures && result.failures.length > 0) { + const failed = result.failures.map((f) => `${f.target}: ${f.error}`).join('; ') + emit( + 'notice', + `${t(count === 1 ? 'accounts.importCompleteOne' : 'accounts.importComplete').replace('{count}', String(count))} (${failed})`, + count > 0 ? 'success' : 'error', + ) + } else { + emit( + 'notice', + t( + count === 1 ? 'accounts.importCompleteOne' : 'accounts.importComplete', + ).replace('{count}', String(count)), + 'success', + ) + } } catch (error) { actionError(error) } finally { @@ -1067,6 +1110,19 @@ async function refreshAllAccounts(): Promise { +
+ +
@@ -1089,12 +1145,14 @@ async function refreshAllAccounts(): Promise { v-for="profile in chromeProfiles" :key="profile.id" class="flex cursor-pointer items-start gap-3 rounded border border-line bg-canvas p-3 transition hover:border-gray-600" + :class="{ 'opacity-60': profile.imported }" > { >{{ profile.profile }} + {{ + t('accounts.alreadyImported') + }}
diff --git a/web/src/types.ts b/web/src/types.ts index 53fddc73..097ccf4f 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -62,23 +62,43 @@ export interface AccountLoginInput { timezone: string } -export interface ChromeImportProfile { +export type BrowserType = 'chrome' | 'brave' | 'edge' + +export interface BrowserImportProfile { id: string + browser?: BrowserType profile: string display_name: string email: string locale: string + auth_user?: number + imported?: boolean } +export type ChromeImportProfile = BrowserImportProfile + export interface PairingToken { token: string expires_at: string } -export interface ChromeImportInput extends AccountLoginInput { +export interface BrowserImportInput extends AccountLoginInput { + browser?: BrowserType account_ids: string[] } +export type ChromeImportInput = BrowserImportInput + +export interface ImportFailure { + target: string + error: string +} + +export interface ImportBrowserResponse { + accounts: Account[] + failures?: ImportFailure[] +} + export interface CookieImportInput extends AccountLoginInput { label: string cookies: string