diff --git a/README.en.md b/README.en.md index a5847d7..f8b3080 100644 --- a/README.en.md +++ b/README.en.md @@ -59,7 +59,7 @@ Download the `windows-amd64.zip` package from [Releases](https://github.com/Mag1 - The console opens in a desktop window that uses the system WebView2 runtime; the app stays in the tray after the window closes, and the tray menu copies the API address and toggles launch at login - Double-clicking `gemini-web2api.exe` again brings back the running window -- Import accounts from local Chrome, cookies, or remote pairing on the console's Import account page +- Import accounts from local browsers (Chrome, Brave, Edge, Firefox), cookies, or remote pairing on the console's Import account page - `.env`, `auth/`, and `data/` all live in the folder containing `gemini-web2api.exe` To run commands from cmd or PowerShell, write them as `cmd /c start /wait gemini-web2api.exe ` so the prompt returns after the program exits: @@ -80,7 +80,7 @@ Download the desktop build for your system from [Releases](https://github.com/Ma - Every desktop build is portable: on macOS, extract it and open `Gemini-Web2API.app`; on Linux, extract it and run `gemini-web2api`, which needs the system GTK 4 and WebKitGTK 6.0 - The desktop app opens the console in a window and stays in the tray after the window closes; the tray menu copies the API address and toggles launch at login -- Import accounts from local Chrome, cookies, or remote pairing on the console's Import account page +- Import accounts from local browsers (Chrome, Brave, Edge, Firefox), cookies, or remote pairing on the console's Import account page - `.env`, `auth/`, and `data/` live in the data directory listed above ### Manual start @@ -93,18 +93,18 @@ pnpm -C web build go build -tags nomsgpack -o gemini-web2api.exe ./cmd/gemini-web2api ``` -On Windows, import accounts already signed into the local Chrome installation: +On Windows, import accounts already signed into a local browser: ```powershell .\gemini-web2api.exe setup ``` -`setup` scans local Chrome accounts and briefly starts an isolated temporary Chrome process to read the App-Bound master key. Existing Chrome windows and profiles remain untouched. +`setup` scans local browser accounts (specify `--browser` with `chrome`, `brave`, `edge`, or `firefox`; automatically checks other browsers if Chrome is not installed). Existing browser windows and profiles remain untouched. An account can also be selected directly. When a profile is signed in to several Google accounts, each account is listed and imported separately: ```powershell -.\gemini-web2api.exe setup --email "name@example.com" +.\gemini-web2api.exe setup --browser brave --email "name@example.com" ``` You can also paste the one-line value of a request's `Cookie` header. This state works while those cookies remain valid and cannot perform OAuth renewal: diff --git a/README.md b/README.md index 57d8b77..9c2a299 100644 --- a/README.md +++ b/README.md @@ -59,7 +59,7 @@ - 管理端在桌面窗口中打开,窗口使用系统的 WebView2 运行时;关闭窗口后留在托盘,托盘菜单可复制 API 地址、设置开机启动 - 再次双击 `gemini-web2api.exe` 唤回已运行的窗口 -- 账号在管理端的“导入账号”页从本机 Chrome、Cookie 或远程配对导入 +- 账号在管理端的“导入账号”页从本机浏览器(Chrome、Brave、Edge、Firefox)、Cookie 或远程配对导入 - `.env`、`auth/` 与 `data/` 都在 `gemini-web2api.exe` 所在目录 在 cmd 或 PowerShell 中运行命令时写作 `cmd /c start /wait gemini-web2api.exe <参数>`,命令行等程序结束后返回: @@ -80,7 +80,7 @@ cmd /c start /wait gemini-web2api.exe setup --email "name@example.com" - 桌面版都是便携包:macOS 解压后打开 `Gemini-Web2API.app`;Linux 解压后运行 `gemini-web2api`,需要系统的 GTK 4 与 WebKitGTK 6.0 - 桌面版在窗口中打开管理端,关闭窗口后留在托盘;托盘菜单可复制 API 地址、设置开机启动 -- 账号在管理端的“导入账号”页从本机 Chrome、Cookie 或远程配对导入 +- 账号在管理端的“导入账号”页从本机浏览器(Chrome、Brave、Edge、Firefox)、Cookie 或远程配对导入 - `.env`、`auth/` 与 `data/` 都在上表的数据目录中 ### 手动启动 @@ -99,12 +99,12 @@ Windows 首次使用时,直接运行交互式配置: .\gemini-web2api.exe setup ``` -`setup` 会扫描本机 Chrome 账号,并短暂启动独立临时 Chrome 进程读取 App-Bound 主密钥。现有 Chrome 窗口与 Profile 保持原状。 +`setup` 会扫描本机浏览器账号(支持通过 `--browser` 指定 `chrome`、`brave`、`edge`、`firefox`,未指定且 Chrome 未安装时自动检测其他浏览器)。现有浏览器窗口与 Profile 保持原状。 -也可以直接指定 Chrome 中已登录的账号;同一 Profile 登录了多个 Google 账号时,每个账号单独列出、单独导入: +也可以直接指定已登录的账号;同一 Profile 登录了多个 Google 账号时,每个账号单独列出、单独导入: ```powershell -.\gemini-web2api.exe setup --email "name@example.com" +.\gemini-web2api.exe setup --browser brave --email "name@example.com" ``` 也可以粘贴浏览器请求头中 `Cookie` 字段的一行值;这种状态在现有 Cookie 有效期内使用,不具备 OAuth 续签能力: diff --git a/internal/accounts/manager.go b/internal/accounts/manager.go index 7ab211f..e063c92 100644 --- a/internal/accounts/manager.go +++ b/internal/accounts/manager.go @@ -214,10 +214,13 @@ func (m *Manager) Add(account auth.LoadedAccount) error { func (m *Manager) launch(account auth.LoadedAccount, firstDone func()) { proxy, source := m.accountProxy(account) account.ProxyURL = proxy - info := balancer.AccountInfo{Source: "cookie", Profile: account.Profile, Proxy: proxy, ProxySource: source} - if account.OAuth != nil { - info.Source = "chrome" + accountSource := "cookie" + if account.Browser != "" { + accountSource = account.Browser + } else if account.OAuth != nil { + accountSource = "chrome" } + info := balancer.AccountInfo{Source: accountSource, Profile: account.Profile, Proxy: proxy, ProxySource: source} if account.File != nil { info.Path = account.File.Path() } @@ -237,6 +240,8 @@ func (m *Manager) launch(account auth.LoadedAccount, firstDone func()) { if account.OAuth != nil { account.Source.Refresh = newChromeRenewal(account).refresh + } else if account.Browser != "" && account.Profile != "" { + account.Source.Refresh = newBrowserRenewal(account).refresh } account.Source.Concurrency = settings.Concurrency client, err := gemini.NewClient(account.Source, account.ProxyURL, settings.SaveHistory) @@ -509,6 +514,11 @@ func (m *Manager) Verify(accountID string) error { return fmt.Errorf("账号 %q 已停用", accountID) } logging.Info(accountID, "账号重新验证") + if entry.account.OAuth == nil && entry.account.Browser != "" && entry.account.Profile != "" { + if cookies, err := newBrowserRenewal(entry.account).refresh(m.ctx); err == nil && len(cookies) > 0 { + entry.account.Source.Cookies = cookies + } + } if err := m.restart(entry.account); err != nil { return err } @@ -587,54 +597,131 @@ func (m *Manager) IDs() []string { return append([]string(nil), m.order...) } -// ChromeAccounts 列出本机 Chrome 中可导入的账号,并标出已导入的账号 -func (m *Manager) ChromeAccounts() ([]ChromeAccount, error) { - root, err := chromeauth.DefaultChromeRoot() +// BrowserAccounts 列出本机指定浏览器中可导入的账号,并标出已导入的账号 +func (m *Manager) BrowserAccounts(browser string) ([]BrowserAccount, error) { + if browser == "" { + browser = chromeauth.BrowserChrome + } + root, err := chromeauth.DefaultBrowserRoot(browser) if err != nil { return nil, err } - discovered, err := chromeauth.Discover(root) + discovered, err := chromeauth.DiscoverBrowser(browser, root) if err != nil { return nil, err } - imported := make(map[string]bool) - for _, id := range m.IDs() { - imported[id] = true + importedEmails := make(map[string]bool) + importedIDs := make(map[string]bool) + importedNoEmailProfiles := make(map[string]bool) + m.mu.Lock() + for id, entry := range m.accounts { + importedIDs[strings.ToLower(id)] = true + var email string + if client := m.pool.Client(id); client != nil && client.Email() != "" { + email = client.Email() + } + if email != "" { + importedEmails[strings.ToLower(strings.TrimSpace(email))] = true + importedIDs[chromeauth.AccountID(email)] = true + } + if entry != nil { + b := entry.account.Browser + if b == "" && entry.account.OAuth != nil { + b = chromeauth.BrowserChrome + } + prof := strings.ToLower(entry.account.Profile) + if email == "" && b != "" && prof != "" { + importedNoEmailProfiles[b+"/"+prof] = true + } + } } - result := make([]ChromeAccount, 0, len(discovered)) + m.mu.Unlock() + + result := make([]BrowserAccount, 0, len(discovered)) for _, account := range discovered { - result = append(result, ChromeAccount{Account: account, Imported: imported[chromeauth.AccountID(account.Email)]}) + isImported := false + email := strings.ToLower(strings.TrimSpace(account.Email)) + targetID := chromeauth.AccountID(email) + + if email != "" && strings.Contains(email, "@") { + if importedEmails[email] || (targetID != "" && importedIDs[targetID]) { + isImported = true + } + } else { + profID := chromeauth.AccountID(account.Profile) + b := strings.ToLower(account.Browser) + if b == "" { + b = browser + } + profKey := b + "/" + strings.ToLower(account.Profile) + if (profID != "" && importedIDs[profID]) || importedNoEmailProfiles[profKey] { + isImported = true + } + } + result = append(result, BrowserAccount{Account: account, Imported: isImported}) } sort.SliceStable(result, func(i, j int) bool { return result[i].Profile < result[j].Profile }) return result, nil } -// ChromeAccount 是本机 Chrome 中的一个 Google 账号及其导入状态 -type ChromeAccount struct { +// ChromeAccounts 列出本机 Chrome 中可导入的账号,并标出已导入的账号 +func (m *Manager) ChromeAccounts() ([]ChromeAccount, error) { + return m.BrowserAccounts(chromeauth.BrowserChrome) +} + +// BrowserAccount 是本机浏览器中的一个 Google 账号及其导入状态 +type BrowserAccount struct { chromeauth.Account Imported bool `json:"imported"` } -// ImportChrome 按邮箱从本机 Chrome 导入账号并启动 -func (m *Manager) ImportChrome(ctx context.Context, emails []string, proxy string) ([]string, error) { - root, err := chromeauth.DefaultChromeRoot() - if err != nil { - return nil, err +// ChromeAccount 是本机 Chrome 中的一个 Google 账号及其导入状态 +type ChromeAccount = BrowserAccount + +// ImportBrowser 按邮箱或 Profile 从本机指定浏览器导入账号并启动 +func (m *Manager) ImportBrowser(ctx context.Context, browser string, targets []string, proxy string) ([]string, []chromeauth.ImportFailure, error) { + if browser == "" { + browser = chromeauth.BrowserChrome } - settings := m.Settings() - results, _, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ChromeRoot: root, Output: settings.AuthDir, Proxy: proxy, RequestProxy: m.importProxy(), Emails: emails}) + root, err := chromeauth.DefaultBrowserRoot(browser) if err != nil { - return nil, err + return nil, nil, err } - ids := make([]string, 0, len(results)) - for _, result := range results { - id, err := m.addFile(result.Path) - if err != nil { - return ids, err + settings := m.Settings() + res, err := chromeauth.ImportBrowserWithDetails(ctx, chromeauth.BrowserImportOptions{ + Browser: browser, + BrowserRoot: root, + Output: settings.AuthDir, + Proxy: proxy, + RequestProxy: m.importProxy(), + Targets: targets, + }) + if err != nil && len(res.Results) == 0 { + return nil, res.Failures, err + } + ids := make([]string, 0, len(res.Results)) + for _, result := range res.Results { + id, addErr := m.addFile(result.Path) + if addErr != nil { + target := result.Email + if target == "" { + target = result.Profile + } + res.Failures = append(res.Failures, chromeauth.ImportFailure{Target: target, Error: addErr.Error()}) + continue } ids = append(ids, id) } - return ids, nil + if len(ids) == 0 && len(res.Failures) > 0 { + return nil, res.Failures, fmt.Errorf("所有所选账号均导入失败: %s", res.Failures[0].Error) + } + return ids, res.Failures, nil +} + +// ImportChrome 按邮箱从本机 Chrome 导入账号并启动 +func (m *Manager) ImportChrome(ctx context.Context, emails []string, proxy string) ([]string, error) { + ids, _, err := m.ImportBrowser(ctx, chromeauth.BrowserChrome, emails, proxy) + return ids, err } // ImportCookie 从 Cookie Header、浏览器导出的 JSON 或 Netscape cookies.txt 导入账号并启动 diff --git a/internal/accounts/renewal.go b/internal/accounts/renewal.go index 52d425a..e068351 100644 --- a/internal/accounts/renewal.go +++ b/internal/accounts/renewal.go @@ -13,24 +13,29 @@ import ( "github.com/Mag1cFall/Gemini-Web2API/internal/logging" ) -// chromeRenewal 用保存的续签材料签发 Cookie,材料被拒时从本机 Chrome 读取同一 Gaia ID 账号的新材料并写回认证状态 +// chromeRenewal 用保存的续签材料签发 Cookie,材料被拒时从本机浏览器读取同一 Gaia ID 账号的新材料并写回认证状态 type chromeRenewal struct { mu sync.Mutex id string + browser string material auth.OAuthMaterial profile string proxy string file *auth.File - // tried 是 Chrome 中已确认被拒的 refresh token 密文 + // tried 是浏览器中已确认被拒的 refresh token 密文 tried []byte } // newChromeRenewal 返回账号的续签器 func newChromeRenewal(account auth.LoadedAccount) *chromeRenewal { - return &chromeRenewal{id: account.ID, material: *account.OAuth, profile: account.Profile, proxy: account.ProxyURL, file: account.File} + browser := account.Browser + if browser == "" { + browser = chromeauth.BrowserChrome + } + return &chromeRenewal{id: account.ID, browser: browser, material: *account.OAuth, profile: account.Profile, proxy: account.ProxyURL, file: account.File} } -// refresh 签发 Cookie,保存的材料被拒且 Chrome 中的材料已更新时改用 Chrome 中的材料 +// refresh 签发 Cookie,保存的材料被拒且浏览器中的材料已更新时改用浏览器中的材料 func (r *chromeRenewal) refresh(ctx context.Context) ([]gemini.Cookie, error) { r.mu.Lock() defer r.mu.Unlock() @@ -38,17 +43,17 @@ func (r *chromeRenewal) refresh(ctx context.Context) ([]gemini.Cookie, error) { if !errors.Is(err, chromeauth.ErrCredentialsRejected) || r.file == nil { return cookies, err } - current, findErr := chromeauth.FindChromeMaterial(r.material.GaiaID, r.profile) + current, findErr := chromeauth.FindBrowserMaterial(r.browser, r.material.GaiaID, r.profile) if findErr != nil { - return nil, fmt.Errorf("%w;从 Chrome 重新导入失败: %v", err, findErr) + return nil, fmt.Errorf("%w;从 %s 重新导入失败: %v", err, r.browser, findErr) } if bytes.Equal(current.Encrypted, r.tried) { - return nil, fmt.Errorf("%w;Chrome 中的材料未更新", err) + return nil, fmt.Errorf("%w;%s 中的材料未更新", err, r.browser) } - logging.Info(r.id, "续签材料被拒,从 Chrome 重新导入", "Profile", current.Profile) + logging.Info(r.id, "续签材料被拒,从浏览器重新导入", "Browser", r.browser, "Profile", current.Profile) material, importErr := current.Decrypt() if importErr == nil && material.RefreshToken == r.material.RefreshToken { - importErr = fmt.Errorf("%w:Chrome 中的材料与被拒材料相同", chromeauth.ErrCredentialsRejected) + importErr = fmt.Errorf("%w:%s 中的材料与被拒材料相同", chromeauth.ErrCredentialsRejected, r.browser) } if importErr == nil { cookies, importErr = chromeauth.Refresh(ctx, material, r.proxy) @@ -57,12 +62,53 @@ func (r *chromeRenewal) refresh(ctx context.Context) ([]gemini.Cookie, error) { r.tried = current.Encrypted } if importErr != nil { - return nil, fmt.Errorf("%w;从 Chrome 重新导入失败: %w", err, importErr) + return nil, fmt.Errorf("%w;从 %s 重新导入失败: %w", err, r.browser, importErr) } if err := r.file.SetOAuth(material, current.Profile); err != nil { return nil, err } r.material, r.profile = material, current.Profile - logging.Info(r.id, "已从 Chrome 重新导入续签材料", "Profile", current.Profile) + logging.Info(r.id, "已从浏览器重新导入续签材料", "Browser", r.browser, "Profile", current.Profile) return cookies, nil } + +// browserRenewal 在 Cookie 会话失效且没有 OAuth 续签材料时,从本机浏览器重新读取最新 Cookie 并写回认证状态 +type browserRenewal struct { + mu sync.Mutex + id string + browser string + profile string + file *auth.File +} + +// newBrowserRenewal 返回浏览器 Cookie 账号的续签器 +func newBrowserRenewal(account auth.LoadedAccount) *browserRenewal { + browser := account.Browser + if browser == "" { + browser = chromeauth.BrowserChrome + } + return &browserRenewal{id: account.ID, browser: browser, profile: account.Profile, file: account.File} +} + +// refresh 从本机浏览器读取当前最新的 Cookie 并写回认证状态 +func (r *browserRenewal) refresh(ctx context.Context) ([]gemini.Cookie, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.file == nil || r.profile == "" { + return nil, gemini.ErrSignedOut + } + logging.Info(r.id, "Cookie 会话失效,从浏览器重新读取", "Browser", r.browser, "Profile", r.profile) + stateCookies, err := chromeauth.ReadBrowserCookies(r.browser, r.profile) + if err != nil { + return nil, fmt.Errorf("从 %s 重新读取 Cookie 失败: %w", r.browser, err) + } + geminiCookies := auth.ToGeminiCookies(stateCookies) + if len(geminiCookies) == 0 { + return nil, fmt.Errorf("从 %s 未读取到任何 Cookie", r.browser) + } + if err := r.file.SaveCookies(geminiCookies); err != nil { + logging.Warn(r.id, "写回重新读取的 Cookie 失败", "错误", err) + } + logging.Info(r.id, "已从浏览器重新读取最新 Cookie", "Browser", r.browser, "Count", len(geminiCookies)) + return geminiCookies, nil +} diff --git a/internal/admin/accounts.go b/internal/admin/accounts.go index 58f81d6..9cf2249 100644 --- a/internal/admin/accounts.go +++ b/internal/admin/accounts.go @@ -2,6 +2,7 @@ package admin import ( "net/http" + "strings" "github.com/Mag1cFall/Gemini-Web2API/internal/balancer" "github.com/Mag1cFall/Gemini-Web2API/internal/chromeauth" @@ -97,33 +98,95 @@ func (h *handler) verifyAccount(c *gin.Context) { h.respondAccount(c, id, h.Accounts.Verify(id)) } -// chromeProfile 是本机 Chrome 中可导入的账号 -type chromeProfile struct { +// browserProfile 是本机浏览器中可导入的账号 +type browserProfile struct { ID string `json:"id"` + Browser string `json:"browser,omitempty"` Profile string `json:"profile"` DisplayName string `json:"display_name"` Email string `json:"email"` + AuthUser int `json:"auth_user,omitempty"` Importable bool `json:"importable"` Imported bool `json:"imported"` Reason string `json:"reason,omitempty"` } +type chromeProfile = browserProfile + +func (h *handler) browserProfiles(c *gin.Context) { + browser := strings.ToLower(strings.TrimSpace(c.DefaultQuery("browser", c.DefaultQuery("type", "chrome")))) + if browser == "" { + browser = "chrome" + } + discovered, err := h.Accounts.BrowserAccounts(browser) + if err != nil { + writeError(c, http.StatusInternalServerError, "browser_unavailable", err.Error()) + return + } + profiles := make([]browserProfile, 0, len(discovered)) + for _, account := range discovered { + id := chromeauth.AccountID(account.Email) + if id == "" { + id = chromeauth.AccountID(account.Profile) + } + profiles = append(profiles, browserProfile{ + ID: id, Browser: browser, Profile: account.Profile, DisplayName: account.DisplayName, + Email: account.Email, AuthUser: account.AuthUser, Importable: account.Importable, Imported: account.Imported, Reason: account.Reason, + }) + } + c.JSON(http.StatusOK, gin.H{"browser": browser, "profiles": profiles}) +} + func (h *handler) chromeProfiles(c *gin.Context) { discovered, err := h.Accounts.ChromeAccounts() if err != nil { writeError(c, http.StatusInternalServerError, "chrome_unavailable", err.Error()) return } - profiles := make([]chromeProfile, 0, len(discovered)) + profiles := make([]browserProfile, 0, len(discovered)) for _, account := range discovered { - profiles = append(profiles, chromeProfile{ - ID: chromeauth.AccountID(account.Email), Profile: account.Profile, DisplayName: account.DisplayName, - Email: account.Email, Importable: account.Importable, Imported: account.Imported, Reason: account.Reason, + id := chromeauth.AccountID(account.Email) + if id == "" { + id = chromeauth.AccountID(account.Profile) + } + profiles = append(profiles, browserProfile{ + ID: id, Browser: "chrome", Profile: account.Profile, DisplayName: account.DisplayName, + Email: account.Email, AuthUser: account.AuthUser, Importable: account.Importable, Imported: account.Imported, Reason: account.Reason, }) } c.JSON(http.StatusOK, gin.H{"profiles": profiles}) } +func (h *handler) importBrowser(c *gin.Context) { + var input struct { + Browser string `json:"browser"` + Emails []string `json:"emails"` + Targets []string `json:"targets"` + Proxy string `json:"proxy"` + } + if !bindJSON(c, &input) { + return + } + targets := input.Targets + if len(targets) == 0 { + targets = input.Emails + } + if len(targets) == 0 { + writeError(c, http.StatusBadRequest, "invalid_request", "targets 不能为空") + return + } + browser := strings.ToLower(strings.TrimSpace(input.Browser)) + if browser == "" { + browser = "chrome" + } + ids, failures, err := h.Accounts.ImportBrowser(c.Request.Context(), browser, targets, trimmed(input.Proxy)) + if err != nil && len(ids) == 0 { + writeError(c, http.StatusBadRequest, "account_import_failed", err.Error()) + return + } + c.JSON(http.StatusOK, gin.H{"accounts": h.accountsByID(ids), "failures": failures}) +} + func (h *handler) importChrome(c *gin.Context) { var input struct { Emails []string `json:"emails"` diff --git a/internal/admin/admin.go b/internal/admin/admin.go index 0261343..b2cbe8c 100644 --- a/internal/admin/admin.go +++ b/internal/admin/admin.go @@ -62,6 +62,8 @@ func Mount(router *gin.Engine, deps Deps) { api.POST("/accounts/:id/verify", h.verifyAccount) api.GET("/accounts/import/chrome", h.chromeProfiles) api.POST("/accounts/import/chrome", h.importChrome) + api.GET("/accounts/import/browser", h.browserProfiles) + api.POST("/accounts/import/browser", h.importBrowser) api.POST("/accounts/import/cookie", h.importCookie) api.POST("/pairing", h.createPairing) api.GET("/accounts/usage", func(c *gin.Context) { diff --git a/internal/auth/state.go b/internal/auth/state.go index 8cdcacc..1a5eb96 100644 --- a/internal/auth/state.go +++ b/internal/auth/state.go @@ -60,7 +60,9 @@ type LoadedAccount struct { ProxyURL string Source gemini.AccountSource OAuth *OAuthMaterial - // Profile 为 Chrome 导入的 Profile 目录名,Cookie Header 导入时为空 + // Browser 为导入该账号的浏览器名称,如 chrome、brave、edge、firefox,Cookie Header 导入时为空 + Browser string + // Profile 为浏览器导入的 Profile 目录名,Cookie Header 导入时为空 Profile string Disabled bool File *File @@ -211,7 +213,7 @@ func (f *File) Account() LoadedAccount { source.Save = f.SaveCookies return LoadedAccount{ ID: source.ID, ProxyURL: proxyURL, Source: source, - OAuth: f.state.Metadata.OAuth, Profile: f.state.Metadata.Source.Profile, + OAuth: f.state.Metadata.OAuth, Browser: f.state.Metadata.Source.Browser, Profile: f.state.Metadata.Source.Profile, Disabled: f.state.Metadata.Disabled, File: f, } } @@ -307,6 +309,11 @@ func validateOAuthMaterial(material *OAuthMaterial) error { return nil } +// ToGeminiCookies 将 StateCookie 转换为协议客户端 Cookie +func ToGeminiCookies(cookies []StateCookie) []gemini.Cookie { + return toGeminiCookies(cookies) +} + func toGeminiCookies(cookies []StateCookie) []gemini.Cookie { result := make([]gemini.Cookie, 0, len(cookies)) for _, cookie := range cookies { diff --git a/internal/chromeauth/abe_unsupported.go b/internal/chromeauth/abe_unsupported.go index 8798967..6eb2bda 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 9caa901..f75f477 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" @@ -37,7 +38,6 @@ const ( ) var ( - kernel32 = windows.NewLazySystemDLL("kernel32.dll") procVirtualAllocEx = kernel32.NewProc("VirtualAllocEx") procWriteProcessMemory = kernel32.NewProc("WriteProcessMemory") procCreateRemoteThread = kernel32.NewProc("CreateRemoteThread") @@ -47,17 +47,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 +134,26 @@ 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() + case BrowserFirefox: + return findFirefoxExecutable() + 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 +166,57 @@ 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") +} + +// findFirefoxExecutable 定位 Firefox 可执行文件 +func findFirefoxExecutable() (string, error) { + for _, root := range []registry.Key{registry.CURRENT_USER, registry.LOCAL_MACHINE} { + path, err := executableFromRegistry(root, "firefox.exe") + if err == nil { + return path, nil + } + } + for _, path := range firefoxExecutableFallbacks() { + if fileInfo, err := os.Stat(path); err == nil && !fileInfo.IsDir() { + return path, nil + } + } + return "", fmt.Errorf("找不到 firefox.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,17 +225,17 @@ 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 } -// chromeExecutableFallbacks 返回常见安装位置 +// chromeExecutableFallbacks 返回 Chrome 常见安装位置 func chromeExecutableFallbacks() []string { paths := make([]string, 0, 3) if programFiles := strings.TrimSpace(os.Getenv("ProgramFiles")); programFiles != "" { @@ -140,8 +250,53 @@ 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 +} + +// firefoxExecutableFallbacks 返回 Firefox 常见安装位置 +func firefoxExecutableFallbacks() []string { + paths := make([]string, 0, 3) + if programFiles := strings.TrimSpace(os.Getenv("ProgramFiles")); programFiles != "" { + paths = append(paths, filepath.Join(programFiles, "Mozilla Firefox", "firefox.exe")) + } + if programFilesX86 := strings.TrimSpace(os.Getenv("ProgramFiles(x86)")); programFilesX86 != "" { + paths = append(paths, filepath.Join(programFilesX86, "Mozilla Firefox", "firefox.exe")) + } + if localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")); localAppData != "" { + paths = append(paths, filepath.Join(localAppData, "Mozilla Firefox", "firefox.exe")) + } + return paths +} + +// decryptAppBoundCiphertext 在独立浏览器进程内解密主密钥 +func decryptAppBoundCiphertext(browser string, browserPath string, encrypted []byte) ([]byte, error) { tempDir, err := os.MkdirTemp("", "gemini-web2api-abe-*") if err != nil { return nil, fmt.Errorf("创建 ABE 临时目录: %w", err) @@ -150,7 +305,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 +314,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 @@ -168,7 +324,7 @@ func decryptAppBoundCiphertext(chromePath string, encrypted []byte) ([]byte, err defer stopChrome(process) 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 { @@ -179,13 +335,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) } // stopChrome 结束临时 Chrome 并等主进程退出,之后临时目录中的文件才能删除 diff --git a/internal/chromeauth/auth.go b/internal/chromeauth/auth.go index fe4a602..64e827d 100644 --- a/internal/chromeauth/auth.go +++ b/internal/chromeauth/auth.go @@ -25,24 +25,37 @@ const ( storageFileName = "storage-state.json" ) +const ( + BrowserChrome = "chrome" + BrowserBrave = "brave" + BrowserEdge = "edge" + BrowserFirefox = "firefox" +) + var slugPattern = regexp.MustCompile(`[^a-z0-9]+`) -// Account 描述本机 Chrome 中可发现的 Google 账号,同一 Profile 登录的每个账号各占一项 +// Account 描述本机浏览器中可发现的 Google 账号,同一 Profile 登录的每个账号各占一项 type Account struct { + Browser string `json:"browser,omitempty"` Profile string `json:"profile"` GaiaID string `json:"-"` DisplayName string `json:"displayName"` Email string `json:"email"` + AuthUser int `json:"authUser,omitempty"` Importable bool `json:"importable"` - // Reason 是账号不可导入的原因,不可导入且为空时表示该账号未在 Chrome 登录 + // Reason 是账号不可导入的原因,不可导入且为空时表示该账号未在浏览器登录 Reason string `json:"reason,omitempty"` + // SourceType 表示认证来源方式,oauth 为 OAuth Refresh Token,cookie 为浏览器 Cookie + SourceType string `json:"sourceType,omitempty"` } const ( - // ReasonReadFailed 表示读取 Profile 的 Web Data 失败 + // ReasonReadFailed 表示读取 Profile 的数据失败 ReasonReadFailed = "read_failed" - // ReasonNeedsReauth 表示 refresh token 不是 App-Bound 加密,需要在 Chrome 重新登录该账号 + // ReasonNeedsReauth 表示 refresh token 不是 App-Bound 加密,需要在浏览器重新登录该账号 ReasonNeedsReauth = "needs_reauth" + // ReasonBrowserRunning 表示浏览器正在运行并锁定了 Cookie 数据库 + ReasonBrowserRunning = "browser_running" ) // ImportOptions 保存批量导入参数 @@ -56,6 +69,32 @@ type ImportOptions struct { Emails []string } +// BrowserImportOptions 保存按浏览器批量导入参数 +type BrowserImportOptions struct { + Browser string + BrowserRoot string + Output string + // Proxy 是写入账号的固定代理,RequestProxy 是没有固定代理时导入请求使用的代理 + Proxy string + RequestProxy string + Profiles []string + Emails []string + Targets []string +} + +// ImportFailure 描述单个账号导入失败的原因 +type ImportFailure struct { + Target string `json:"target"` + Error string `json:"error"` +} + +// ImportBrowserResult 包含批量导入的结果与失败详情 +type ImportBrowserResult struct { + Results []ImportResult `json:"results"` + Failures []ImportFailure `json:"failures,omitempty"` + Models int `json:"models"` +} + // ImportResult 描述单个账号导入结果 type ImportResult struct { Profile string `json:"profile"` @@ -87,7 +126,15 @@ type CookieImportOptions struct { // Discover 只读列出本机 Chrome Google 账号 func Discover(chromeRoot string) ([]Account, error) { - return discoverPlatform(chromeRoot) + return DiscoverBrowser(BrowserChrome, chromeRoot) +} + +// DiscoverBrowser 只读列出指定浏览器中可导入的 Google 账号 +func DiscoverBrowser(browser string, root string) ([]Account, error) { + if browser == "" { + browser = BrowserChrome + } + return discoverPlatformBrowser(browser, root) } // Refresh 使用已保存的 OAuth 续签材料重新签发 Google Cookie @@ -145,7 +192,7 @@ func ImportCookies(ctx context.Context, options CookieImportOptions) (ImportResu }, requestProxy: requestProxy, } - results, modelCount, err := validateAndWriteImports(ctx, []pendingImport{pending}) + results, _, modelCount, err := validateAndWriteImports(ctx, []pendingImport{pending}, nil) if err != nil { return ImportResult{}, 0, err } @@ -154,72 +201,283 @@ func ImportCookies(ctx context.Context, options CookieImportOptions) (ImportResu // Import 用本机 Chrome 的 OAuth 续签材料生成协议账号状态 func Import(ctx context.Context, options ImportOptions) ([]ImportResult, int, error) { + return ImportBrowser(ctx, BrowserImportOptions{ + Browser: BrowserChrome, + BrowserRoot: options.ChromeRoot, + Output: options.Output, + Proxy: options.Proxy, + RequestProxy: options.RequestProxy, + Profiles: options.Profiles, + Emails: options.Emails, + }) +} + +// ReadBrowserCookies 读取指定浏览器 Profile 中的 Google Cookie +func ReadBrowserCookies(browser string, profile string) ([]auth.StateCookie, error) { + if browser == "" { + browser = BrowserChrome + } + root, err := DefaultBrowserRoot(browser) + if err != nil { + return nil, err + } + if browser == BrowserFirefox { + return readFirefoxCookies(root, profile) + } + masterKey, err := retrieveMasterKey(browser, root) + if err != nil { + return nil, err + } + return readChromiumCookies(browser, root, profile, masterKey) +} + +// ImportBrowser 用本机指定浏览器的认证材料生成协议账号状态 +func ImportBrowser(ctx context.Context, options BrowserImportOptions) ([]ImportResult, int, error) { + res, err := ImportBrowserWithDetails(ctx, options) + return res.Results, res.Models, err +} + +// ImportBrowserWithDetails 用本机指定浏览器的认证材料生成协议账号状态,返回成功与失败详情 +func ImportBrowserWithDetails(ctx context.Context, options BrowserImportOptions) (ImportBrowserResult, error) { + if options.Browser == "" { + options.Browser = BrowserChrome + } + if options.Browser == BrowserFirefox { + return importFirefox(ctx, options) + } + return importChromium(ctx, options) +} + +func importFirefox(ctx context.Context, options BrowserImportOptions) (ImportBrowserResult, error) { + proxyURL, requestProxy, err := importProxies(options.Proxy, options.RequestProxy) + if err != nil { + return ImportBrowserResult{}, err + } + root := options.BrowserRoot + if root == "" { + var rootErr error + root, rootErr = DefaultFirefoxRoot() + if rootErr != nil { + return ImportBrowserResult{}, rootErr + } + options.BrowserRoot = root + } + accounts, err := discoverFirefox(root) + if err != nil { + return ImportBrowserResult{}, err + } + selected, err := selectAccounts(accounts, options.Profiles, options.Emails, options.Targets) + if err != nil { + return ImportBrowserResult{}, err + } + pending := make([]pendingImport, 0, len(selected)) + var initialFailures []ImportFailure + for _, account := range selected { + targetLabel := account.Email + if targetLabel == "" { + targetLabel = account.Profile + } + cookies, err := readFirefoxCookies(root, account.Profile) + if err != nil { + initialFailures = append(initialFailures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue + } + id := AccountID(account.Email) + if id == "" { + id = AccountID(account.Profile) + } + path := filepath.Join(options.Output, id, storageFileName) + absPath, err := filepath.Abs(path) + if err != nil { + initialFailures = append(initialFailures, ImportFailure{Target: targetLabel, Error: fmt.Sprintf("解析认证状态路径: %v", err)}) + continue + } + state := auth.StorageState{ + Cookies: cookies, + Origins: []json.RawMessage{}, + Metadata: auth.Metadata{ + ID: id, + Proxy: proxyURL, + Source: auth.ImportSource{Browser: BrowserFirefox, Profile: account.Profile}, + Language: defaultImportLanguage, + }, + } + pending = append(pending, pendingImport{ + state: state, + result: ImportResult{ + Profile: account.Profile, Email: account.Email, Imported: true, + CookieCount: len(cookies), Path: absPath, + }, + requestProxy: requestProxy, + }) + } + succeeded, failures, models, err := validateAndWriteImports(ctx, pending, initialFailures) + return ImportBrowserResult{Results: succeeded, Failures: failures, Models: models}, err +} + +func importChromium(ctx context.Context, options BrowserImportOptions) (ImportBrowserResult, error) { if err := ensurePlatformImport(); err != nil { - return nil, 0, err + return ImportBrowserResult{}, err } proxyURL, requestProxy, err := importProxies(options.Proxy, options.RequestProxy) if err != nil { - return nil, 0, err + return ImportBrowserResult{}, err + } + root := options.BrowserRoot + if root == "" { + var rootErr error + root, rootErr = DefaultBrowserRoot(options.Browser) + if rootErr != nil { + return ImportBrowserResult{}, rootErr + } + options.BrowserRoot = root } - accounts, err := Discover(options.ChromeRoot) + accounts, err := DiscoverBrowser(options.Browser, root) if err != nil { - return nil, 0, err + return ImportBrowserResult{}, err } - selected, err := selectAccounts(accounts, options.Profiles, options.Emails) + selected, err := selectAccounts(accounts, options.Profiles, options.Emails, options.Targets) if err != nil { - return nil, 0, err + return ImportBrowserResult{}, err } - masterKey, err := retrieveV20Key(options.ChromeRoot) + + masterKey, err := retrieveMasterKey(options.Browser, root) if err != nil { - return nil, 0, err + return ImportBrowserResult{}, err } if len(masterKey) != 32 { - return nil, 0, fmt.Errorf("Chrome v20 主密钥长度异常") + return ImportBrowserResult{}, fmt.Errorf("%s 主密钥长度异常", options.Browser) } pending := make([]pendingImport, 0, len(selected)) + var initialFailures []ImportFailure for _, account := range selected { - result, err := importAccount(ctx, account, options, proxyURL, requestProxy, masterKey) + targetLabel := account.Email + if targetLabel == "" { + targetLabel = account.Profile + } + if account.SourceType == "cookie" { + cookies, err := readChromiumCookies(options.Browser, root, account.Profile, masterKey) + if err != nil { + initialFailures = append(initialFailures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue + } + id := AccountID(account.Email) + if id == "" { + id = AccountID(account.Profile) + } + path := filepath.Join(options.Output, id, storageFileName) + absPath, err := filepath.Abs(path) + if err != nil { + initialFailures = append(initialFailures, ImportFailure{Target: targetLabel, Error: fmt.Sprintf("解析认证状态路径: %v", err)}) + continue + } + language, _ := readProfileLanguage(root, account.Profile) + if language == "" { + language = defaultImportLanguage + } + state := auth.StorageState{ + Cookies: cookies, + Origins: []json.RawMessage{}, + Metadata: auth.Metadata{ + ID: id, + Proxy: proxyURL, + Source: auth.ImportSource{Browser: options.Browser, Profile: account.Profile}, + Language: language, + AuthUser: account.AuthUser, + }, + } + pending = append(pending, pendingImport{ + state: state, + result: ImportResult{ + Profile: account.Profile, Email: account.Email, Imported: true, + CookieCount: len(cookies), Path: absPath, + }, + requestProxy: requestProxy, + }) + continue + } + + result, err := importChromiumAccount(ctx, account, options, proxyURL, requestProxy, masterKey) if err != nil { - return nil, 0, fmt.Errorf("导入 %s: %w", account.Email, err) + initialFailures = append(initialFailures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue } pending = append(pending, result) } - return validateAndWriteImports(ctx, pending) + succeeded, failures, models, err := validateAndWriteImports(ctx, pending, initialFailures) + return ImportBrowserResult{Results: succeeded, Failures: failures, Models: models}, err } -func selectAccounts(accounts []Account, profiles []string, emails []string) ([]Account, error) { +func selectAccounts(accounts []Account, profiles []string, emails []string, targets []string) ([]Account, error) { requestedProfiles := normalizedSet(profiles) requestedEmails := normalizedSet(emails) + requestedTargets := normalizedSet(targets) selected := make([]Account, 0, len(accounts)) foundProfiles := make(map[string]struct{}) foundEmails := make(map[string]struct{}) + foundTargets := make(map[string]struct{}) + for _, account := range accounts { profile := strings.ToLower(strings.TrimSpace(account.Profile)) email := strings.ToLower(strings.TrimSpace(account.Email)) _, profileMatch := requestedProfiles[profile] _, emailMatch := requestedEmails[email] - if !profileMatch && !emailMatch { + _, targetMatchProfile := requestedTargets[profile] + _, targetMatchEmail := requestedTargets[email] + + matched := false + if len(requestedTargets) > 0 { + if targetMatchProfile || targetMatchEmail { + matched = true + if targetMatchProfile { + foundTargets[profile] = struct{}{} + } + if targetMatchEmail { + foundTargets[email] = struct{}{} + } + } + } else if profileMatch || emailMatch { + matched = true + if profileMatch { + foundProfiles[profile] = struct{}{} + } + if emailMatch { + foundEmails[email] = struct{}{} + } + } + + if !matched { continue } if !account.Importable { - return nil, fmt.Errorf("%s %s 缺少可导入的 OAuth 认证材料", account.Profile, account.Email) + if account.Reason == ReasonBrowserRunning { + b := account.Browser + if b == "" { + b = "浏览器" + } + return nil, fmt.Errorf("%s 正在运行中并锁定了 Cookie 数据库,请先关闭 %s 浏览器后再重试导入", b, b) + } + return nil, fmt.Errorf("%s %s 缺少可导入的认证材料", account.Profile, account.Email) } - if !strings.Contains(account.Email, "@") { + if account.SourceType != "cookie" && !strings.Contains(account.Email, "@") { return nil, fmt.Errorf("%s 缺少账号邮箱", account.Profile) } - foundProfiles[profile] = struct{}{} - if _, exists := foundEmails[email]; exists { - continue - } selected = append(selected, account) - foundEmails[email] = struct{}{} } + + if len(requestedTargets) > 0 { + if missing := missingValues(requestedTargets, foundTargets); len(missing) != 0 { + return nil, fmt.Errorf("找不到目标: %s", strings.Join(missing, ", ")) + } + return selected, nil + } + if missing := missingValues(requestedProfiles, foundProfiles); len(missing) != 0 { - return nil, fmt.Errorf("找不到 Chrome Profile: %s", strings.Join(missing, ", ")) + return nil, fmt.Errorf("找不到 Profile: %s", strings.Join(missing, ", ")) } if missing := missingValues(requestedEmails, foundEmails); len(missing) != 0 { - return nil, fmt.Errorf("找不到 Chrome 账号: %s", strings.Join(missing, ", ")) + return nil, fmt.Errorf("找不到账号: %s", strings.Join(missing, ", ")) } return selected, nil } @@ -277,11 +535,23 @@ func ValidateProxy(value string) (string, error) { } } -func decryptV20Token(masterKey []byte, encrypted []byte) (string, error) { - if len(encrypted) < 3+12+16 || string(encrypted[:3]) != "v20" { - return "", fmt.Errorf("refresh token 密文版本不是 v20") +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) } - block, err := aes.NewCipher(masterKey) + if len(key) != 32 { + return "", fmt.Errorf("缺少 %s 解密密钥", ver) + } + block, err := aes.NewCipher(key) if err != nil { return "", fmt.Errorf("创建 AES 解密器: %w", err) } @@ -300,12 +570,16 @@ func decryptV20Token(masterKey []byte, encrypted []byte) (string, error) { return token, nil } -func importAccount(ctx context.Context, account Account, options ImportOptions, proxyURL string, requestProxy string, masterKey []byte) (pendingImport, error) { - gaiaID, encryptedToken, wrappedKey, err := readTokenService(options.ChromeRoot, account.Profile, account.GaiaID) +func decryptV20Token(masterKey []byte, encrypted []byte) (string, error) { + return decryptToken("", masterKey, encrypted) +} + +func importChromiumAccount(ctx context.Context, account Account, options BrowserImportOptions, proxyURL string, requestProxy string, masterKey []byte) (pendingImport, error) { + gaiaID, encryptedToken, wrappedKey, err := readTokenService(options.BrowserRoot, account.Profile, account.GaiaID) if err != nil { return pendingImport{}, err } - token, err := decryptV20Token(masterKey, encryptedToken) + token, err := decryptToken(options.BrowserRoot, masterKey, encryptedToken) if err != nil { return pendingImport{}, err } @@ -313,7 +587,7 @@ func importAccount(ctx context.Context, account Account, options ImportOptions, if err != nil { return pendingImport{}, err } - language, err := readProfileLanguage(options.ChromeRoot, account.Profile) + language, err := readProfileLanguage(options.BrowserRoot, account.Profile) if err != nil { return pendingImport{}, err } @@ -326,7 +600,7 @@ func importAccount(ctx context.Context, account Account, options ImportOptions, Metadata: auth.Metadata{ ID: AccountID(email), Proxy: proxyURL, - Source: auth.ImportSource{Browser: "chrome", Profile: account.Profile}, + Source: auth.ImportSource{Browser: options.Browser, Profile: account.Profile}, OAuth: &auth.OAuthMaterial{ GaiaID: gaiaID, RefreshToken: token, WrappedBindingKey: wrappedKey, }, @@ -347,10 +621,18 @@ func importAccount(ctx context.Context, account Account, options ImportOptions, }, nil } -func validateAndWriteImports(ctx context.Context, pending []pendingImport) ([]ImportResult, int, error) { +func validateAndWriteImports(ctx context.Context, pending []pendingImport, initialFailures []ImportFailure) ([]ImportResult, []ImportFailure, int, error) { models := make(map[string]struct{}) + failures := append([]ImportFailure(nil), initialFailures...) + var succeeded []ImportResult + completedAt := time.Now().UTC().Format(time.RFC3339Nano) + for index := range pending { item := &pending[index] + targetLabel := item.result.Email + if targetLabel == "" { + targetLabel = item.result.Profile + } source := gemini.AccountSource{ ID: item.state.Metadata.ID, Cookies: stateCookiesToGemini(item.state.Cookies), Language: item.state.Metadata.Language, AuthUser: item.state.Metadata.AuthUser, @@ -361,36 +643,49 @@ func validateAndWriteImports(ctx context.Context, pending []pendingImport) ([]Im } client, err := gemini.NewClient(source, item.requestProxy, false) if err != nil { - return nil, 0, fmt.Errorf("验证账号 %s: %w", item.state.Metadata.ID, err) + failures = append(failures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue } accountContext, cancel := context.WithTimeout(ctx, 30*time.Second) err = client.Init(accountContext) if err == nil { _, err = client.FetchUsage(accountContext) } + if email := client.Email(); email != "" { + newID := AccountID(email) + if newID != "" && item.state.Metadata.ID != newID && !strings.Contains(item.state.Metadata.ID, "@") { + item.state.Metadata.ID = newID + item.result.Email = email + item.result.Path = filepath.Join(filepath.Dir(filepath.Dir(item.result.Path)), newID, storageFileName) + } + } cancel() client.CloseIdleConnections() if err != nil { - return nil, 0, fmt.Errorf("验证账号 %s: %w", item.state.Metadata.ID, err) + failures = append(failures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue } for _, model := range client.Models() { models[model.ID] = struct{}{} } - } - results := make([]ImportResult, len(pending)) - completedAt := time.Now().UTC().Format(time.RFC3339Nano) - for index := range pending { - file, err := auth.New(pending[index].result.Path, pending[index].state) + + file, err := auth.New(item.result.Path, item.state) if err != nil { - return nil, 0, err + failures = append(failures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue } if err := file.Write(); err != nil { - return nil, 0, err + failures = append(failures, ImportFailure{Target: targetLabel, Error: err.Error()}) + continue } - pending[index].result.CompletedAt = completedAt - results[index] = pending[index].result + item.result.CompletedAt = completedAt + succeeded = append(succeeded, item.result) + } + + if len(succeeded) == 0 && len(failures) > 0 { + return nil, failures, 0, fmt.Errorf("所选账号均验证失败: %s", failures[0].Error) } - return results, len(models), nil + return succeeded, failures, len(models), nil } // AccountID 返回邮箱对应的账号 ID diff --git a/internal/chromeauth/copy.go b/internal/chromeauth/copy.go new file mode 100644 index 0000000..85b9614 --- /dev/null +++ b/internal/chromeauth/copy.go @@ -0,0 +1,25 @@ +package chromeauth + +import ( + "errors" + "io" + "os" +) + +var errBrowserRunning = errors.New("browser is running and locking cookie database") + +// copyFile 把 src 复制为 dst +func copyFile(src string, 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/firefox.go b/internal/chromeauth/firefox.go new file mode 100644 index 0000000..990287c --- /dev/null +++ b/internal/chromeauth/firefox.go @@ -0,0 +1,238 @@ +package chromeauth + +import ( + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/Mag1cFall/Gemini-Web2API/internal/auth" + _ "modernc.org/sqlite" +) + +type firefoxProfile struct { + Name string + Path string +} + +// readFirefoxProfiles 解析 profiles.ini 获取所有 Profile +func readFirefoxProfiles(firefoxRoot string) ([]firefoxProfile, error) { + data, err := os.ReadFile(filepath.Join(firefoxRoot, "profiles.ini")) + if err != nil { + return nil, fmt.Errorf("读取 Firefox profiles.ini: %w", err) + } + lines := strings.Split(string(data), "\n") + var profiles []firefoxProfile + var curName, curPath string + var isRel bool + + save := func() { + if curPath != "" { + p := curPath + if isRel { + p = filepath.Join(firefoxRoot, filepath.FromSlash(curPath)) + } + name := curName + if name == "" { + name = filepath.Base(p) + } + profiles = append(profiles, firefoxProfile{Name: name, Path: p}) + } + curName, curPath, isRel = "", "", false + } + + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + save() + continue + } + k, v, found := strings.Cut(line, "=") + if !found { + continue + } + switch strings.TrimSpace(k) { + case "Name": + curName = strings.TrimSpace(v) + case "Path": + curPath = strings.TrimSpace(v) + case "IsRelative": + isRel = strings.TrimSpace(v) == "1" + } + } + save() + return profiles, nil +} + +// openFirefoxCookies 复制并只读打开 Firefox cookies.sqlite +func openFirefoxCookies(profileDir string) (*sql.DB, func(), error) { + tempDir, err := os.MkdirTemp("", "gemini-web2api-ffcookies-*") + if err != nil { + return nil, nil, fmt.Errorf("创建 Firefox Cookie 临时目录: %w", err) + } + defer func() { + if err != nil { + os.RemoveAll(tempDir) + } + }() + src := filepath.Join(profileDir, "cookies.sqlite") + dst := filepath.Join(tempDir, "cookies.sqlite") + if err = copyLockedFile(src, dst, "firefox.exe"); err != nil { + if isSharingViolation(err) { + return nil, nil, fmt.Errorf("%w: %s", errBrowserRunning, err.Error()) + } + return nil, nil, fmt.Errorf("复制 Firefox cookies.sqlite: %w", err) + } + _ = copyLockedFile(src+"-wal", dst+"-wal", "firefox.exe") + uri := "file:" + filepath.ToSlash(dst) + "?mode=ro" + database, err := sql.Open("sqlite", uri) + if err != nil { + return nil, nil, fmt.Errorf("打开 Firefox cookies.sqlite: %w", err) + } + database.SetMaxOpenConns(1) + return database, func() { + database.Close() + os.RemoveAll(tempDir) + }, nil +} + +// discoverFirefox 扫描 Firefox profiles 并检查 Google 登录态 +func discoverFirefox(firefoxRoot string) ([]Account, error) { + profiles, err := readFirefoxProfiles(firefoxRoot) + if err != nil { + return nil, err + } + accounts := make([]Account, 0, len(profiles)) + for _, p := range profiles { + cookieDB := filepath.Join(p.Path, "cookies.sqlite") + if _, err := os.Stat(cookieDB); err != nil { + continue + } + db, closeDB, err := openFirefoxCookies(p.Path) + if err != nil { + reason := ReasonReadFailed + if errors.Is(err, errBrowserRunning) || isSharingViolation(err) { + reason = ReasonBrowserRunning + } + accounts = append(accounts, Account{ + Browser: BrowserFirefox, + Profile: p.Name, + DisplayName: p.Name, + Email: p.Name, + Reason: reason, + }) + continue + } + rows, err := db.Query("SELECT name FROM moz_cookies WHERE host LIKE '%google.com'") + if err != nil { + closeDB() + accounts = append(accounts, Account{ + Browser: BrowserFirefox, + Profile: p.Name, + DisplayName: p.Name, + Email: p.Name, + Reason: ReasonReadFailed, + }) + continue + } + names := make(map[string]bool) + for rows.Next() { + var name string + if err := rows.Scan(&name); err == nil { + names[name] = true + } + } + rows.Close() + closeDB() + + importable := names["SAPISID"] && names["__Secure-1PSID"] + acc := Account{ + Browser: BrowserFirefox, + Profile: p.Name, + DisplayName: p.Name, + Email: p.Name, + Importable: importable, + } + if importable { + acc.SourceType = "cookie" + } else { + acc.Reason = "not_signed_in" + } + accounts = append(accounts, acc) + } + return accounts, nil +} + +// readFirefoxCookies 读取特定 Profile 的 Google Cookie +func readFirefoxCookies(firefoxRoot string, profileName string) ([]auth.StateCookie, error) { + profiles, err := readFirefoxProfiles(firefoxRoot) + if err != nil { + return nil, err + } + var targetPath string + for _, p := range profiles { + if strings.EqualFold(p.Name, profileName) || strings.EqualFold(filepath.Base(p.Path), profileName) { + targetPath = p.Path + break + } + } + if targetPath == "" { + return nil, fmt.Errorf("找不到 Firefox Profile: %s", profileName) + } + + db, closeDB, err := openFirefoxCookies(targetPath) + if err != nil { + return nil, err + } + defer closeDB() + + rows, err := db.Query("SELECT name, value, host, path, expiry, isSecure, isHttpOnly, sameSite FROM moz_cookies WHERE host LIKE '%google.com'") + if err != nil { + return nil, fmt.Errorf("读取 Firefox moz_cookies: %w", err) + } + defer rows.Close() + + var cookies []auth.StateCookie + for rows.Next() { + var name, value, host, path string + var expiry int64 + var isSecure, isHttpOnly int + var sameSite int + if err := rows.Scan(&name, &value, &host, &path, &expiry, &isSecure, &isHttpOnly, &sameSite); err != nil { + return nil, fmt.Errorf("解析 Firefox moz_cookies: %w", err) + } + if name == "" || value == "" { + continue + } + sameSiteStr := "" + switch sameSite { + case 1: + sameSiteStr = "Lax" + case 2: + sameSiteStr = "Strict" + } + cookies = append(cookies, auth.StateCookie{ + Name: name, + Value: value, + Domain: host, + Path: path, + Expires: float64(expiry), + Secure: isSecure != 0, + HTTPOnly: isHttpOnly != 0, + SameSite: sameSiteStr, + }) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("读取 Firefox moz_cookies: %w", err) + } + names := make(map[string]bool) + for _, c := range cookies { + names[c.Name] = true + } + if !names["SAPISID"] || !names["__Secure-1PSID"] { + return nil, fmt.Errorf("Firefox %s 缺少核心 Google Cookie (SAPISID/__Secure-1PSID)", profileName) + } + return cookies, nil +} diff --git a/internal/chromeauth/gaia_proto.go b/internal/chromeauth/gaia_proto.go new file mode 100644 index 0000000..d0eeb8f --- /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_other.go b/internal/chromeauth/locked_file_other.go new file mode 100644 index 0000000..1f4a5a4 --- /dev/null +++ b/internal/chromeauth/locked_file_other.go @@ -0,0 +1,11 @@ +//go:build !windows + +package chromeauth + +func browserExeName(browser string) string { + return "" +} + +func copyLockedFile(src string, dst string, exeNames ...string) error { + return copyFile(src, dst) +} diff --git a/internal/chromeauth/locked_file_windows.go b/internal/chromeauth/locked_file_windows.go new file mode 100644 index 0000000..4ffaa87 --- /dev/null +++ b/internal/chromeauth/locked_file_windows.go @@ -0,0 +1,185 @@ +//go:build windows + +package chromeauth + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + procGetFinalPathNameByHandle = kernel32.NewProc("GetFinalPathNameByHandleW") + procGetFileType = kernel32.NewProc("GetFileType") + procSetFilePointerEx = kernel32.NewProc("SetFilePointerEx") + procGetFileSizeEx = kernel32.NewProc("GetFileSizeEx") +) + +// browserExeName 返回浏览器对应的进程可执行文件名 +func browserExeName(browser string) string { + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserBrave: + return "brave.exe" + case BrowserEdge: + return "msedge.exe" + case BrowserFirefox: + return "firefox.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", "firefox.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 71e2daf..456a204 100644 --- a/internal/chromeauth/platform_other.go +++ b/internal/chromeauth/platform_other.go @@ -2,25 +2,106 @@ package chromeauth -import "fmt" +import ( + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/Mag1cFall/Gemini-Web2API/internal/auth" +) // DefaultChromeRoot 返回本机 Chrome User Data 目录 func DefaultChromeRoot() (string, error) { - return "", fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return "", fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +// DefaultBraveRoot 返回本机 Brave User Data 目录 +func DefaultBraveRoot() (string, error) { + return "", fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +// DefaultEdgeRoot 返回本机 Edge User Data 目录 +func DefaultEdgeRoot() (string, error) { + return "", fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +// DefaultFirefoxRoot 返回本机 Firefox 目录 +func DefaultFirefoxRoot() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("获取用户目录失败: %w", err) + } + if runtime.GOOS == "darwin" { + return filepath.Join(home, "Library", "Application Support", "Firefox"), nil + } + return filepath.Join(home, ".mozilla", "firefox"), nil +} + +// DefaultBrowserRoot 根据浏览器类型返回默认根目录 +func DefaultBrowserRoot(browser string) (string, error) { + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserChrome, "": + return DefaultChromeRoot() + case BrowserBrave: + return DefaultBraveRoot() + case BrowserEdge: + return DefaultEdgeRoot() + case BrowserFirefox: + return DefaultFirefoxRoot() + default: + return "", fmt.Errorf("不支持的浏览器: %s", browser) + } } func ensurePlatformImport() error { - return fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } func discoverPlatform(string) ([]Account, error) { - return nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +func discoverPlatformBrowser(browser string, root string) ([]Account, error) { + if strings.ToLower(strings.TrimSpace(browser)) == BrowserFirefox { + if root == "" { + var err error + root, err = DefaultFirefoxRoot() + if err != nil { + return nil, err + } + } + return discoverFirefox(root) + } + 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") +} + +func readChromiumCookies(string, string, string, []byte) ([]auth.StateCookie, error) { + return nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") +} + +func isSharingViolation(err error) bool { + if err == nil { + return false + } + msg := strings.ToLower(err.Error()) + return strings.Contains(msg, "resource temporarily unavailable") || strings.Contains(msg, "busy") || strings.Contains(msg, "locked") } func readTokenService(string, string, string) (string, []byte, []byte, error) { - return "", nil, nil, fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return "", nil, nil, fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } func readProfileLanguage(string, string) (string, error) { - return "", fmt.Errorf("Chrome OAuth 导入仅支持 Windows") + return "", fmt.Errorf("Chromium 浏览器导入仅支持 Windows") } + diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index 8384084..7fea02e 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -3,21 +3,41 @@ package chromeauth import ( + "cmp" + "crypto/aes" + "crypto/cipher" "database/sql" + "encoding/base64" "encoding/json" + "errors" "fmt" - "io" "net/url" "os" "path/filepath" "sort" "strconv" "strings" + "syscall" "unicode" + "unsafe" + "github.com/Mag1cFall/Gemini-Web2API/internal/auth" + "golang.org/x/sys/windows" _ "modernc.org/sqlite" ) +type dataBlob struct { + cbData uint32 + pbData *byte +} + +var ( + kernel32 = windows.NewLazySystemDLL("kernel32.dll") + crypt32 = windows.NewLazySystemDLL("crypt32.dll") + procCryptUnprotect = crypt32.NewProc("CryptUnprotectData") + procLocalFree = kernel32.NewProc("LocalFree") +) + // DefaultChromeRoot 返回本机 Chrome User Data 目录 func DefaultChromeRoot() (string, error) { localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) @@ -27,14 +47,335 @@ func DefaultChromeRoot() (string, error) { return filepath.Join(localAppData, "Google", "Chrome", "User Data"), nil } +// DefaultBraveRoot 返回本机 Brave User Data 目录 +func DefaultBraveRoot() (string, error) { + localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if localAppData == "" { + return "", fmt.Errorf("环境变量 LOCALAPPDATA 为空") + } + return filepath.Join(localAppData, "BraveSoftware", "Brave-Browser", "User Data"), nil +} + +// DefaultEdgeRoot 返回本机 Edge User Data 目录 +func DefaultEdgeRoot() (string, error) { + localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) + if localAppData == "" { + return "", fmt.Errorf("环境变量 LOCALAPPDATA 为空") + } + return filepath.Join(localAppData, "Microsoft", "Edge", "User Data"), nil +} + +// DefaultFirefoxRoot 返回本机 Firefox 目录 +func DefaultFirefoxRoot() (string, error) { + appData := strings.TrimSpace(os.Getenv("APPDATA")) + if appData == "" { + return "", fmt.Errorf("环境变量 APPDATA 为空") + } + return filepath.Join(appData, "Mozilla", "Firefox"), nil +} + +// DefaultBrowserRoot 根据浏览器类型返回默认根目录 +func DefaultBrowserRoot(browser string) (string, error) { + switch strings.ToLower(strings.TrimSpace(browser)) { + case BrowserChrome, "": + return DefaultChromeRoot() + case BrowserBrave: + return DefaultBraveRoot() + case BrowserEdge: + return DefaultEdgeRoot() + case BrowserFirefox: + return DefaultFirefoxRoot() + default: + return "", fmt.Errorf("不支持的浏览器: %s", browser) + } +} + func ensurePlatformImport() error { return nil } +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") +} + +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 前缀") + } + cipher := decoded[len(dpapiPrefix):] + var inBlob, outBlob dataBlob + inBlob.cbData = uint32(len(cipher)) + inBlob.pbData = &cipher[0] + r, _, callErr := procCryptUnprotect.Call( + uintptr(unsafe.Pointer(&inBlob)), + 0, + 0, + 0, + 0, + 0, + uintptr(unsafe.Pointer(&outBlob)), + ) + if r == 0 { + return nil, fmt.Errorf("CryptUnprotectData 失败: %w", callErr) + } + defer procLocalFree.Call(uintptr(unsafe.Pointer(outBlob.pbData))) + key := make([]byte, outBlob.cbData) + copy(key, unsafe.Slice(outBlob.pbData, outBlob.cbData)) + if len(key) != 32 { + return nil, fmt.Errorf("DPAPI 密钥长度异常 (%d)", len(key)) + } + return key, nil +} + +func retrieveMasterKey(browser string, root string) ([]byte, error) { + // 优先尝试 v20 App-Bound 密钥(Chrome 127+ 及现代 Chromium 核心默认启用) + v20Key, v20Err := retrieveV20KeyForBrowser(browser, root) + if v20Err == nil && len(v20Key) == 32 { + return v20Key, nil + } + // 其次尝试 DPAPI 密钥(Brave 或未启用 ABE 的 Chromium 核心) + dpapiMasterKey, dpapiErr := decryptDPAPIMasterKey(root) + if dpapiErr == nil && len(dpapiMasterKey) == 32 { + return dpapiMasterKey, 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("", "gemini-web2api-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("sqlite", 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-1PSID"], false, nil +} + +func readChromiumCookies(browser string, root string, profile string, masterKey []byte) ([]auth.StateCookie, error) { + dpapiKey, _ := decryptDPAPIMasterKey(root) + + database, closeDatabase, err := openChromiumCookies(browser, root, profile) + if err != nil { + if errors.Is(err, errBrowserRunning) || isSharingViolation(err) { + b := browser + if b == "" { + b = "浏览器" + } + return nil, fmt.Errorf("%s 正在运行中并锁定了 Cookie 数据库,请先关闭 %s 浏览器后再重试导入", b, b) + } + 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 []auth.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, auth.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"] || !names["__Secure-1PSID"] { + return nil, fmt.Errorf("%s 缺少核心 Google Cookie (SAPISID/__Secure-1PSID)", profile) + } + return cookies, nil +} + +func isPrintableASCII(data []byte) bool { + for _, b := range data { + if b < 32 || b > 126 { + return false + } + } + return true +} + +func discoverPlatformBrowser(browser string, root string) ([]Account, error) { + browser = strings.ToLower(strings.TrimSpace(browser)) + if browser == "" { + browser = BrowserChrome + } + if root == "" { + var err error + root, err = DefaultBrowserRoot(browser) + if err != nil { + return nil, err + } + } + if browser == BrowserFirefox { + return discoverFirefox(root) + } + return discoverChromium(browser, root) +} + func discoverPlatform(chromeRoot string) ([]Account, error) { - data, err := os.ReadFile(filepath.Join(chromeRoot, "Local State")) + return discoverChromium(BrowserChrome, chromeRoot) +} + +func discoverChromium(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 { Profile struct { @@ -45,10 +386,10 @@ func discoverPlatform(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)) @@ -61,38 +402,86 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { accounts := make([]Account, 0, len(profiles)) for _, profile := range profiles { info := state.Profile.InfoCache[profile] - identities, err := profileIdentities(chromeRoot, profile) - if err != nil || len(identities) == 0 { - accounts = append(accounts, Account{Profile: profile, DisplayName: info.Name, Email: info.UserName}) - continue + identities, err := profileIdentities(root, profile) + if err == nil && len(identities) > 0 { + records, recordsErr := readTokenServices(root, profile) + hasCoreCookies, isLocked, cookieErr := checkChromiumCookies(browser, root, profile) + seen := make(map[string]bool, len(identities)) + for _, identity := range identities { + if identity.GaiaID == "" || seen[identity.GaiaID] { + continue + } + seen[identity.GaiaID] = true + record, found := records[identity.GaiaID] + dispName := identity.DisplayName + if dispName == "" { + dispName = info.Name + } + account := Account{ + Browser: browser, + Profile: profile, + GaiaID: identity.GaiaID, + DisplayName: dispName, + Email: identity.Email, + AuthUser: identity.AuthUser, + } + switch { + case found: + account.SourceType = "oauth" + if recordsErr != nil { + account.Reason = ReasonReadFailed + } else if !strings.HasPrefix(string(record.encryptedToken), "v20") && !strings.HasPrefix(string(record.encryptedToken), "v10") { + account.Reason = ReasonNeedsReauth + } else { + account.Importable = true + } + case isLocked: + account.SourceType = "cookie" + account.Reason = ReasonBrowserRunning + case hasCoreCookies: + account.SourceType = "cookie" + account.Importable = true + case cookieErr != nil: + account.SourceType = "cookie" + account.Reason = ReasonReadFailed + default: + account.Reason = "not_signed_in" + } + accounts = append(accounts, account) + } } - records, recordsErr := readTokenServices(chromeRoot, profile) - seen := make(map[string]bool, len(identities)) - for _, identity := range identities { - if identity.GaiaID == "" || seen[identity.GaiaID] { - continue + + if len(identities) == 0 { + hasCoreCookies, isLocked, cookieErr := checkChromiumCookies(browser, root, profile) + acc := Account{ + Browser: browser, + Profile: profile, + DisplayName: info.Name, + Email: cmp.Or(info.UserName, profile), } - seen[identity.GaiaID] = true - record, found := records[identity.GaiaID] - account := Account{Profile: profile, GaiaID: identity.GaiaID, DisplayName: info.Name, Email: identity.Email} switch { - case recordsErr != nil: - account.Reason = ReasonReadFailed - case found && !strings.HasPrefix(string(record.encryptedToken), "v20"): - account.Reason = ReasonNeedsReauth + case isLocked: + acc.Reason = ReasonBrowserRunning + case hasCoreCookies: + acc.Importable = true + acc.SourceType = "cookie" + case cookieErr != nil: + acc.Reason = ReasonReadFailed default: - account.Importable = found + acc.Reason = "not_signed_in" } - accounts = append(accounts, account) + accounts = append(accounts, acc) } } return accounts, nil } -// profileIdentity 是 Preferences.account_info 中的一个 Google 账号 +// profileIdentity 是 Preferences 中的一个 Google 账号 type profileIdentity struct { - GaiaID string `json:"gaia"` - Email string `json:"email"` + GaiaID string `json:"gaia"` + Email string `json:"email"` + DisplayName string `json:"name,omitempty"` + AuthUser int `json:"authuser,omitempty"` } // profileIdentities 读取 Profile 中登录的全部 Google 账号 @@ -102,28 +491,35 @@ 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 -} - -// copyFile 把 src 复制为 dst -func copyFile(src string, 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 + 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{ + GaiaID: p.GaiaID, + Email: p.Email, + DisplayName: p.DisplayName, + AuthUser: p.AuthUser, + } + } + return identities, nil + } + } } - defer out.Close() - _, err = io.Copy(out, in) - return err + return nil, nil } // openWebData 把 Profile 的 Web Data 与其 WAL 日志复制到临时目录后只读打开,绕过运行中 Chrome 的排他锁并包含尚未检查点的写入 diff --git a/internal/chromeauth/reimport.go b/internal/chromeauth/reimport.go index 840a5d6..dfb26a4 100644 --- a/internal/chromeauth/reimport.go +++ b/internal/chromeauth/reimport.go @@ -6,9 +6,10 @@ import ( "github.com/Mag1cFall/Gemini-Web2API/internal/auth" ) -// ChromeMaterial 是本机 Chrome 中一个 Google 账号当前的续签材料 +// ChromeMaterial 是本机浏览器中一个 Google 账号当前的续签材料 type ChromeMaterial struct { - Profile string + Browser string + Profile string // Encrypted 是 token_service 中的 refresh token 密文 Encrypted []byte chromeRoot string @@ -18,11 +19,19 @@ type ChromeMaterial struct { // FindChromeMaterial 在本机 Chrome 中查找 Gaia ID 对应的可导入账号,同一账号登录多个 Profile 时优先 profile func FindChromeMaterial(gaiaID string, profile string) (ChromeMaterial, error) { - root, err := DefaultChromeRoot() + return FindBrowserMaterial(BrowserChrome, gaiaID, profile) +} + +// FindBrowserMaterial 在本机指定浏览器中查找 Gaia ID 对应的可导入账号,同一账号登录多个 Profile 时优先 profile +func FindBrowserMaterial(browser string, gaiaID string, profile string) (ChromeMaterial, error) { + if browser == "" { + browser = BrowserChrome + } + root, err := DefaultBrowserRoot(browser) if err != nil { return ChromeMaterial{}, err } - accounts, err := Discover(root) + accounts, err := DiscoverBrowser(browser, root) if err != nil { return ChromeMaterial{}, err } @@ -33,23 +42,23 @@ func FindChromeMaterial(gaiaID string, profile string) (ChromeMaterial, error) { } } if selected == "" { - return ChromeMaterial{}, fmt.Errorf("Chrome 中没有可导入的同一账号") + return ChromeMaterial{}, fmt.Errorf("%s 中没有可导入的同一账号", browser) } _, encrypted, wrappedKey, err := readTokenService(root, selected, gaiaID) if err != nil { return ChromeMaterial{}, err } - return ChromeMaterial{Profile: selected, Encrypted: encrypted, chromeRoot: root, gaiaID: gaiaID, wrappedKey: wrappedKey}, nil + return ChromeMaterial{Browser: browser, Profile: selected, Encrypted: encrypted, chromeRoot: root, gaiaID: gaiaID, wrappedKey: wrappedKey}, nil } -// Decrypt 以 Chrome App-Bound 主密钥解密 refresh token,返回可保存的续签材料 +// Decrypt 以浏览器主密钥解密 refresh token,返回可保存的续签材料 func (m ChromeMaterial) Decrypt() (auth.OAuthMaterial, error) { - masterKey, err := retrieveV20Key(m.chromeRoot) + masterKey, err := retrieveMasterKey(m.Browser, m.chromeRoot) if err != nil { return auth.OAuthMaterial{}, err } if len(masterKey) != 32 { - return auth.OAuthMaterial{}, fmt.Errorf("Chrome v20 主密钥长度异常") + return auth.OAuthMaterial{}, fmt.Errorf("%s 主密钥长度异常", m.Browser) } token, err := decryptV20Token(masterKey, m.Encrypted) if err != nil { diff --git a/internal/chromeauth/setup.go b/internal/chromeauth/setup.go index 044bb01..3c759c4 100644 --- a/internal/chromeauth/setup.go +++ b/internal/chromeauth/setup.go @@ -38,9 +38,10 @@ func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Wri flags.SetOutput(stderr) var profiles stringList var emails stringList - flags.Var(&profiles, "profile", "要导入的 Chrome Profile,可重复") - flags.Var(&emails, "email", "要导入的 Google 邮箱,可重复") - chromeRoot := flags.String("chrome-root", "", "Chrome User Data 目录") + flags.Var(&profiles, "profile", "要导入的浏览器 Profile,可重复") + flags.Var(&emails, "email", "要导入的 Google 邮箱或 Profile 名称,可重复") + browserFlag := flags.String("browser", "chrome", "要导入的浏览器 (chrome、brave、edge、firefox)") + chromeRoot := flags.String("chrome-root", "", "浏览器 User Data 目录(可覆盖默认路径)") output := flags.String("output", "auth", "认证状态输出目录") remote := flags.String("remote", "", "把导入的账号上传到远程实例,例如 https://example.com,配对令牌从标准输入读取") proxy := flags.String("proxy", "", "账号固定代理 URL") @@ -115,15 +116,46 @@ func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Wri return fmt.Errorf("--id、--authuser 只与 --cookie、--cookie-file 一起使用") } - root, err := resolveChromeRoot(*chromeRoot) - if err != nil { - return err + targetBrowser := strings.ToLower(strings.TrimSpace(*browserFlag)) + if targetBrowser == "" { + targetBrowser = BrowserChrome + } + root := strings.TrimSpace(*chromeRoot) + if root == "" { + var err error + root, err = DefaultBrowserRoot(targetBrowser) + if err != nil { + return err + } + } else { + var err error + root, err = filepath.Abs(root) + if err != nil { + return fmt.Errorf("解析浏览器目录: %w", err) + } } + if len(profiles) == 0 && len(emails) == 0 { if !interactive { return fmt.Errorf("非交互模式请通过 --profile 或 --email 选择账号") } - accounts, err := Discover(root) + accounts, err := DiscoverBrowser(targetBrowser, root) + if (err != nil || len(accounts) == 0) && targetBrowser == BrowserChrome && *chromeRoot == "" { + altBrowsers := []string{BrowserBrave, BrowserEdge, BrowserFirefox} + for _, b := range altBrowsers { + altRoot, rErr := DefaultBrowserRoot(b) + if rErr == nil { + if accs, dErr := DiscoverBrowser(b, altRoot); dErr == nil && len(accs) > 0 { + targetBrowser = b + root = altRoot + accounts = accs + err = nil + fmt.Fprintf(stderr, "未发现 Chrome 账号,自动切换到 %s\n", b) + break + } + } + } + } if err != nil { return err } @@ -133,8 +165,9 @@ func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Wri } } - results, modelCount, err := Import(ctx, ImportOptions{ - ChromeRoot: root, + results, modelCount, err := ImportBrowser(ctx, BrowserImportOptions{ + Browser: targetBrowser, + BrowserRoot: root, Output: outputPath, Proxy: proxyURL, RequestProxy: requestProxy, @@ -151,7 +184,7 @@ func RunSetup(ctx context.Context, args []string, stdin io.Reader, stdout io.Wri return finish(paths, modelCount) } -// promptAccounts 列出可导入账号并按输入的编号返回选中账号的邮箱 +// promptAccounts 列出可导入账号并按输入的编号返回选中账号的邮箱或 Profile func promptAccounts(accounts []Account, input io.Reader, output io.Writer) (stringList, error) { available := make([]Account, 0, len(accounts)) for _, account := range accounts { @@ -160,11 +193,19 @@ func promptAccounts(accounts []Account, input io.Reader, output io.Writer) (stri } } if len(available) == 0 { - return nil, fmt.Errorf("本机 Chrome 没有可导入账号") + return nil, fmt.Errorf("没有可导入账号") } fmt.Fprintln(output, "可导入账号:") for index, account := range available { - fmt.Fprintf(output, "%d. %s %s %s\n", index+1, account.Email, account.Profile, account.DisplayName) + extra := "" + if account.SourceType == "cookie" { + extra = " [Cookie]" + } + label := account.Email + if label == "" { + label = account.Profile + } + fmt.Fprintf(output, "%d. %s %s %s%s\n", index+1, label, account.Profile, account.DisplayName, extra) } fmt.Fprint(output, "请输入逗号分隔的编号: ") line, err := bufio.NewReader(input).ReadString('\n') @@ -178,30 +219,27 @@ func promptAccounts(accounts []Account, input io.Reader, output io.Writer) (stri selected := make(stringList, 0) seen := make(map[int]struct{}) for _, raw := range strings.Split(line, ",") { - index, err := strconv.Atoi(strings.TrimSpace(raw)) - if err != nil || index < 1 || index > len(available) { - return nil, fmt.Errorf("账号编号 %q 无效", strings.TrimSpace(raw)) + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + index, parseErr := strconv.Atoi(raw) + if parseErr != nil || index < 1 || index > len(available) { + return nil, fmt.Errorf("编号无效: %q", raw) } - if _, exists := seen[index]; exists { + if _, ok := seen[index]; ok { continue } seen[index] = struct{}{} - selected = append(selected, available[index-1].Email) + target := available[index-1].Email + if target == "" { + target = available[index-1].Profile + } + selected = append(selected, target) } return selected, nil } -func resolveChromeRoot(value string) (string, error) { - if strings.TrimSpace(value) == "" { - return DefaultChromeRoot() - } - root, err := filepath.Abs(strings.TrimSpace(value)) - if err != nil { - return "", fmt.Errorf("解析 Chrome User Data 目录: %w", err) - } - return root, nil -} - func writeJSON(writer io.Writer, value any) error { encoder := json.NewEncoder(writer) encoder.SetEscapeHTML(false) diff --git a/web/src/features/accounts/AccountPage.tsx b/web/src/features/accounts/AccountPage.tsx index 52a3a94..949fd1f 100644 --- a/web/src/features/accounts/AccountPage.tsx +++ b/web/src/features/accounts/AccountPage.tsx @@ -176,7 +176,15 @@ export function AccountPage() { } description={[ - account.source === 'chrome' ? m.sourceChrome : m.sourceCookie, + account.source === 'chrome' + ? m.sourceChrome + : account.source === 'brave' + ? m.sourceBrave + : account.source === 'edge' + ? m.sourceEdge + : account.source === 'firefox' + ? m.sourceFirefox + : m.sourceCookie, account.profile, account.tier, ] diff --git a/web/src/features/accounts/AccountsPage.tsx b/web/src/features/accounts/AccountsPage.tsx index ccb3990..4c8c4a2 100644 --- a/web/src/features/accounts/AccountsPage.tsx +++ b/web/src/features/accounts/AccountsPage.tsx @@ -276,7 +276,17 @@ export function AccountsPage() { {sm[account.state]} - {account.source === 'chrome' ? m.sourceChrome : m.sourceCookie} + + {account.source === 'chrome' + ? m.sourceChrome + : account.source === 'brave' + ? m.sourceBrave + : account.source === 'edge' + ? m.sourceEdge + : account.source === 'firefox' + ? m.sourceFirefox + : m.sourceCookie} + {account.tier ?? m.none} {account.models.length} diff --git a/web/src/features/accounts/ImportPage.tsx b/web/src/features/accounts/ImportPage.tsx index 72cd5fd..a5b0250 100644 --- a/web/src/features/accounts/ImportPage.tsx +++ b/web/src/features/accounts/ImportPage.tsx @@ -8,123 +8,180 @@ import { CopyButton } from '@/components/ui/copy-button' import { Field } from '@/components/ui/field' import { Input, Textarea } from '@/components/ui/input' import { PageHeader, Section } from '@/components/ui/page' +import { Segmented } from '@/components/ui/segmented' import { ErrorState, Loading } from '@/components/ui/states' import { toast } from '@/components/ui/toast' import { importMessages } from '@/features/accounts/messages' import { accountsKey } from '@/features/accounts/queries' -import type { ChromeProfiles, PairingToken } from '@/features/accounts/types' +import type { BrowserProfiles, BrowserType, ImportBrowserResponse, PairingToken } from '@/features/accounts/types' import { api } from '@/lib/api' import { formatDateTime } from '@/lib/format' import { fill, useI18n, useMessages } from '@/lib/i18n' -// ChromeImport 是从本机 Chrome Profile 导入 -function ChromeImport() { +// BrowserImport 是从本机浏览器(Chrome、Brave、Edge、Firefox)导入 +function BrowserImport() { const m = useMessages(importMessages) const client = useQueryClient() + const [browser, setBrowser] = useState('chrome') const [selected, setSelected] = useState([]) const [proxy, setProxy] = useState('') + + const browserOptions: { value: BrowserType; label: string }[] = [ + { value: 'chrome', label: m.sourceChrome }, + { value: 'brave', label: m.sourceBrave }, + { value: 'edge', label: m.sourceEdge }, + { value: 'firefox', label: m.sourceFirefox }, + ] + const profiles = useQuery({ - queryKey: ['chrome-profiles'], - queryFn: () => api('/api/accounts/import/chrome'), + queryKey: ['browser-profiles', browser], + queryFn: () => api(`/api/accounts/import/browser?browser=${browser}`), }) const run = useMutation({ mutationFn: () => - api('/api/accounts/import/chrome', { method: 'POST', json: { emails: selected, proxy } }), - onSuccess: () => { + api('/api/accounts/import/browser', { + method: 'POST', + json: { browser, targets: selected, emails: selected, proxy }, + }), + onSuccess: (data) => { void client.invalidateQueries({ queryKey: accountsKey }) void profiles.refetch() - toast({ tone: 'success', title: m.importDone }) - setSelected([]) + const importedTargets = new Set(data.accounts.map((a) => a.email ?? a.account_id)) + setSelected((current) => current.filter((target) => !importedTargets.has(target))) + + const failureCount = data.failures?.length ?? 0 + const successCount = data.accounts.length + + if (failureCount > 0 && successCount > 0) { + toast({ + tone: 'warning', + title: fill(m.importPartial, { succeeded: successCount, failed: failureCount }), + description: data.failures?.map((f) => `${f.target} (${f.error})`).join('; '), + }) + } else if (failureCount > 0 && successCount === 0) { + toast({ + tone: 'error', + title: data.failures?.[0]?.error ?? m.readFailed, + }) + } else { + toast({ tone: 'success', title: m.importDone }) + } }, onError: (error) => { toast({ tone: 'error', title: error.message }) }, }) - if (profiles.isPending) return - if (profiles.isError) { - return ( - void profiles.refetch()} - /> - ) - } - const items = profiles.data.profiles - const toggle = (email: string) => { + const toggle = (target: string) => { setSelected((current) => - current.includes(email) ? current.filter((item) => item !== email) : [...current, email], + current.includes(target) ? current.filter((item) => item !== target) : [...current, target], ) } + const items = profiles.data?.profiles ?? [] return ( -
- {items.length === 0 ? ( -

{m.noProfiles}

- ) : ( -
    - {items.map((profile) => ( -
  • - -
  • - ))} -
- )} - - { - setProxy(event.target.value) +
+
+ { + setBrowser(val) + setSelected([]) }} + options={browserOptions} /> - -
- + {profiles.isPending ? ( + + ) : profiles.isError ? ( + void profiles.refetch()} + /> + ) : items.length === 0 ? ( +

{m.noProfiles}

+ ) : ( +
    + {items.map((profile) => { + const target = profile.email || profile.profile + return ( +
  • + +
  • + ) + })} +
+ )} + + { + setProxy(event.target.value) + }} + /> + +
+ +
) @@ -276,14 +333,14 @@ function RemotePairing() { ) } -// ImportPage 是导入账号页:Chrome Profile、Cookie 与远程配对三种来源 +// ImportPage 是导入账号页:浏览器(Chrome、Brave、Edge、Firefox)、Cookie 与远程配对三种来源 export function ImportPage() { const m = useMessages(importMessages) return ( <>
- +
diff --git a/web/src/features/accounts/messages.ts b/web/src/features/accounts/messages.ts index d8b6a73..1372e82 100644 --- a/web/src/features/accounts/messages.ts +++ b/web/src/features/accounts/messages.ts @@ -142,6 +142,9 @@ export const accountsMessages = defineMessages({ emptyHint: '从本机 Chrome、Cookie 或远程配对导入账号', import: '导入账号', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: '从未', none: '无', @@ -216,6 +219,9 @@ export const accountsMessages = defineMessages({ emptyHint: '從本機 Chrome、Cookie 或遠端配對匯入帳號', import: '匯入帳號', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: '從未', none: '無', @@ -290,6 +296,9 @@ export const accountsMessages = defineMessages({ emptyHint: 'Import from local Chrome, a cookie or remote pairing', import: 'Import account', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: 'Never', none: 'None', @@ -366,6 +375,9 @@ export const accountsMessages = defineMessages({ emptyHint: 'ローカルの Chrome、Cookie、またはリモートペアリングでインポートします', import: 'アカウントをインポート', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: '未使用', none: 'なし', @@ -443,6 +455,9 @@ export const accountsMessages = defineMessages({ emptyHint: '로컬 Chrome, 쿠키 또는 원격 페어링으로 가져옵니다', import: '계정 가져오기', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: '쿠키', never: '사용 기록 없음', none: '없음', @@ -519,6 +534,9 @@ export const accountsMessages = defineMessages({ emptyHint: 'Importez depuis Chrome, un cookie ou un appairage à distance', import: 'Importer un compte', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: 'Jamais', none: 'Aucun', @@ -596,6 +614,9 @@ export const accountsMessages = defineMessages({ emptyHint: 'Aus lokalem Chrome, einem Cookie oder per Remote-Kopplung importieren', import: 'Konto importieren', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: 'Nie', none: 'Keine', @@ -665,15 +686,22 @@ export const accountsMessages = defineMessages({ export const importMessages = defineMessages({ 'zh-CN': { title: '导入账号', - description: '从本机 Chrome Profile、Cookie 或远程配对导入 Gemini 账号', + description: '从本机浏览器 Profile、Cookie 或远程配对导入 Gemini 账号', chromeTitle: '从 Chrome 导入', chromeHint: '选择已登录 Gemini 的 Chrome Profile', + browserTitle: '从浏览器导入', + browserHint: '选择本机浏览器并导入已登录 Gemini 的账号', + browserRunning: '浏览器运行中,若读取 Cookie 请关闭后重试', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: '已导入', notSignedIn: '未登录 Google', - readFailed: '无法读取 Chrome 数据', - needsReauth: '需在 Chrome 重新登录', + readFailed: '无法读取浏览器数据', + needsReauth: '需在浏览器重新登录', importSelected: '导入所选', - noProfiles: '未发现 Chrome Profile', + noProfiles: '未发现浏览器 Profile', cookieTitle: '从 Cookie 导入', cookieHint: '粘贴 Cookie 头或浏览器导出的 JSON、cookies.txt', cookieId: '账号标识', @@ -686,6 +714,7 @@ export const importMessages = defineMessages({ proxyHint: '留空时从代理池分配;填写后作为该账号的固定代理,填 direct 表示直连', importCookie: '导入', importDone: '导入完成', + importPartial: '成功导入 {succeeded} 个,失败 {failed} 个', pairingTitle: '远程配对', pairingHint: '服务所在的机器没有 Chrome 时,在登录了 Chrome 的电脑上运行下面的命令,按提示粘贴配对令牌', @@ -700,15 +729,22 @@ export const importMessages = defineMessages({ }, 'zh-TW': { title: '匯入帳號', - description: '從本機 Chrome Profile、Cookie 或遠端配對匯入 Gemini 帳號', + description: '從本機瀏覽器 Profile、Cookie 或遠端配對匯入 Gemini 帳號', chromeTitle: '從 Chrome 匯入', chromeHint: '選擇已登入 Gemini 的 Chrome Profile', + browserTitle: '從瀏覽器匯入', + browserHint: '選擇本機瀏覽器並匯入已登入 Gemini 的帳號', + browserRunning: '瀏覽器執行中,若讀取 Cookie 請關閉後重試', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: '已匯入', notSignedIn: '未登入 Google', - readFailed: '無法讀取 Chrome 資料', - needsReauth: '需在 Chrome 重新登入', + readFailed: '無法讀取瀏覽器資料', + needsReauth: '需在瀏覽器重新登入', importSelected: '匯入所選', - noProfiles: '未發現 Chrome Profile', + noProfiles: '未發現瀏覽器 Profile', cookieTitle: '從 Cookie 匯入', cookieHint: '貼上 Cookie 標頭或瀏覽器匯出的 JSON、cookies.txt', cookieId: '帳號識別', @@ -721,6 +757,7 @@ export const importMessages = defineMessages({ proxyHint: '留空時從代理池分配;填寫後作為該帳號的固定代理,填 direct 表示直連', importCookie: '匯入', importDone: '匯入完成', + importPartial: '成功匯入 {succeeded} 個,失敗 {failed} 個', pairingTitle: '遠端配對', pairingHint: '服務所在的機器沒有 Chrome 時,在登入了 Chrome 的電腦上執行下列指令,依提示貼上配對權杖', @@ -735,15 +772,22 @@ export const importMessages = defineMessages({ }, en: { title: 'Import account', - description: 'Import a Gemini account from a local Chrome profile, a cookie or remote pairing', + description: 'Import a Gemini account from a local browser profile, a cookie or remote pairing', chromeTitle: 'Import from Chrome', chromeHint: 'Pick a Chrome profile signed in to Gemini', + browserTitle: 'Import from browser', + browserHint: 'Select a local browser and import accounts signed in to Gemini', + browserRunning: 'Browser is running; close it to read cookies', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: 'Imported', notSignedIn: 'Not signed in to Google', - readFailed: 'Cannot read Chrome data', - needsReauth: 'Sign in again in Chrome', + readFailed: 'Cannot read browser data', + needsReauth: 'Sign in again in browser', importSelected: 'Import selected', - noProfiles: 'No Chrome profile found', + noProfiles: 'No browser profile found', cookieTitle: 'Import from cookie', cookieHint: 'Paste a cookie header, browser-exported JSON or cookies.txt', cookieId: 'Account id', @@ -757,6 +801,7 @@ export const importMessages = defineMessages({ 'Leave empty to use the proxy pool; a value becomes this account’s fixed proxy, and direct connects without a proxy', importCookie: 'Import', importDone: 'Import complete', + importPartial: '{succeeded} succeeded, {failed} failed', pairingTitle: 'Remote pairing', pairingHint: 'When the service runs on a machine without Chrome, run this command on a computer signed in to Chrome and paste the pairing token when asked', @@ -772,15 +817,22 @@ export const importMessages = defineMessages({ ja: { title: 'アカウントをインポート', description: - 'ローカルの Chrome プロファイル、Cookie、またはリモートペアリングで Gemini アカウントをインポートします', + 'ローカルのブラウザプロファイル、Cookie、またはリモートペアリングで Gemini アカウントをインポートします', chromeTitle: 'Chrome からインポート', chromeHint: 'Gemini にログイン済みの Chrome プロファイルを選択します', + browserTitle: 'ブラウザからインポート', + browserHint: 'ローカルのブラウザを選択して Gemini にログイン済みのアカウントをインポートします', + browserRunning: 'ブラウザが実行中です。Cookie を読み取るには終了してください', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: 'インポート済み', notSignedIn: 'Google 未ログイン', - readFailed: 'Chrome のデータを読み取れません', - needsReauth: 'Chrome で再ログインが必要', + readFailed: 'ブラウザのデータを読み取れません', + needsReauth: 'ブラウザで再ログインが必要', importSelected: '選択項目をインポート', - noProfiles: 'Chrome プロファイルが見つかりません', + noProfiles: 'ブラウザプロファイルが見つかりません', cookieTitle: 'Cookie からインポート', cookieHint: 'Cookie ヘッダー、ブラウザーから書き出した JSON または cookies.txt を貼り付けます', cookieId: 'アカウント ID', @@ -794,6 +846,7 @@ export const importMessages = defineMessages({ '空欄の場合はプロキシプールから割り当てます。入力するとこのアカウントの固定プロキシになり、direct で直接接続します', importCookie: 'インポート', importDone: 'インポートしました', + importPartial: '{succeeded} 件のインポートに成功、{failed} 件が失敗', pairingTitle: 'リモートペアリング', pairingHint: 'サービスを動かすマシンに Chrome がない場合は、Chrome にログインしたパソコンで次のコマンドを実行し、表示に従ってペアリングトークンを貼り付けます', @@ -808,15 +861,22 @@ export const importMessages = defineMessages({ }, ko: { title: '계정 가져오기', - description: '로컬 Chrome 프로필, 쿠키 또는 원격 페어링으로 Gemini 계정을 가져옵니다', + description: '로컬 브라우저 프로필, 쿠키 또는 원격 페어링으로 Gemini 계정을 가져옵니다', chromeTitle: 'Chrome에서 가져오기', chromeHint: 'Gemini에 로그인된 Chrome 프로필을 선택합니다', + browserTitle: '브라우저에서 가져오기', + browserHint: '로컬 브라우저를 선택하고 Gemini에 로그인된 계정을 가져옵니다', + browserRunning: '브라우저가 실행 중입니다. 쿠키를 읽으려면 브라우저를 닫아주세요', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: '가져옴', notSignedIn: 'Google 로그인 안 됨', - readFailed: 'Chrome 데이터를 읽을 수 없음', - needsReauth: 'Chrome에서 다시 로그인 필요', + readFailed: '브라우저 데이터를 읽을 수 없음', + needsReauth: '브라우저에서 다시 로그인 필요', importSelected: '선택 항목 가져오기', - noProfiles: 'Chrome 프로필을 찾지 못했습니다', + noProfiles: '브라우저 프로필을 찾지 못했습니다', cookieTitle: '쿠키에서 가져오기', cookieHint: '쿠키 헤더, 브라우저에서 내보낸 JSON 또는 cookies.txt를 붙여 넣습니다', cookieId: '계정 식별자', @@ -830,6 +890,7 @@ export const importMessages = defineMessages({ '비워 두면 프록시 풀에서 할당합니다. 입력하면 이 계정의 고정 프록시가 되며, direct는 직접 연결합니다', importCookie: '가져오기', importDone: '가져오기 완료', + importPartial: '{succeeded}개 가져오기 성공, {failed}개 실패', pairingTitle: '원격 페어링', pairingHint: '서비스가 실행되는 머신에 Chrome이 없으면 Chrome에 로그인된 컴퓨터에서 아래 명령을 실행하고 안내에 따라 페어링 토큰을 붙여 넣습니다', @@ -845,15 +906,22 @@ export const importMessages = defineMessages({ fr: { title: 'Importer un compte', description: - 'Importez un compte Gemini depuis un profil Chrome local, un cookie ou un appairage à distance', + 'Importez un compte Gemini depuis un profil de navigateur local, un cookie ou un appairage à distance', chromeTitle: 'Importer depuis Chrome', chromeHint: 'Choisissez un profil Chrome connecté à Gemini', + browserTitle: 'Importer depuis un navigateur', + browserHint: 'Sélectionnez un navigateur local et importez les comptes connectés à Gemini', + browserRunning: 'Le navigateur est en cours d’exécution, fermez-le pour lire les cookies', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: 'Importé', notSignedIn: 'Non connecté à Google', - readFailed: 'Données Chrome illisibles', - needsReauth: 'Reconnectez-vous dans Chrome', + readFailed: 'Données du navigateur illisibles', + needsReauth: 'Reconnectez-vous dans le navigateur', importSelected: 'Importer la sélection', - noProfiles: 'Aucun profil Chrome trouvé', + noProfiles: 'Aucun profil de navigateur trouvé', cookieTitle: 'Importer depuis un cookie', cookieHint: 'Collez un en-tête Cookie, un JSON exporté par le navigateur ou un cookies.txt', cookieId: 'Identifiant du compte', @@ -867,6 +935,7 @@ export const importMessages = defineMessages({ 'Laissez vide pour utiliser le pool de proxys ; une valeur devient le proxy fixe de ce compte, direct pour une connexion directe', importCookie: 'Importer', importDone: 'Importation terminée', + importPartial: '{succeeded} importés avec succès, {failed} échoués', pairingTitle: 'Appairage à distance', pairingHint: 'Si la machine du service n’a pas Chrome, exécutez cette commande sur un ordinateur connecté à Chrome et collez le jeton d’appairage lorsqu’il est demandé', @@ -882,15 +951,22 @@ export const importMessages = defineMessages({ de: { title: 'Konto importieren', description: - 'Gemini-Konto aus einem lokalen Chrome-Profil, einem Cookie oder per Remote-Kopplung importieren', + 'Gemini-Konto aus einem lokalen Browser-Profil, einem Cookie oder per Remote-Kopplung importieren', chromeTitle: 'Aus Chrome importieren', chromeHint: 'Wählen Sie ein Chrome-Profil, das bei Gemini angemeldet ist', + browserTitle: 'Aus Browser importieren', + browserHint: 'Wählen Sie einen lokalen Browser und importieren Sie bei Gemini angemeldete Konten', + browserRunning: 'Browser läuft, bitte schließen um Cookies zu lesen', + sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', imported: 'Importiert', notSignedIn: 'Nicht bei Google angemeldet', - readFailed: 'Chrome-Daten nicht lesbar', - needsReauth: 'In Chrome erneut anmelden', + readFailed: 'Browser-Daten nicht lesbar', + needsReauth: 'Im Browser erneut anmelden', importSelected: 'Auswahl importieren', - noProfiles: 'Kein Chrome-Profil gefunden', + noProfiles: 'Kein Browser-Profil gefunden', cookieTitle: 'Aus Cookie importieren', cookieHint: 'Cookie-Header, vom Browser exportiertes JSON oder cookies.txt einfügen', cookieId: 'Konto-ID', @@ -904,6 +980,7 @@ export const importMessages = defineMessages({ 'Leer lassen für den Proxy-Pool; ein Wert wird zum festen Proxy dieses Kontos, direct für eine direkte Verbindung', importCookie: 'Importieren', importDone: 'Import abgeschlossen', + importPartial: '{succeeded} erfolgreich importiert, {failed} fehlgeschlagen', pairingTitle: 'Remote-Kopplung', pairingHint: 'Läuft der Dienst auf einem Rechner ohne Chrome, führen Sie diesen Befehl auf einem bei Chrome angemeldeten Computer aus und fügen Sie das Kopplungstoken bei Aufforderung ein', diff --git a/web/src/features/accounts/types.ts b/web/src/features/accounts/types.ts index 6cc924e..fa1b1e4 100644 --- a/web/src/features/accounts/types.ts +++ b/web/src/features/accounts/types.ts @@ -3,7 +3,10 @@ export type AccountState = 'initializing' | 'ready' | 'cooldown' | 'auth_required' | 'unavailable' | 'disabled' // AccountSource 是账户导入来源 -export type AccountSource = 'chrome' | 'cookie' | 'guest' +export type AccountSource = 'chrome' | 'brave' | 'edge' | 'firefox' | 'cookie' | 'guest' + +// BrowserType 是支持导入的浏览器类型 +export type BrowserType = 'chrome' | 'brave' | 'edge' | 'firefox' // UsageWindow 是官网用量窗口,used_ratio 为已用比例 export interface UsageWindow { @@ -67,23 +70,42 @@ export interface AccountsUsage { accounts: AccountUsageStatus[] } -// ChromeProfile 对应 GET /api/accounts/import/chrome 的 profiles 项 +// ChromeProfile 对应 GET /api/accounts/import/chrome 或 /api/accounts/import/browser 的 profiles 项 export interface ChromeProfile { id: string + browser?: BrowserType profile: string display_name: string email: string + auth_user?: number importable: boolean imported: boolean - // reason 为账号不可导入的原因,不可导入且缺省时为未在 Chrome 登录 - reason?: 'read_failed' | 'needs_reauth' + // reason 为账号不可导入的原因,不可导入且缺省时为未在浏览器登录 + reason?: 'read_failed' | 'needs_reauth' | 'browser_running' } -// ChromeProfiles 对应 GET /api/accounts/import/chrome 的响应 +export type BrowserProfile = ChromeProfile + +// ChromeProfiles 对应 GET /api/accounts/import/chrome 或 /api/accounts/import/browser 的响应 export interface ChromeProfiles { + browser?: BrowserType profiles: ChromeProfile[] } +export type BrowserProfiles = ChromeProfiles + +// ImportFailure 对应批量导入时单个账号的失败项 +export interface ImportFailure { + target: string + error: string +} + +// ImportBrowserResponse 对应 POST /api/accounts/import/browser 的响应 +export interface ImportBrowserResponse { + accounts: Account[] + failures?: ImportFailure[] +} + // PairingToken 对应 POST /api/pairing 的响应 export interface PairingToken { token: string