diff --git a/.gitignore b/.gitignore index e903f85..8638047 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Binaries and executables *.exe +*.exe~ *.dll *.so *.dylib 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..bf38075 100644 --- a/internal/accounts/manager.go +++ b/internal/accounts/manager.go @@ -80,6 +80,10 @@ func New(ctx context.Context, pool *balancer.AccountPool, settings Settings) *Ma proxies: proxypool.New(config.SplitList(settings.Proxy)), proxyCheck: make(chan struct{}, 1), } pool.OnTransportError(manager.requestProxyCheck) + pool.OnProxyBlocked(manager.MarkProxyBlocked) + pool.SetReconnector(func(ctx context.Context, accountID string) error { + return manager.Verify(accountID) + }) return manager } @@ -106,6 +110,7 @@ func (m *Manager) Start(accounts []auth.LoadedAccount) { m.launchGuest(wait.Done) wait.Wait() go m.watchProxies() + go m.watchQuotas() } // guestReloadInterval 是访客会话重读首页与模型目录的间隔,与账号的默认轮换间隔相同 @@ -214,10 +219,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 +245,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) @@ -370,6 +380,64 @@ func catalogOf(client *gemini.Client) accountCatalog { return catalog } +// watchQuotas 定时检查各账号的用量重置时间,到期时自动向 Google 刷新用量 +func (m *Manager) watchQuotas() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + select { + case <-m.ctx.Done(): + return + case <-ticker.C: + m.refreshDueQuotas() + } + } +} + +// refreshDueQuotas 为配额重置时间已过或用量已耗尽的账号自动向 Google 刷新最新用量 +func (m *Manager) refreshDueQuotas() { + m.mu.Lock() + entries := make([]*managed, 0, len(m.accounts)) + for _, entry := range m.accounts { + if entry != nil && !entry.account.Disabled { + entries = append(entries, entry) + } + } + m.mu.Unlock() + + now := time.Now() + for _, entry := range entries { + client := m.pool.Client(entry.account.ID) + if client == nil { + continue + } + usage := client.CachedUsage() + shouldRefresh := false + if usage != nil { + if usage.Current != nil && usage.Current.UsedRatio >= 1 && now.After(usage.Current.ResetAt) { + shouldRefresh = true + } + if usage.Weekly != nil && usage.Weekly.UsedRatio >= 1 && now.After(usage.Weekly.ResetAt) { + shouldRefresh = true + } + } + if shouldRefresh { + accountID := entry.account.ID + go func(id string, c *gemini.Client) { + ctx, cancel := context.WithTimeout(m.ctx, 20*time.Second) + defer cancel() + newUsage, err := c.FetchUsage(ctx) + if err == nil { + logging.Info(id, "账号配额已到期自动刷新", "层级", newUsage.Tier) + if newUsage.Current != nil && newUsage.Current.UsedRatio < 1 { + _ = m.pool.ClearCooldown(id, "") + } + } + }(accountID, client) + } + } +} + // logCatalogChange 在账号层级或模型目录变化时写一行日志,列出新旧层级与增减的模型 func logCatalogChange(accountID string, before, after accountCatalog) { var fields []any @@ -509,6 +577,19 @@ func (m *Manager) Verify(accountID string) error { return fmt.Errorf("账号 %q 已停用", accountID) } logging.Info(accountID, "账号重新验证") + if entry.account.OAuth != nil { + if cookies, err := newChromeRenewal(entry.account).refresh(m.ctx); err == nil && len(cookies) > 0 { + entry.account.Source.Cookies = cookies + } + } else if 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 + } + } else if entry.account.File != nil { + if cookies := entry.account.File.Cookies(); len(cookies) > 0 { + entry.account.Source.Cookies = cookies + } + } if err := m.restart(entry.account); err != nil { return err } @@ -587,54 +668,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/proxies.go b/internal/accounts/proxies.go index fe9e155..5741adb 100644 --- a/internal/accounts/proxies.go +++ b/internal/accounts/proxies.go @@ -3,11 +3,13 @@ package accounts import ( "cmp" "context" + "fmt" "strings" "sync" "time" "github.com/Mag1cFall/Gemini-Web2API/internal/auth" + "github.com/Mag1cFall/Gemini-Web2API/internal/chromeauth" "github.com/Mag1cFall/Gemini-Web2API/internal/gemini" "github.com/Mag1cFall/Gemini-Web2API/internal/logging" "github.com/Mag1cFall/Gemini-Web2API/internal/proxypool" @@ -141,3 +143,68 @@ func (m *Manager) relaunchGuest() { case <-m.ctx.Done(): } } + +// MarkProxyBlocked 将遭 Google 人机验证拦截的代理移入冷却隔离,并将绑定在此代理上的账号与访客会话改绑 +func (m *Manager) MarkProxyBlocked(proxyURL string, err error) { + if m.proxies.Empty() || proxyURL == "" { + return + } + moved := m.proxies.MarkCooldown(proxyURL, 30*time.Minute, "遭遇 Google 人机验证拦截 (302)") + if len(moved) == 0 { + return + } + logging.Warn(logging.SourceService, "代理遭 Google 人机验证拦截,已自动隔离冷却并重新分配账号", "代理", maskProxy(proxyURL), "重分配账号数", len(moved)) + go func() { + sem := make(chan struct{}, 3) + var wg sync.WaitGroup + for _, id := range moved { + accountID := id + wg.Add(1) + sem <- struct{}{} + go func() { + defer func() { + <-sem + wg.Done() + }() + if accountID == gemini.GuestID { + m.relaunchGuest() + return + } + m.rebind(accountID) + }() + } + wg.Wait() + }() +} + +// TestProxy 测试指定代理的连通性、延迟以及是否被 Google 风控拦截 +func (m *Manager) TestProxy(ctx context.Context, proxy string) gemini.ProxyDiagnostic { + proxy = strings.TrimSpace(proxy) + targetProxy := proxy + if proxy == "" { + targetProxy = m.importProxy() + } else if proxy == proxypool.Direct { + targetProxy = "" + } else { + fixed, err := chromeauth.ValidateProxy(proxy) + if err != nil { + return gemini.ProxyDiagnostic{ + OK: false, + Status: "invalid_format", + Message: err.Error(), + Error: err.Error(), + } + } + targetProxy = fixed + } + + diag := gemini.DiagnoseProxy(ctx, targetProxy) + if proxy == "" { + if targetProxy != "" { + diag.Message = fmt.Sprintf("(代理池分配 %s)%s", maskProxy(targetProxy), diag.Message) + } else { + diag.Message = fmt.Sprintf("(代理池為空,測試本機直連)%s", diag.Message) + } + } + return diag +} diff --git a/internal/accounts/proxy_test.go b/internal/accounts/proxy_test.go new file mode 100644 index 0000000..480521f --- /dev/null +++ b/internal/accounts/proxy_test.go @@ -0,0 +1,40 @@ +package accounts + +import ( + "context" + "testing" + + "github.com/Mag1cFall/Gemini-Web2API/internal/balancer" +) + +func TestTestProxyFormatValidation(t *testing.T) { + mgr := New(context.Background(), balancer.NewAccountPool(0, 0), Settings{}) + + // 1. 无效的 scheme + res := mgr.TestProxy(context.Background(), "ftp://127.0.0.1:21") + if res.OK || res.Status != "invalid_format" { + t.Fatalf("expected invalid_format, got ok=%v, status=%s", res.OK, res.Status) + } + + // 2. 无法解析的主机 + res = mgr.TestProxy(context.Background(), "http://") + if res.OK || res.Status != "invalid_format" { + t.Fatalf("expected invalid_format, got ok=%v, status=%s", res.OK, res.Status) + } +} + +func TestPoolReconnectHook(t *testing.T) { + pool := balancer.NewAccountPool(0, 0) + mgr := New(context.Background(), pool, Settings{}) + + // 验证 pool.Reconnect 是否已绑定 + err := pool.Reconnect(context.Background(), "non-existent-account") + if err == nil { + t.Fatalf("expected error for non-existent account, got nil") + } + // 确认错误不是 "未配置重新连接器" + if err.Error() == "未配置重新连接器" { + t.Fatalf("expected manager.Verify to be registered as reconnector") + } + _ = mgr +} 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/adapter/audio.go b/internal/adapter/audio.go index 95a2ba3..8deb558 100644 --- a/internal/adapter/audio.go +++ b/internal/adapter/audio.go @@ -85,59 +85,92 @@ func SpeechHandler(pool *balancer.AccountPool) gin.HandlerFunc { modelID := config.MapModel(request.Model) trace.SetStage("等待账号") - client, accountID, release, err := pool.AcquireForModel(c.Request.Context(), "", modelID, nil) - if err != nil { - err = cancelledError(c.Request.Context(), err) - writeOpenAIError(c, upstreamStatus(err), openAIUpstreamCode(err), err.Error()) - return - } - if client == nil { + excludedAccountIDs := make(map[string]struct{}) + availableAccounts := pool.Size() + if availableAccounts == 0 { writeOpenAIError(c, http.StatusServiceUnavailable, "upstream_error", "No account is ready for the requested model") return } - c.Set("account_id", accountID) - trace.AccountReady(accountID, modelID) - upstreamFormat := gemini.SpeechMP3 - if format == "opus" { - upstreamFormat = gemini.SpeechOggOpus + maxAttempts := 1 + if AccountFallbackEnabled() { + maxAttempts = availableAccounts } - audio, err := client.Speech(c.Request.Context(), request.Input, "", upstreamFormat) - err = cancelledError(c.Request.Context(), err) - if err != nil { - if retryableAccountError(err) { - pool.ReportFailure(accountID, modelID, err) + var lastErr error + for attempts := 0; attempts < maxAttempts && len(excludedAccountIDs) < availableAccounts; attempts++ { + client, accountID, release, err := pool.AcquireForModel(c.Request.Context(), "", modelID, excludedAccountIDs) + if err != nil { + err = cancelledError(c.Request.Context(), err) + writeOpenAIError(c, upstreamStatus(err), openAIUpstreamCode(err), err.Error()) + return } - release() - writeOpenAIError(c, upstreamStatus(err), openAIUpstreamCode(err), err.Error()) - return - } - pool.ReportSuccess(accountID, modelID) - release() - contentType := audioContentTypes[format] - if upstreamFormat == gemini.SpeechMP3 { - if audio, contentType, err = transcodeMP3(audio, format); err != nil { - writeOpenAIError(c, http.StatusInternalServerError, "server_error", err.Error()) + if client == nil { + break + } + c.Set("account_id", accountID) + trace.AccountReady(accountID, modelID) + upstreamFormat := gemini.SpeechMP3 + if format == "opus" { + upstreamFormat = gemini.SpeechOggOpus + } + audio, err := client.Speech(c.Request.Context(), request.Input, "", upstreamFormat) + err = cancelledError(c.Request.Context(), err) + if err != nil && gemini.IsAuthenticationError(err) { + release() + release = nil + logging.Info(accountID, "语音生成会话失效,尝试自动重新连接一次") + if rErr := pool.Reconnect(c.Request.Context(), accountID); rErr == nil { + logging.Info(accountID, "自动重新连接成功,重试语音生成") + var newRelease func() + client, accountID, newRelease, err = pool.AcquireForModel(c.Request.Context(), "", modelID, excludedAccountIDs) + if err == nil && client != nil { + release = newRelease + audio, err = client.Speech(c.Request.Context(), request.Input, "", upstreamFormat) + err = cancelledError(c.Request.Context(), err) + } + } + } + if release != nil { + release() + release = nil + } + if err == nil { + pool.ReportSuccess(accountID, modelID) + contentType := audioContentTypes[format] + if upstreamFormat == gemini.SpeechMP3 { + if audio, contentType, err = transcodeMP3(audio, format); err != nil { + writeOpenAIError(c, http.StatusInternalServerError, "server_error", err.Error()) + return + } + } + inputTokens := tokencount.Text(request.Input) + outputTokens := int(math.Ceil(audioSeconds(audio) * audioTokensPerSec)) + trace.SetUsage(inputTokens, 0, outputTokens) + trace.SetResult(modelID, "", "stop") + if request.StreamFormat != "sse" { + c.Data(http.StatusOK, contentType, audio) + return + } + setSSEHeaders(c) + for offset := 0; offset < len(audio); offset += speechChunkBytes { + chunk := audio[offset:min(offset+speechChunkBytes, len(audio))] + if err := writeSSEJSON(c.Writer, gin.H{"type": "speech.audio.delta", "audio": base64.StdEncoding.EncodeToString(chunk)}); err != nil { + return + } + } + _ = writeSSEJSON(c.Writer, gin.H{"type": "speech.audio.done"}) return } + lastErr = err + if retryableAccountError(err) { + pool.ReportFailure(accountID, modelID, err) + } + excludedAccountIDs[accountID] = struct{}{} } - inputTokens := tokencount.Text(request.Input) - outputTokens := int(math.Ceil(audioSeconds(audio) * audioTokensPerSec)) - trace.SetUsage(inputTokens, 0, outputTokens) - trace.SetResult(modelID, "", "stop") - if request.StreamFormat != "sse" { - c.Data(http.StatusOK, contentType, audio) + if lastErr != nil { + writeOpenAIError(c, upstreamStatus(lastErr), openAIUpstreamCode(lastErr), lastErr.Error()) return } - setSSEHeaders(c) - for offset := 0; offset < len(audio); offset += speechChunkBytes { - chunk := audio[offset:min(offset+speechChunkBytes, len(audio))] - if err := writeSSEJSON(c.Writer, gin.H{"type": "speech.audio.delta", "audio": base64.StdEncoding.EncodeToString(chunk)}); err != nil { - return - } - } - _ = writeSSEJSON(c.Writer, gin.H{"type": "speech.audio.done", "usage": gin.H{ - "input_tokens": inputTokens, "output_tokens": outputTokens, "total_tokens": inputTokens + outputTokens, - }}) + writeOpenAIError(c, http.StatusServiceUnavailable, "upstream_error", "No account is ready for the requested model") } } diff --git a/internal/adapter/stream.go b/internal/adapter/stream.go index 799bb55..d7f9262 100644 --- a/internal/adapter/stream.go +++ b/internal/adapter/stream.go @@ -6,6 +6,7 @@ import ( "fmt" "net/http" "strings" + "sync/atomic" "github.com/Mag1cFall/Gemini-Web2API/internal/balancer" "github.com/Mag1cFall/Gemini-Web2API/internal/config" @@ -14,6 +15,22 @@ import ( "github.com/Mag1cFall/Gemini-Web2API/internal/tokencount" ) +var accountFallback atomic.Bool + +func init() { + accountFallback.Store(true) +} + +// ConfigureAccountFallback 设置当账号遇到模型或上游错误时是否自动换号重试 +func ConfigureAccountFallback(enabled bool) { + accountFallback.Store(enabled) +} + +// AccountFallbackEnabled 报告是否开启了账号故障自动换号重试 +func AccountFallbackEnabled() bool { + return accountFallback.Load() +} + // generationMedia 是请求对生成媒体的要求 type generationMedia struct { // requireImage 表示请求必须返回生成图片 @@ -247,18 +264,23 @@ func runGenerationAttempts( newSession = !exists } + availableAccounts := pool.Size() + if availableAccounts == 0 { + return generationResult{}, "", &gemini.ProtocolError{HTTPStatus: 503, Message: "没有可用账号", Retryable: true} + } + fallbackEnabled := AccountFallbackEnabled() requestAttempts := 1 if sessionKey == "" || newSession { requestAttempts = 2 } - excludedAccountIDs := make(map[string]struct{}) - availableAccounts := pool.Size() - if availableAccounts == 0 { - return generationResult{}, "", &gemini.ProtocolError{HTTPStatus: 503, Message: "没有可用账号", Retryable: true} + if fallbackEnabled && rebuildable { + requestAttempts = availableAccounts } + excludedAccountIDs := make(map[string]struct{}) usedRequestAttempts := 0 lastAccountID := "" var lastErr error + reconnectedAccounts := make(map[string]bool) for len(excludedAccountIDs) < availableAccounts { selectionKey := sessionKey if newSession { @@ -272,7 +294,12 @@ func runGenerationAttempts( if client == nil { if sessionKey != "" && !newSession && accountID != "" { if rebuildable { - sessionKey, newSession, requestAttempts = "", true, 2 + sessionKey, newSession = "", true + if fallbackEnabled { + requestAttempts = availableAccounts + } else { + requestAttempts = 2 + } continue } return generationResult{}, accountID, &gemini.ProtocolError{HTTPStatus: 503, Message: fmt.Sprintf("会话所在账号 %s,会话没有保存的响应链,无法在其他账号续接", pool.AccountReason(accountID, requestedModel))} @@ -282,6 +309,7 @@ func runGenerationAttempts( } return generationResult{}, accountID, &gemini.ProtocolError{HTTPStatus: 503, Message: "没有可用账号", Retryable: true} } + reconnected := false result, err := func() (generationResult, error) { defer releaseRequest() result, err := runGenerationWithClient( @@ -290,7 +318,13 @@ func runGenerationAttempts( ) if err == nil { pool.ReportSuccess(accountID, requestedModel) - } else if ctx.Err() == nil && retryableAccountError(err) && !isOutputMismatchError(err) { + return result, nil + } + if ctx.Err() == nil && retryableAccountError(err) && !isOutputMismatchError(err) { + if gemini.IsAuthenticationError(err) && (hasVisibleOutput == nil || !hasVisibleOutput()) && !reconnectedAccounts[accountID] { + reconnected = true + return result, err + } if usageLimitError(err) { _, _ = client.FetchUsage(ctx) } @@ -307,15 +341,40 @@ func runGenerationAttempts( if ctx.Err() != nil { return generationResult{}, accountID, err } + if reconnected { + reconnectedAccounts[accountID] = true + trace.Stage("会话失效,自动重新连接", "账号", accountID) + logging.Info(accountID, "检测到账号会话失效,尝试自动重新连接一次") + reconnectErr := pool.Reconnect(ctx, accountID) + if reconnectErr == nil { + logging.Info(accountID, "自动重新连接成功,无感重试当前请求") + trace.Stage("自动重新连接成功,重试请求", "账号", accountID) + delete(excludedAccountIDs, accountID) + continue + } + logging.Warn(accountID, "自动重新连接失败,恢复原错误", "错误", reconnectErr) + trace.Stage("自动重新连接失败", "账号", accountID, "错误", reconnectErr) + pool.ReportFailure(accountID, requestedModel, err) + } if errors.Is(err, gemini.ErrConversationLost) && rebuildable && !newSession && (hasVisibleOutput == nil || !hasVisibleOutput()) { trace.AttemptFailed(accountID, upstreamStatus(err), err, true) - sessionKey, newSession, requestAttempts = "", true, 2 + sessionKey, newSession = "", true + if fallbackEnabled { + requestAttempts = availableAccounts + } else { + requestAttempts = 2 + } continue } if rebuildable && sessionKey != "" && !newSession && (isAccountContextError(err) || errors.Is(err, gemini.ErrGuestAttachments) || retryableAccountError(err) && (hasVisibleOutput == nil || !hasVisibleOutput())) { trace.AttemptFailed(accountID, upstreamStatus(err), err, true) excludedAccountIDs[accountID] = struct{}{} - sessionKey, newSession, requestAttempts = "", true, 2 + sessionKey, newSession = "", true + if fallbackEnabled { + requestAttempts = availableAccounts + } else { + requestAttempts = 2 + } continue } if isAccountContextError(err) { @@ -611,8 +670,8 @@ func actualProviderModel(client *gemini.Client, requestedModel string, metadata return requestedModel } -// usageLimitError 判断错误是否为官网用量上限 1037 或用量用满后的模型降级,此时先读取账号用量再按其重置时刻冷却 +// usageLimitError 判断错误是否为官网用量上限 1037、1173 或用量用满后的模型降级,此时先读取账号用量再按其重置时刻冷却 func usageLimitError(err error) bool { var protocolErr *gemini.ProtocolError - return errors.As(err, &protocolErr) && protocolErr.Code == 1037 || errors.Is(err, gemini.ErrModelMismatch) + return errors.As(err, &protocolErr) && (protocolErr.Code == 1037 || protocolErr.Code == 1173) || errors.Is(err, gemini.ErrModelMismatch) } diff --git a/internal/adapter/tool_bridge.go b/internal/adapter/tool_bridge.go index 7eb368b..c21f98b 100644 --- a/internal/adapter/tool_bridge.go +++ b/internal/adapter/tool_bridge.go @@ -88,7 +88,7 @@ func (b ToolBridge) PromptSuffix() (string, error) { rule, otherwise = fmt.Sprintf("必须调用工具 %s", choice), "" } - sections = append(sections, fmt.Sprintf("工具协议:\n可用工具:%s\n工具选择:%s\n这些工具由客户端在用户的环境中执行,结果会在下一条消息中返回。%s,输出一个 ```json 代码块,内容为 {\"tool_calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]},代码块之后不再输出内容;对话要求在调用工具前说明时,把说明写在代码块之前。参数中的字符串按 JSON 转义原样写出%s", available, choice, rule, otherwise)) + sections = append(sections, fmt.Sprintf("工具协议:\n可用工具:%s\n工具选择:%s\n这些工具由客户端在用户的环境中执行,结果会在下一条消息中返回。%s,输出一个 ```json 代码块,内容为 {\"tool_calls\":[{\"name\":\"工具名称\",\"arguments\":{}}]},代码块之后不再输出内容;对话要求在调用工具前说明时,把说明写在代码块之前,代码块必须另起一行输出。参数中的字符串按 JSON 转义原样写出%s", available, choice, rule, otherwise)) } if len(b.Enum) > 0 { @@ -180,26 +180,64 @@ func (b ToolBridge) Parse(text string) (string, []ToolCall, error) { // toolEnvelopeStart 匹配工具调用信封的开头 var toolEnvelopeStart = regexp.MustCompile(`\{\s*"tool_calls"\s*:`) +// openingFenceRe 匹配信封前的代码块开头(支持紧贴文字、空格或换行) +var openingFenceRe = regexp.MustCompile("```+[ \t]*[a-zA-Z0-9_-]*\\s*$") + +// trailingFenceRe 匹配末尾残留的多余代码块标记 +var trailingFenceRe = regexp.MustCompile("\\s*`{3,}[a-zA-Z0-9_-]*\\s*$") + +// leadingFenceRe 匹配开头残留的多余代码块标记 +var leadingFenceRe = regexp.MustCompile("^\\s*`{3,}[a-zA-Z0-9_-]*\\s*") + // splitToolEnvelope 把回答分为信封前的文字、信封与信封后的文字,代码块标记不计入文字 func splitToolEnvelope(text string) (string, string, string, bool) { location := toolEnvelopeStart.FindStringIndex(text) if location == nil { return "", "", "", false } - before, rest := strings.TrimSpace(text[:location[0]]), text[location[0]:] - if line := strings.LastIndex(before, "\n") + 1; strings.HasPrefix(before[line:], "```") { + rawBefore, rest := text[:location[0]], text[location[0]:] + before := strings.TrimSpace(rawBefore) + + if match := openingFenceRe.FindStringIndex(rawBefore); match != nil { + before = strings.TrimSpace(rawBefore[:match[0]]) + } else if line := strings.LastIndex(before, "\n") + 1; strings.HasPrefix(strings.TrimSpace(before[line:]), "```") { before = strings.TrimSpace(before[:line]) - if end := closingFence(rest); end >= 0 { - return before, rest[:end], strings.TrimSpace(rest[end+3:]), true - } - return before, rest, "", true } + before = trailingFenceRe.ReplaceAllString(before, "") + before = strings.TrimSpace(before) + decoder := json.NewDecoder(strings.NewReader(rest)) var value json.RawMessage - if decoder.Decode(&value) != nil { - return before, rest, "", true + if decoder.Decode(&value) == nil { + offset := int(decoder.InputOffset()) + envelope := strings.TrimSpace(rest[:offset]) + after := rest[offset:] + if fenceLoc := strings.Index(after, "```"); fenceLoc >= 0 { + fenceEnd := fenceLoc + 3 + for fenceEnd < len(after) && after[fenceEnd] == '`' { + fenceEnd++ + } + after = strings.TrimSpace(after[fenceEnd:]) + } else { + after = strings.TrimSpace(after) + } + after = leadingFenceRe.ReplaceAllString(after, "") + after = trailingFenceRe.ReplaceAllString(after, "") + return before, envelope, strings.TrimSpace(after), true } - return before, rest[:decoder.InputOffset()], strings.TrimSpace(rest[decoder.InputOffset():]), true + + if end := closingFence(rest); end >= 0 { + fenceEnd := end + 3 + for fenceEnd < len(rest) && rest[fenceEnd] == '`' { + fenceEnd++ + } + after := strings.TrimSpace(rest[fenceEnd:]) + after = leadingFenceRe.ReplaceAllString(after, "") + after = trailingFenceRe.ReplaceAllString(after, "") + return before, strings.TrimSpace(rest[:end]), strings.TrimSpace(after), true + } + + return before, strings.TrimSpace(rest), "", true } // closingFence 返回代码块中信封之后的结束标记位置:信封是有效 JSON 时取 JSON 之后的第一个标记,否则取第一个位于行首的标记 diff --git a/internal/adapter/tool_bridge_test.go b/internal/adapter/tool_bridge_test.go new file mode 100644 index 0000000..bdbd1d1 --- /dev/null +++ b/internal/adapter/tool_bridge_test.go @@ -0,0 +1,109 @@ +package adapter + +import ( + "encoding/json" + "testing" +) + +func TestSplitToolEnvelopeNoNewline(t *testing.T) { + // 用户截图中的实际场景:句号后无换行直接紧贴 ```json + input := `正在讀取目前 C:/Users/fi/source/metasoftware-valorant/tts/features/aimassist/aimassist.cpp 的目標偵測與移動控制區段,準備在不影響原版 Makcu 硬體貝茲移動手感(ApplySmoothing、ExecuteMovement、mouseMoveBezier)的前提下,將先前參考五大開源專案的優秀偵測技術完整整合進去。` + "```json\n" + + `{"tool_calls":[{"name":"codemode","arguments":{"action":"view"}}]} ` + "\n```" + + before, envelope, after, found := splitToolEnvelope(input) + if !found { + t.Fatalf("expected tool envelope to be found") + } + + expectedBefore := `正在讀取目前 C:/Users/fi/source/metasoftware-valorant/tts/features/aimassist/aimassist.cpp 的目標偵測與移動控制區段,準備在不影響原版 Makcu 硬體貝茲移動手感(ApplySmoothing、ExecuteMovement、mouseMoveBezier)的前提下,將先前參考五大開源專案的優秀偵測技術完整整合進去。` + + if before != expectedBefore { + t.Errorf("expected before:\n%s\ngot:\n%s", expectedBefore, before) + } + + if after != "" { + t.Errorf("expected empty after, got: %q", after) + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(envelope), &parsed); err != nil { + t.Errorf("failed to parse envelope: %v", err) + } +} + +func TestSplitToolEnvelopeWithSpaceAndInlineCode(t *testing.T) { + // 含有行内单反引号 `code`,且在句号后带空格接 ```json + input := "正在讀取 `aimassist.cpp` 與 `makcu.cpp` 的內容。 ```json\n" + + `{"tool_calls":[{"name":"read_file","arguments":{"path":"aimassist.cpp"}}]} ` + "\n```" + + before, envelope, after, found := splitToolEnvelope(input) + if !found { + t.Fatalf("expected tool envelope to be found") + } + + expectedBefore := "正在讀取 `aimassist.cpp` 與 `makcu.cpp` 的內容。" + if before != expectedBefore { + t.Errorf("expected before:\n%s\ngot:\n%s", expectedBefore, before) + } + + if after != "" { + t.Errorf("expected empty after, got: %q", after) + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(envelope), &parsed); err != nil { + t.Errorf("failed to parse envelope: %v", err) + } +} + +func TestSplitToolEnvelopeStandardNewline(t *testing.T) { + // 标准换行情况 + input := "正在檢查檔案。\n```json\n" + + `{"tool_calls":[{"name":"search","arguments":{"q":"test"}}]} ` + "\n```\n後續備註文字" + + before, envelope, after, found := splitToolEnvelope(input) + if !found { + t.Fatalf("expected tool envelope to be found") + } + + if before != "正在檢查檔案。" { + t.Errorf("expected before '正在檢查檔案。', got %q", before) + } + + if after != "後續備註文字" { + t.Errorf("expected after '後續備註文字', got %q", after) + } + + var parsed map[string]interface{} + if err := json.Unmarshal([]byte(envelope), &parsed); err != nil { + t.Errorf("failed to parse envelope: %v", err) + } +} + +func TestToolBridgeParseCleanContent(t *testing.T) { + bridge := ToolBridge{ + Definitions: []ToolDefinition{ + { + Name: "codemode", + Description: "execute code", + Parameters: json.RawMessage(`{"type":"object"}`), + }, + }, + } + + rawText := `正在分析目標檔案。` + "```json\n" + + `{"tool_calls":[{"name":"codemode","arguments":{}}]} ` + "\n```" + + content, calls, err := bridge.Parse(rawText) + if err != nil { + t.Fatalf("bridge.Parse failed: %v", err) + } + + if content != "正在分析目標檔案。" { + t.Errorf("expected content '正在分析目標檔案。', got %q", content) + } + + if len(calls) != 1 || calls[0].Name != "codemode" { + t.Errorf("unexpected calls: %v", calls) + } +} 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..a8af971 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) { @@ -90,6 +92,7 @@ func Mount(router *gin.Engine, deps Deps) { api.POST("/proxies/check", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"proxies": h.Accounts.CheckProxies(c.Request.Context())}) }) + api.POST("/proxies/test", h.testProxy) api.GET("/update", h.updateStatus) api.POST("/update", h.applyUpdate) } @@ -348,6 +351,17 @@ func (h *handler) updateConfig(c *gin.Context) { c.JSON(http.StatusOK, h.settings.View()) } +func (h *handler) testProxy(c *gin.Context) { + var input struct { + Proxy string `json:"proxy"` + } + if !bindJSON(c, &input) { + return + } + res := h.Accounts.TestProxy(c.Request.Context(), input.Proxy) + c.JSON(http.StatusOK, res) +} + // writeError 以管理端错误体结束请求 func writeError(c *gin.Context, status int, code string, message string) { c.AbortWithStatusJSON(status, gin.H{"error": gin.H{"code": code, "message": message}}) diff --git a/internal/admin/settings.go b/internal/admin/settings.go index 2804271..367844b 100644 --- a/internal/admin/settings.go +++ b/internal/admin/settings.go @@ -56,6 +56,7 @@ type ServiceConfig struct { LogMaxTotalMB int `json:"log_max_total_mb"` Concurrency int `json:"per_account_concurrency"` RoutingStrategy string `json:"routing_strategy"` + AccountFallback bool `json:"account_fallback"` AdminAuthEnabled bool `json:"admin_auth_enabled"` AdminUsername string `json:"admin_username"` AdminPasswordSet bool `json:"admin_password_set"` @@ -82,6 +83,7 @@ type ConfigUpdate struct { LogMaxTotalMB *int `json:"log_max_total_mb"` Concurrency *int `json:"per_account_concurrency"` RoutingStrategy *string `json:"routing_strategy"` + AccountFallback *bool `json:"account_fallback"` AdminAuthEnabled *bool `json:"admin_auth_enabled"` AdminUsername *string `json:"admin_username"` AdminPassword *string `json:"admin_password"` @@ -100,7 +102,7 @@ func (s *Settings) View() ServiceConfig { ModelMapping: config.FormatModelMapping(cfg.ModelMapping), AccountCooldown: shortDuration(cfg.AccountCooldown), SessionTTL: shortDuration(cfg.SessionTTL), InitTimeout: shortDuration(cfg.InitTimeout), LogLevel: cfg.LogLevel, RequestBodyLog: cfg.RequestBodyLog, LogFile: cfg.LogFile, LogMaxTotalMB: cfg.LogMaxTotalMB, Concurrency: cfg.PerAccountConcurrency, RoutingStrategy: cfg.RoutingStrategy, - AdminAuthEnabled: cfg.AdminAuthEnabled, AdminUsername: cfg.AdminUsername, + AccountFallback: cfg.AccountFallback, AdminAuthEnabled: cfg.AdminAuthEnabled, AdminUsername: cfg.AdminUsername, AdminPasswordSet: cfg.AdminPassword != "", ListenAddr: cfg.ListenAddress, AuthStates: strings.Join(cfg.AuthStatePaths, ","), LogFormat: cfg.LogFormat, ActiveListenAddr: s.active.ListenAddress, RestartFields: []string{}, @@ -208,6 +210,7 @@ func (s *Settings) Update(update ConfigUpdate) error { next.RoutingStrategy = strategy env["ROUTING_STRATEGY"] = strategy } + setBool(update.AccountFallback, "ACCOUNT_FALLBACK", &next.AccountFallback) setBool(update.AdminAuthEnabled, "ADMIN_AUTH_ENABLED", &next.AdminAuthEnabled) setString(update.AdminUsername, "ADMIN_USERNAME", &next.AdminUsername) if next.AdminUsername == "" { diff --git a/internal/app/app.go b/internal/app/app.go index fe0f6f6..e64a4f1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -225,6 +225,7 @@ func (instance *Instance) Serve(ctx context.Context) error { logStartup(cfg, len(loaded)) adapter.ConfigureSessionTTL(cfg.SessionTTL) + adapter.ConfigureAccountFallback(cfg.AccountFallback) pool := balancer.NewAccountPool(cfg.AccountCooldown, cfg.SessionTTL) manager := accounts.New(ctx, pool, accounts.Settings{ InitTimeout: cfg.InitTimeout, RetryInterval: cfg.AccountCooldown, Proxy: cfg.ProxyURL, @@ -235,6 +236,7 @@ func (instance *Instance) Serve(ctx context.Context) error { config.SetModelMapping(next.ModelMapping) pool.SetTimings(next.AccountCooldown, next.SessionTTL) adapter.ConfigureSessionTTL(next.SessionTTL) + adapter.ConfigureAccountFallback(next.AccountFallback) if level, err := logging.ParseLevel(next.LogLevel); err == nil { logging.SetLevel(level) } diff --git a/internal/auth/state.go b/internal/auth/state.go index 8cdcacc..867f2a8 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, } } @@ -253,6 +255,13 @@ func (f *File) SetOAuth(material OAuthMaterial, profile string) error { return f.writeLocked() } +// Cookies 返回当前保存的 Cookie 副本 +func (f *File) Cookies() []gemini.Cookie { + f.mu.Lock() + defer f.mu.Unlock() + return toGeminiCookies(f.state.Cookies) +} + // SaveCookies 更新 Cookie 并原子写回认证状态 func (f *File) SaveCookies(cookies []gemini.Cookie) error { f.mu.Lock() @@ -307,6 +316,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/balancer/balancer.go b/internal/balancer/balancer.go index ff5c12d..9e9f7c9 100644 --- a/internal/balancer/balancer.go +++ b/internal/balancer/balancer.go @@ -150,6 +150,8 @@ type AccountPool struct { lastSessionGC time.Time mu sync.Mutex transport func() + proxyBlocked func(proxyURL string, err error) + reconnect func(ctx context.Context, accountID string) error // changed 在账号状态、冷却或请求槽变化时关闭并替换,唤醒等待账号的请求 changed chan struct{} // queues 为按排队键分组、按到达顺序排列的等待请求唤醒通道 @@ -331,6 +333,9 @@ func (p *AccountPool) MarkFailed(accountID string, err error) bool { state = StateAuthRequired } p.reportTransport(err) + if errors.Is(err, gemini.ErrCaptcha) && entry.Info.ProxySource == "pool" { + p.reportProxyBlocked(entry.Info.Proxy, err) + } changed := entry.State != state || entry.LastError != errorText(err) entry.State = state entry.LastError, entry.LastErrorAt = errorText(err), time.Now() @@ -355,6 +360,38 @@ func (p *AccountPool) OnTransportError(fn func()) { p.transport = fn } +// OnProxyBlocked 设置账号遭遇 Google 人机验证拦截时调用的函数 +func (p *AccountPool) OnProxyBlocked(fn func(proxyURL string, err error)) { + p.mu.Lock() + defer p.mu.Unlock() + p.proxyBlocked = fn +} + +// SetReconnector 设置账号遇到认证失效时自动重新连接并初始化的函数 +func (p *AccountPool) SetReconnector(fn func(ctx context.Context, accountID string) error) { + p.mu.Lock() + defer p.mu.Unlock() + p.reconnect = fn +} + +// Reconnect 调用已设置的重新连接函数为指定账号重新连接并初始化 +func (p *AccountPool) Reconnect(ctx context.Context, accountID string) error { + p.mu.Lock() + fn := p.reconnect + p.mu.Unlock() + if fn == nil { + return errors.New("未配置重新连接器") + } + return fn(ctx, accountID) +} + +// reportProxyBlocked 在代理遭 Google 人机验证拦截时通知外部 +func (p *AccountPool) reportProxyBlocked(proxyURL string, err error) { + if p.proxyBlocked != nil && proxyURL != "" { + p.proxyBlocked(proxyURL, err) + } +} + // reportTransport 在 err 为连接、TLS 或代理错误时调用 OnTransportError 设置的函数,调用方持有 p.mu func (p *AccountPool) reportTransport(err error) { if p.transport != nil && errors.Is(err, gemini.ErrTransport) { @@ -515,9 +552,11 @@ func (p *AccountPool) NextForModelExcluding(sessionKey string, modelID string, e if binding, ok := p.sessions[sessionKey]; ok && now.Before(binding.ExpiresAt) { entry := p.entry(binding.AccountID) if entry != nil && entry.schedulable(now, modelID) { - entry.LastUsed = now - p.sessions[sessionKey] = sessionBinding{AccountID: entry.AccountID, ExpiresAt: now.Add(p.sessionTTL)} - return entry.Client, entry.AccountID + if _, excluded := excludedAccountIDs[entry.AccountID]; !excluded { + entry.LastUsed = now + p.sessions[sessionKey] = sessionBinding{AccountID: entry.AccountID, ExpiresAt: now.Add(p.sessionTTL)} + return entry.Client, entry.AccountID + } } return nil, binding.AccountID } @@ -614,6 +653,9 @@ func (p *AccountPool) ReportFailure(accountID string, modelID string, err error) return } entry.ConsecutiveFails++ + if errors.Is(err, gemini.ErrCaptcha) && entry.Info.ProxySource == "pool" { + p.reportProxyBlocked(entry.Info.Proxy, err) + } if gemini.IsAuthenticationError(err) { entry.State = StateAuthRequired logging.Warn(accountID, "账号待认证", "原因", err) @@ -640,7 +682,7 @@ func failureScope(err error) (bool, string) { var protocolErr *gemini.ProtocolError if errors.As(err, &protocolErr) { switch protocolErr.Code { - case 1037: + case 1037, 1173: return true, "usage_limit" case 1095: return true, "rate_limited" @@ -662,7 +704,7 @@ func failureScope(err error) (bool, string) { // usageReset 对用量上限与模型降级返回官网已用满的用量窗口的重置时间 func usageReset(client *gemini.Client, err error) time.Time { var protocolErr *gemini.ProtocolError - limited := errors.As(err, &protocolErr) && protocolErr.Code == 1037 || errors.Is(err, gemini.ErrModelMismatch) + limited := errors.As(err, &protocolErr) && (protocolErr.Code == 1037 || protocolErr.Code == 1173) || errors.Is(err, gemini.ErrModelMismatch) if !limited || client == nil { return time.Time{} } @@ -933,6 +975,15 @@ func (e *accountEntry) schedulable(now time.Time, modelID string) bool { return e.Client != nil && e.State == StateReady && e.blockedUntil(now, modelID).IsZero() && e.supportsModel(modelID) } +// isQuotaLimitedModel 报告模型是否受 Pro/Advanced 5小时用量周期限制 +func isQuotaLimitedModel(modelID string) bool { + id := strings.ToLower(modelID) + if strings.Contains(id, "flash") && !strings.Contains(id, "task") { + return false + } + return strings.Contains(id, "argon") || strings.Contains(id, "pro") || strings.Contains(id, "deep-research") || strings.Contains(id, "task") || strings.Contains(id, "video") +} + // blockedUntil 返回账号或目标模型冷却的结束时间,没有冷却时为零值 func (e *accountEntry) blockedUntil(now time.Time, modelID string) time.Time { var until time.Time @@ -942,6 +993,26 @@ func (e *accountEntry) blockedUntil(now time.Time, modelID string) time.Time { if value, ok := e.ModelCooldowns[modelID]; ok && now.Before(value.until) && value.until.After(until) { until = value.until } + baseID := strings.TrimSuffix(modelID, "-task") + if baseID != modelID { + if value, ok := e.ModelCooldowns[baseID]; ok && now.Before(value.until) && value.until.After(until) { + until = value.until + } + } else { + taskID := modelID + "-task" + if value, ok := e.ModelCooldowns[taskID]; ok && now.Before(value.until) && value.until.After(until) { + until = value.until + } + } + if isQuotaLimitedModel(modelID) && e.Client != nil { + if usage := e.Client.CachedUsage(); usage != nil { + for _, window := range []*gemini.UsageWindow{usage.Current, usage.Weekly} { + if window != nil && window.UsedRatio >= 1 && now.Before(window.ResetAt) && window.ResetAt.After(until) { + until = window.ResetAt + } + } + } + } return until } @@ -980,7 +1051,7 @@ func (p *AccountPool) nextAvailable(now time.Time, modelID string, excludedAccou return p.nextFrom(now, modelID, excludedAccountIDs, true) } -// nextFrom 在账号或访客会话中按上下文窗口与调度策略选择 +// nextFrom 在账号或访客会话中按单账号配额耗尽优先与上下文窗口统一选择 func (p *AccountPool) nextFrom(now time.Time, modelID string, excludedAccountIDs map[string]struct{}, guest bool) *accountEntry { if len(p.entries) == 0 { return nil @@ -1000,21 +1071,62 @@ func (p *AccountPool) nextFrom(now time.Time, modelID string, excludedAccountIDs return nil } - start := p.index - if p.strategy == FillFirst { - start = 0 - } - for _, idleOnly := range []bool{true, false} { - for offset := uint64(0); offset < uint64(len(p.entries)); offset++ { - idx := (start + offset) % uint64(len(p.entries)) - entry := p.entries[idx] - if eligible(entry) && entry.Client.ContextWindow() == maxContextWindow && (!idleOnly || !entry.Client.Busy()) { - p.index = idx + 1 - return entry - } + quotaRatio := func(entry *accountEntry) float64 { + if entry.Client == nil || !isQuotaLimitedModel(modelID) { + return 0 + } + usage := entry.Client.CachedUsage() + if usage == nil || usage.Current == nil { + return 0 + } + if now.After(usage.Current.ResetAt) { + return 0 + } + return usage.Current.UsedRatio + } + + type candidate struct { + entry *accountEntry + origIndex int + usedRatio float64 + busy bool + } + candidates := make([]candidate, 0, len(p.entries)) + for idx, entry := range p.entries { + if eligible(entry) && entry.Client.ContextWindow() == maxContextWindow { + candidates = append(candidates, candidate{ + entry: entry, + origIndex: idx, + usedRatio: quotaRatio(entry), + busy: entry.Client.Busy(), + }) } } - return nil + if len(candidates) == 0 { + return nil + } + + // 统一调度策略:单账号配额耗尽优先(Exhaust-First) + // 1. 空闲账号优先(!busy 优先于 busy) + // 2. 正在消耗配额的账号优先(0 < usedRatio < 1 降序),优先完全耗尽单一账号额度,避免分散消耗多个账号的 5 小时窗口 + // 3. 配额消耗相同时,严格保持账号列表原始顺序(origIndex),逐个账号依次耗尽 + sort.SliceStable(candidates, func(i, j int) bool { + a, b := candidates[i], candidates[j] + if a.busy != b.busy { + return !a.busy && b.busy + } + aInProgress := a.usedRatio > 0 && a.usedRatio < 1 + bInProgress := b.usedRatio > 0 && b.usedRatio < 1 + if aInProgress != bInProgress { + return aInProgress + } + if aInProgress && bInProgress && a.usedRatio != b.usedRatio { + return a.usedRatio > b.usedRatio + } + return a.origIndex < b.origIndex + }) + + return candidates[0].entry } // guest 报告条目是否为访客会话 diff --git a/internal/balancer/balancer_test.go b/internal/balancer/balancer_test.go new file mode 100644 index 0000000..a9499fc --- /dev/null +++ b/internal/balancer/balancer_test.go @@ -0,0 +1,113 @@ +package balancer + +import ( + "context" + "testing" + "time" + + "github.com/Mag1cFall/Gemini-Web2API/internal/gemini" +) + +func TestUnifiedRoutingQuotaAwareness(t *testing.T) { + pool := NewAccountPool(1*time.Minute, 30*time.Minute) + + acc1 := "acc1-exhausted@gmail.com" + acc2 := "acc2-with-quota@gmail.com" + + c1, err := gemini.NewGuestClient("", 1) + if err != nil { + t.Fatal(err) + } + c2, err := gemini.NewGuestClient("", 1) + if err != nil { + t.Fatal(err) + } + + argonModel := gemini.Model{ID: "gemini-4-argon-task", DisplayName: "4 Argon Task", Capabilities: []string{"generateContent"}} + catalog := gemini.NewModelCatalogForTest([]gemini.Model{argonModel}) + c1.SetBootstrapForTest(&gemini.Bootstrap{Catalog: catalog}) + c2.SetBootstrapForTest(&gemini.Bootstrap{Catalog: catalog}) + + // 模拟 acc1 额度已 100% 耗尽,ResetAt 在 2 小时后 + c1.SetUsageForTest(&gemini.UsageInfo{ + Tier: "pro", + Current: &gemini.UsageWindow{ + UsedRatio: 1.0, + ResetAt: time.Now().Add(2 * time.Hour), + }, + }) + + // 模拟 acc2 额度只用了 17% (尚有 83% 额度) + c2.SetUsageForTest(&gemini.UsageInfo{ + Tier: "pro", + Current: &gemini.UsageWindow{ + UsedRatio: 0.17, + ResetAt: time.Now().Add(3 * time.Hour), + }, + }) + + pool.Register(acc1, AccountInfo{Source: "brave"}, false) + pool.SetClient(acc1, c1) + pool.MarkReady(acc1) + + pool.Register(acc2, AccountInfo{Source: "brave"}, false) + pool.SetClient(acc2, c2) + pool.MarkReady(acc2) + + // 请求 Pro 模型 gemini-4-argon-task + modelID := "gemini-4-argon-task" + client, chosenID, release, err := pool.AcquireForModel(context.Background(), "", modelID, nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if client == nil { + t.Fatalf("expected to acquire a client, got nil") + } + defer release() + + // 调度器必须精准绕过 100% 耗尽的 acc1,直接选择还有额度的 acc2! + if chosenID != acc2 { + t.Fatalf("expected acc2 (%s) with 17%% quota to be chosen, but got %s", acc2, chosenID) + } +} + +func TestUnifiedRoutingSessionAffinity(t *testing.T) { + pool := NewAccountPool(1*time.Minute, 30*time.Minute) + + acc1 := "acc1@gmail.com" + acc2 := "acc2@gmail.com" + + c1, _ := gemini.NewGuestClient("", 1) + c2, _ := gemini.NewGuestClient("", 1) + + affinityModel := gemini.Model{ID: "gemini-4-argon-task", DisplayName: "4 Argon Task", Capabilities: []string{"generateContent"}} + affinityCatalog := gemini.NewModelCatalogForTest([]gemini.Model{affinityModel}) + c1.SetBootstrapForTest(&gemini.Bootstrap{Catalog: affinityCatalog}) + c2.SetBootstrapForTest(&gemini.Bootstrap{Catalog: affinityCatalog}) + + c1.SetUsageForTest(&gemini.UsageInfo{Tier: "pro", Current: &gemini.UsageWindow{UsedRatio: 0.3, ResetAt: time.Now().Add(1 * time.Hour)}}) + c2.SetUsageForTest(&gemini.UsageInfo{Tier: "pro", Current: &gemini.UsageWindow{UsedRatio: 0.1, ResetAt: time.Now().Add(1 * time.Hour)}}) + + pool.Register(acc1, AccountInfo{Source: "brave"}, false) + pool.SetClient(acc1, c1) + pool.MarkReady(acc1) + + pool.Register(acc2, AccountInfo{Source: "brave"}, false) + pool.SetClient(acc2, c2) + pool.MarkReady(acc2) + + // 会话绑定到 acc2 + sessionKey := "test-session-key" + pool.BindSession(sessionKey, acc2) + + // 请求该会话时,即使 acc1 使用率较高,也必须优先沿用已绑定的 acc2,以复用上下文节省 Token + client, chosenID, release, err := pool.AcquireForModel(context.Background(), sessionKey, "gemini-4-argon-task", nil) + if err != nil || client == nil { + t.Fatalf("failed to acquire client: %v", err) + } + defer release() + + if chosenID != acc2 { + t.Fatalf("expected session affinity to stay on %s, got %s", acc2, chosenID) + } +} 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..1a2e474 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,288 @@ 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)) + combo := profile + "/" + email _, profileMatch := requestedProfiles[profile] _, emailMatch := requestedEmails[email] - if !profileMatch && !emailMatch { + _, targetMatchProfile := requestedTargets[profile] + _, targetMatchEmail := requestedTargets[email] + _, targetMatchCombo := requestedTargets[combo] + + matched := false + if len(requestedTargets) > 0 { + if targetMatchCombo || targetMatchProfile || targetMatchEmail { + matched = true + if targetMatchCombo { + foundTargets[combo] = struct{}{} + } + 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 +540,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 +575,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 +592,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 +605,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 +626,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 +648,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/internal/config/config.go b/internal/config/config.go index f5a7a85..93ab3af 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,8 @@ type Config struct { PerAccountConcurrency int // RoutingStrategy 为 round-robin 或 fill-first RoutingStrategy string + // AccountFallback 为 true 时遇到模型用量上限或错误自动换号重试 + AccountFallback bool // AdminAuthEnabled 为 true 时管理端要求登录并接受远程访问 AdminAuthEnabled bool AdminUsername string @@ -90,10 +92,16 @@ func Load() (Config, error) { if cfg.PerAccountConcurrency, err = parsePositiveInt("PER_ACCOUNT_CONCURRENCY", 1); err != nil { return Config{}, err } - cfg.RoutingStrategy = strings.ToLower(envOrDefault("ROUTING_STRATEGY", "round-robin")) + cfg.RoutingStrategy = strings.ToLower(envOrDefault("ROUTING_STRATEGY", "fill-first")) if cfg.RoutingStrategy != "round-robin" && cfg.RoutingStrategy != "fill-first" { return Config{}, fmt.Errorf("ROUTING_STRATEGY 取 round-robin 或 fill-first") } + cfg.AccountFallback = true + if strings.TrimSpace(os.Getenv("ACCOUNT_FALLBACK")) != "" { + if cfg.AccountFallback, err = parseBool("ACCOUNT_FALLBACK"); err != nil { + return Config{}, err + } + } cfg.AdminUsername = envOrDefault("ADMIN_USERNAME", "admin") cfg.AdminPassword = os.Getenv("ADMIN_PASSWORD") if cfg.AdminAuthEnabled && cfg.AdminPassword == "" { diff --git a/internal/gemini/client.go b/internal/gemini/client.go index 53410e3..7bdae22 100644 --- a/internal/gemini/client.go +++ b/internal/gemini/client.go @@ -93,6 +93,18 @@ func (c *Client) CachedUsage() *UsageInfo { return c.usage.Load() } +// SetUsageForTest 供测试设置用量快照 +func (c *Client) SetUsageForTest(usage *UsageInfo) { + c.usage.Store(usage) +} + +// SetBootstrapForTest 供测试设置运行参数 +func (c *Client) SetBootstrapForTest(bootstrap *Bootstrap) { + c.bootstrapMu.Lock() + defer c.bootstrapMu.Unlock() + c.bootstrap = bootstrap +} + // AcquireRequest 占用账号的一个请求槽直到完整上游请求链结束,后台生成的轮询阶段让出 func (c *Client) AcquireRequest(ctx context.Context) (func(), error) { c.busy.Add(1) @@ -505,7 +517,7 @@ func (c *Client) stream(ctx context.Context, request GenerateRequest, emit func( } } if err != nil { - if allowRefresh && !guard.Emitted() && isAuthenticationError(err) && c.refresh != nil { + if allowRefresh && !guard.Emitted() && IsAuthenticationError(err) && c.refresh != nil { return c.restream(ctx, version, request, emit) } return err @@ -963,6 +975,136 @@ func (c *Client) CloseIdleConnections() { c.mediaClient.CloseIdleConnections() } +// ProxyDiagnostic 是代理检测或诊断的完整详细结果 +type ProxyDiagnostic struct { + OK bool `json:"ok"` + Status string `json:"status"` // "ok", "captcha_blocked", "rate_limited", "network_error", "timeout", "http_error", "invalid_format" + LatencyMS int64 `json:"latency_ms"` + Message string `json:"message"` + Error string `json:"error,omitempty"` +} + +// isGoogleCaptchaRedirect 判断响应是否为 Google 人机验证(/sorry)重定向 +func isGoogleCaptchaRedirect(resp *http.Response) (bool, *url.URL) { + if resp == nil { + return false, nil + } + location, err := resp.Location() + if err != nil || location == nil { + return false, nil + } + host := strings.ToLower(location.Hostname()) + isGoogle := host == "google.com" || strings.HasSuffix(host, ".google.com") + if isGoogle && strings.HasPrefix(location.Path, "/sorry") { + return true, location + } + return false, nil +} + +// isGoogleLoginRedirect 判断响应是否为 Google 登录页重定向(会话失效,需重新登录) +func isGoogleLoginRedirect(resp *http.Response) (bool, *url.URL) { + if resp == nil { + return false, nil + } + location, err := resp.Location() + if err != nil || location == nil { + return false, nil + } + host := strings.ToLower(location.Hostname()) + isAccounts := host == "accounts.google.com" || strings.HasSuffix(host, ".accounts.google.com") + if isAccounts || strings.Contains(location.Path, "ServiceLogin") || strings.Contains(location.Path, "InteractiveLogin") { + return true, location + } + return false, nil +} + +// DiagnoseProxy 经代理以浏览器导航请求 Gemini 官网首页,返回详细的连通性、延迟与风控诊断信息 +func DiagnoseProxy(ctx context.Context, proxyURL string) ProxyDiagnostic { + timeout := 15 * time.Second + testCtx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + httpClient, err := NewHTTPClient(15, strings.TrimSpace(proxyURL)) + if err != nil { + return ProxyDiagnostic{ + OK: false, + Status: "network_error", + Message: fmt.Sprintf("初始化代理客戶端失敗: %v", err), + Error: err.Error(), + } + } + defer httpClient.CloseIdleConnections() + + req, err := http.NewRequestWithContext(testCtx, http.MethodGet, geminiOrigin+pathInit, nil) + if err != nil { + return ProxyDiagnostic{ + OK: false, + Status: "network_error", + Message: fmt.Sprintf("建立請求失敗: %v", err), + Error: err.Error(), + } + } + (&Client{language: defaultLanguage}).applyNavigationHeaders(req) + started := time.Now() + resp, err := httpClient.Do(req) + latencyMS := time.Since(started).Milliseconds() + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || (testCtx.Err() != nil && errors.Is(testCtx.Err(), context.DeadlineExceeded)) { + return ProxyDiagnostic{ + OK: false, + Status: "timeout", + LatencyMS: latencyMS, + Message: "代理連線逾時(15 秒內未回應)", + Error: err.Error(), + } + } + errMsg := err.Error() + msg := fmt.Sprintf("連線失敗: %v", err) + if strings.Contains(errMsg, "lookup") { + msg = fmt.Sprintf("DNS 解析失敗(請檢查域名或改用直接 IP): %v", err) + } else if strings.Contains(errMsg, "socks") && (strings.Contains(errMsg, "auth") || strings.Contains(errMsg, "user/password")) { + msg = "SOCKS5 帳號密碼認證失敗" + } else if strings.Contains(errMsg, "connection refused") { + msg = "連線被拒絕(代理伺服器未運行或連接埠錯誤)" + } + return ProxyDiagnostic{ + OK: false, + Status: "network_error", + LatencyMS: latencyMS, + Message: msg, + Error: errMsg, + } + } + defer resp.Body.Close() + + if isCaptcha, _ := isGoogleCaptchaRedirect(resp); isCaptcha || resp.StatusCode == http.StatusTooManyRequests { + return ProxyDiagnostic{ + OK: false, + Status: "captcha_blocked", + LatencyMS: latencyMS, + Message: "被 Google 人機驗證攔截(HTTP 302 重定向到 sorry/index),該出口 IP 受限", + Error: "Google 人機驗證頁攔截 (302 sorry/index)", + } + } + + if resp.StatusCode == http.StatusOK { + return ProxyDiagnostic{ + OK: true, + Status: "ok", + LatencyMS: latencyMS, + Message: fmt.Sprintf("連線正常!延遲 %d ms,Google Gemini 存取許可", latencyMS), + } + } + + return ProxyDiagnostic{ + OK: false, + Status: "http_error", + LatencyMS: latencyMS, + Message: fmt.Sprintf("Google 首頁返回狀態碼 HTTP %d", resp.StatusCode), + Error: resp.Status, + } +} + // ProbeProxy 经代理以浏览器导航请求官网首页,首页返回 200 时报告耗时 func ProbeProxy(ctx context.Context, proxyURL string) (time.Duration, error) { httpClient, err := NewHTTPClient(requestCeilingSeconds, proxyURL) @@ -987,9 +1129,9 @@ func ProbeProxy(ctx context.Context, proxyURL string) (time.Duration, error) { return time.Since(started), nil } -// renewable 判断错误是否续签后重试:会话失效的 401、403 与人机验证页拦截 +// renewable 判断错误是否续签后重试:会话失效(401、403、ErrSignedOut)与人机验证页拦截 func renewable(err error) bool { - return isAuthenticationError(err) || errors.Is(err, ErrCaptcha) + return IsAuthenticationError(err) || errors.Is(err, ErrCaptcha) } // freeTier 报告账号是否为免费账号,免费账号的回答与未登录会话来自同一模型 @@ -1034,13 +1176,19 @@ var ErrCaptcha = errors.New("请求被 Google 人机验证页拦截") // responseStatusError 把非 200 响应转为协议错误,重定向到 google.com/sorry 时为人机验证拦截,按 429 处理;/u/N 页面重定向到 Gemini 其他页面时表示 Cookie 会话中没有第 N 个账号,按会话失效处理 func responseStatusError(resp *http.Response, operation string) *ProtocolError { - location, err := resp.Location() - if err == nil && strings.HasSuffix(location.Hostname(), "google.com") && strings.HasPrefix(location.Path, "/sorry") { + if isCaptcha, location := isGoogleCaptchaRedirect(resp); isCaptcha { return &ProtocolError{ HTTPStatus: http.StatusTooManyRequests, Code: http.StatusTooManyRequests, Retryable: true, Cause: ErrCaptcha, Message: fmt.Sprintf("gemini %s 被 Google 人机验证页拦截(HTTP %d 重定向到 %s%s)", operation, resp.StatusCode, location.Host, location.Path), } } + if isLogin, location := isGoogleLoginRedirect(resp); isLogin { + return &ProtocolError{ + HTTPStatus: http.StatusUnauthorized, Code: http.StatusUnauthorized, Retryable: true, Cause: ErrSignedOut, + Message: fmt.Sprintf("gemini %s 重定向到登录页(HTTP %d 重定向到 %s%s),账号需要重新登录", operation, resp.StatusCode, location.Host, location.Path), + } + } + location, err := resp.Location() if err == nil && location.Hostname() == resp.Request.URL.Hostname() && strings.HasPrefix(resp.Request.URL.Path, "/u/") { return &ProtocolError{ HTTPStatus: http.StatusUnauthorized, Code: http.StatusUnauthorized, Retryable: true, Cause: ErrSignedOut, diff --git a/internal/gemini/decoder.go b/internal/gemini/decoder.go index 5757ac0..bac09e5 100644 --- a/internal/gemini/decoder.go +++ b/internal/gemini/decoder.go @@ -199,9 +199,9 @@ func bardErrorCode(record []any) (int, bool) { func bardProtocolError(code int) *ProtocolError { err := &ProtocolError{HTTPStatus: 502, Code: code, Retryable: true} switch code { - case 1037: + case 1037, 1173: err.HTTPStatus = 429 - err.Message = "Gemini Web 账号的当前模型用量已达上限 (1037)" + err.Message = fmt.Sprintf("Gemini Web 账号的当前模型用量已达上限 (%d)", code) case 1050: err.HTTPStatus = 400 err.Retryable = false diff --git a/internal/gemini/models.go b/internal/gemini/models.go index 4e68338..fa229fe 100644 --- a/internal/gemini/models.go +++ b/internal/gemini/models.go @@ -52,6 +52,15 @@ var featureTools = []featureTool{ {"gemini-guided-learning", "Guided Learning", "Gemini Web Guided Learning", FeatureLearning, []int{117}}, } +// NewModelCatalogForTest 供测试构造模型目录 +func NewModelCatalogForTest(models []Model) ModelCatalog { + catalog := ModelCatalog{models: models, lookup: make(map[string]int), defaultIndex: 0} + for i, m := range models { + catalog.lookup[strings.ToLower(m.ID)] = i + } + return catalog +} + // ModelCatalog 表示单个账号当前可用的模型目录 type ModelCatalog struct { models []Model diff --git a/internal/proxypool/pool.go b/internal/proxypool/pool.go index 959a86d..0316779 100644 --- a/internal/proxypool/pool.go +++ b/internal/proxypool/pool.go @@ -3,6 +3,7 @@ package proxypool import ( "context" "net/url" + "slices" "sort" "sync" "time" @@ -23,21 +24,23 @@ type Pool struct { // proxyState 是代理池中一个代理的检测结果 type proxyState struct { - url string - healthy bool - checked time.Time - latency time.Duration - err string + url string + healthy bool + checked time.Time + latency time.Duration + err string + cooldownUntil time.Time } // Status 是代理池中一个代理的当前状态与绑定的账号 type Status struct { - URL string `json:"url"` - Healthy bool `json:"healthy"` - CheckedAt time.Time `json:"checked_at,omitzero"` - LatencyMS int64 `json:"latency_ms"` - Error string `json:"error,omitempty"` - Accounts []string `json:"accounts"` + URL string `json:"url"` + Healthy bool `json:"healthy"` + CheckedAt time.Time `json:"checked_at,omitzero"` + LatencyMS int64 `json:"latency_ms"` + Error string `json:"error,omitempty"` + Accounts []string `json:"accounts"` + CooldownUntil time.Time `json:"cooldown_until,omitzero"` } // New 创建代理池,代理在首次检测前视为可用 @@ -82,12 +85,24 @@ func (p *Pool) SetProxies(urls []string) { p.rebalance() } +// isAvailable 报告代理当前是否健康且未处于冷却隔离期 +func isAvailable(state *proxyState, now time.Time) bool { + if state == nil || !state.healthy { + return false + } + if !state.cooldownUntil.IsZero() && now.Before(state.cooldownUntil) { + return false + } + return true +} + // rebalance 每次把账号最多的可用代理上 ID 最大的账号改绑到账号最少的可用代理,直到两者相差不超过一个 func (p *Pool) rebalance() { anyHealthy := p.anyHealthy() + now := time.Now() accounts := make(map[*proxyState][]string, len(p.proxies)) for _, state := range p.proxies { - if !anyHealthy || state.healthy { + if !anyHealthy || isAvailable(state, now) { accounts[state] = []string{} } } @@ -123,12 +138,7 @@ func (p *Pool) rebalance() { // contains 报告代理是否仍在列表中 func (p *Pool) contains(target *proxyState) bool { - for _, state := range p.proxies { - if state == target { - return true - } - } - return false + return slices.Contains(p.proxies, target) } // Assign 返回账号使用的代理:已绑定的代理可用时沿用,否则绑定可用代理中账号最少的一个;全部不可用时保持原绑定,代理池为空时返回系统代理 @@ -139,7 +149,8 @@ func (p *Pool) Assign(accountID string) string { delete(p.bindings, accountID) return System() } - if current := p.bindings[accountID]; current != nil && (current.healthy || !p.anyHealthy()) { + now := time.Now() + if current := p.bindings[accountID]; current != nil && (isAvailable(current, now) || !p.anyHealthy()) { return current.url } best := p.leastBound(accountID) @@ -159,8 +170,9 @@ func (p *Pool) Pick() string { // anyHealthy 报告是否有可用代理 func (p *Pool) anyHealthy() bool { + now := time.Now() for _, state := range p.proxies { - if state.healthy { + if isAvailable(state, now) { return true } } @@ -176,9 +188,10 @@ func (p *Pool) leastBound(accountID string) *proxyState { } } anyHealthy := p.anyHealthy() + now := time.Now() var best *proxyState for _, state := range p.proxies { - if anyHealthy && !state.healthy { + if anyHealthy && !isAvailable(state, now) { continue } if best == nil || counts[state] < counts[best] { @@ -230,23 +243,66 @@ func (p *Pool) Check(ctx context.Context, probe func(context.Context, string) (t now := time.Now() anyHealthy := false for index, state := range targets { - state.checked, state.latency, state.err = now, results[index].latency, "" + state.checked = now state.healthy = results[index].err == nil if !state.healthy { state.latency, state.err = 0, results[index].err.Error() + } else { + state.latency = results[index].latency + if now.After(state.cooldownUntil) { + state.cooldownUntil = time.Time{} + state.err = "" + } } - anyHealthy = anyHealthy || state.healthy + anyHealthy = anyHealthy || isAvailable(state, now) } if !anyHealthy { return nil } moved := []string{} for id, state := range p.bindings { - if !state.healthy { + if !isAvailable(state, now) { + moved = append(moved, id) + } + } + sort.Strings(moved) + return moved +} + +// MarkCooldown 将指定代理设为冷却隔离状态(如遭遇 Google 302 人机验证),并返回需要改绑的账号 ID +func (p *Pool) MarkCooldown(proxyURL string, duration time.Duration, reason string) []string { + p.mu.Lock() + defer p.mu.Unlock() + now := time.Now() + var target *proxyState + for _, state := range p.proxies { + if state.url == proxyURL { + target = state + break + } + } + if target == nil { + return nil + } + if !target.cooldownUntil.IsZero() && now.Before(target.cooldownUntil) { + return nil + } + target.cooldownUntil = now.Add(duration) + target.err = reason + + moved := []string{} + for id, state := range p.bindings { + if state == target { moved = append(moved, id) } } sort.Strings(moved) + if p.anyHealthy() { + for _, id := range moved { + p.bindings[id] = p.leastBound(id) + } + p.rebalance() + } return moved } @@ -258,13 +314,15 @@ func (p *Pool) Status() []Status { for id, state := range p.bindings { accounts[state] = append(accounts[state], id) } + now := time.Now() result := make([]Status, 0, len(p.proxies)) for _, state := range p.proxies { ids := append([]string{}, accounts[state]...) sort.Strings(ids) result = append(result, Status{ - URL: Redact(state.url), Healthy: state.healthy, CheckedAt: state.checked, + URL: Redact(state.url), Healthy: isAvailable(state, now), CheckedAt: state.checked, LatencyMS: state.latency.Milliseconds(), Error: state.err, Accounts: ids, + CooldownUntil: state.cooldownUntil, }) } return result diff --git a/internal/proxypool/pool_test.go b/internal/proxypool/pool_test.go new file mode 100644 index 0000000..d17c330 --- /dev/null +++ b/internal/proxypool/pool_test.go @@ -0,0 +1,83 @@ +package proxypool + +import ( + "context" + "testing" + "time" +) + +func TestPoolCooldownAndRebind(t *testing.T) { + pool := New([]string{ + "http://127.0.0.1:8001", + "http://127.0.0.1:8002", + }) + + // 初始状态下两代理均健康 + acc1 := "acc1@gmail.com" + acc2 := "acc2@gmail.com" + + proxy1 := pool.Assign(acc1) + proxy2 := pool.Assign(acc2) + + if proxy1 == proxy2 { + t.Fatalf("expected acc1 and acc2 to be distributed across different proxies, got both %s", proxy1) + } + + // 模拟 proxy1 遭 Google 302 拦截触发隔离冷却 + moved := pool.MarkCooldown(proxy1, 30*time.Minute, "遭遇 Google 人机验证拦截 (302)") + if len(moved) != 1 || moved[0] != acc1 { + t.Fatalf("expected acc1 to be moved, got %v", moved) + } + + // 此时 acc1 应该被改绑至唯一健康的 proxy2 + newProxy1 := pool.Assign(acc1) + if newProxy1 != proxy2 { + t.Fatalf("expected acc1 to be re-bound to proxy2 (%s), got %s", proxy2, newProxy1) + } + + // 检查代理状态列表,proxy1 应处于 CooldownUntil 期间且 healthy=false + statuses := pool.Status() + for _, status := range statuses { + switch status.URL { + case proxy1: + if status.Healthy { + t.Errorf("expected %s to be marked unhealthy during cooldown", proxy1) + } + if status.CooldownUntil.IsZero() { + t.Errorf("expected %s to have CooldownUntil set", proxy1) + } + case proxy2: + if !status.Healthy { + t.Errorf("expected %s to be healthy", proxy2) + } + } + } + + // 测试 Check probe 在冷却期内不应破坏隔离 + pool.Check(context.Background(), func(ctx context.Context, s string) (time.Duration, error) { + return 50 * time.Millisecond, nil + }) + + for _, status := range pool.Status() { + if status.URL == proxy1 && status.Healthy { + t.Errorf("expected %s to remain in cooldown isolation after probe during cooldown window", proxy1) + } + } + + // 模拟冷却时间过后再次 Check,应恢复健康 + pool.mu.Lock() + for _, state := range pool.proxies { + state.cooldownUntil = time.Now().Add(-1 * time.Minute) + } + pool.mu.Unlock() + + pool.Check(context.Background(), func(ctx context.Context, s string) (time.Duration, error) { + return 50 * time.Millisecond, nil + }) + + for _, status := range pool.Status() { + if !status.Healthy { + t.Errorf("expected all proxies to be restored to healthy after cooldown expired, but %s was not", status.URL) + } + } +} 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..ace97e9 100644 --- a/web/src/features/accounts/AccountsPage.tsx +++ b/web/src/features/accounts/AccountsPage.tsx @@ -1,10 +1,11 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' -import { Globe, Power, PowerOff, ShieldCheck, Users } from 'lucide-react' +import { Globe, Power, PowerOff, ShieldCheck, Trash2, Users } from 'lucide-react' import { useState } from 'react' import { Link } from 'react-router' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Dialog, DialogClose, DialogContent } from '@/components/ui/dialog' import { Card, CountBadge, PageHeader } from '@/components/ui/page' import { EmptyState, ErrorState, Loading } from '@/components/ui/states' import { Table, Td, Th } from '@/components/ui/table' @@ -27,7 +28,7 @@ import { useNow } from '@/lib/time' const batchLimit = 3 // BatchAction 是可对多个账号执行的操作 -type BatchAction = 'proxy' | 'enable' | 'disable' | 'verify' +type BatchAction = 'proxy' | 'enable' | 'disable' | 'verify' | 'delete' // BatchRequest 是一次批量操作的目标账号与要设置的代理 interface BatchRequest { @@ -40,6 +41,8 @@ interface BatchRequest { function applyBatch({ action, proxy = '' }: BatchRequest, account: Account) { const path = '/api/accounts/' + encodeURIComponent(account.account_id) switch (action) { + case 'delete': + return api(path, { method: 'DELETE' }) case 'verify': return api(path + '/verify', { method: 'POST' }) case 'proxy': @@ -61,6 +64,7 @@ export function AccountsPage() { const accounts = useAccounts() const [selection, setSelection] = useState>(new Set()) const [proxyOpen, setProxyOpen] = useState(false) + const [deleteOpen, setDeleteOpen] = useState(false) const batch = useMutation({ mutationFn: (request: BatchRequest) => runBatch( @@ -72,7 +76,13 @@ export function AccountsPage() { }, ), onSuccess: (failures, request) => { - const labels = { proxy: m.setProxy, enable: m.enable, disable: m.disable, verify: m.verify } + const labels = { + proxy: m.setProxy, + enable: m.enable, + disable: m.disable, + verify: m.verify, + delete: m.batchDelete ?? m.deleteTitle, + } const result = fill(m.batchResult, { action: labels[request.action], succeeded: request.accounts.length - failures.length, @@ -85,9 +95,14 @@ export function AccountsPage() { tone: failures.length === 0 ? 'success' : 'error', title: failures.length === 0 ? result : fill(m.batchFailures, { result, items }), }) + if (request.action === 'delete') { + const failedIds = new Set(failures.map((f) => f.id)) + setSelection((current) => new Set([...current].filter((id) => failedIds.has(id)))) + } }, onSettled: () => { setProxyOpen(false) + setDeleteOpen(false) void client.invalidateQueries({ queryKey: accountsKey }) }, }) @@ -200,6 +215,18 @@ export function AccountsPage() { {m.verify} + + + + + } + > + {m.deleteTitle} + + + )} ) } diff --git a/web/src/features/accounts/ImportPage.tsx b/web/src/features/accounts/ImportPage.tsx index 72cd5fd..4dd74c7 100644 --- a/web/src/features/accounts/ImportPage.tsx +++ b/web/src/features/accounts/ImportPage.tsx @@ -1,5 +1,5 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' @@ -8,123 +8,219 @@ 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 { ProxyInput } from '@/features/accounts/ProxyInput' 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 items = profiles.data?.profiles ?? [] + + const emailCounts = items.reduce>((acc, p) => { + if (p.email) acc[p.email] = (acc[p.email] ?? 0) + 1 + return acc + }, {}) + + const profileTarget = (p: { email?: string; profile: string }) => { + if (p.email && (emailCounts[p.email] ?? 0) > 1) { + return `${p.profile}/${p.email}` + } + return p.email || p.profile + } + + useEffect(() => { + if (profiles.data?.profiles) { + const unselectable = new Set( + profiles.data.profiles + .filter((p) => !p.importable || p.imported) + .map((p) => profileTarget(p)), + ) + setSelected((current) => current.filter((target) => !unselectable.has(target))) + } + }, [profiles.data]) 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) => { + const email = target.includes('/') ? target.split('/')[1] : target + return !importedTargets.has(target) && !importedTargets.has(email) + }), + ) + + const failureCount = data.failures?.length ?? 0 + const successCount = data.accounts.length + + if (failureCount > 0 && successCount > 0) { + const details = data.failures?.map((f) => `${f.target} (${f.error})`).join('; ') + toast({ + tone: 'error', + title: details + ? `${fill(m.importPartial, { succeeded: successCount, failed: failureCount })}:${details}` + : fill(m.importPartial, { succeeded: successCount, failed: failureCount }), + }) + } else if (failureCount > 0 && successCount === 0) { + const rawErr = data.failures?.[0]?.error ?? m.readFailed + const isCaptcha = rawErr.includes('人机验证') || rawErr.includes('sorry/index') + toast({ + tone: 'error', + title: isCaptcha ? `${m.captchaBlockedTitle}:${m.captchaBlockedHint}` : rawErr, + }) + } else { + toast({ tone: 'success', title: m.importDone }) + } }, onError: (error) => { - toast({ tone: 'error', title: error.message }) + const isCaptcha = error.message.includes('人机验证') || error.message.includes('sorry/index') + toast({ + tone: 'error', + title: isCaptcha ? `${m.captchaBlockedTitle}:${m.captchaBlockedHint}` : 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], ) } 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 = profileTarget(profile) + const isSelectable = profile.importable && !profile.imported + return ( +
  • + +
  • + ) + })} +
+ )} + + + +
+ +
) @@ -150,7 +246,11 @@ function CookieImport() { setCookies('') }, onError: (error) => { - toast({ tone: 'error', title: error.message }) + const isCaptcha = error.message.includes('人机验证') || error.message.includes('sorry/index') + toast({ + tone: 'error', + title: isCaptcha ? `${m.captchaBlockedTitle}:${m.captchaBlockedHint}` : error.message, + }) }, }) return ( @@ -188,13 +288,12 @@ function CookieImport() { />
- { - setProxy(event.target.value) - }} + onChange={setProxy} />
@@ -276,14 +375,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/ProxyDialog.tsx b/web/src/features/accounts/ProxyDialog.tsx index 81d16ff..5cd3824 100644 --- a/web/src/features/accounts/ProxyDialog.tsx +++ b/web/src/features/accounts/ProxyDialog.tsx @@ -3,8 +3,8 @@ import { useState } from 'react' import { Button } from '@/components/ui/button' import { Dialog, DialogClose, DialogContent } from '@/components/ui/dialog' import { Field } from '@/components/ui/field' -import { Input } from '@/components/ui/input' import { accountsMessages } from '@/features/accounts/messages' +import { ProxyInput } from '@/features/accounts/ProxyInput' import { useMessages } from '@/lib/i18n' // ProxyDialog 是填写账号固定代理的对话框,以 initial 为初值,留空表示从代理池分配,direct 表示直连 @@ -50,13 +50,12 @@ export function ProxyDialog({ } > - { - setProxy(event.target.value) - }} + onChange={setProxy} /> diff --git a/web/src/features/accounts/ProxyInput.tsx b/web/src/features/accounts/ProxyInput.tsx new file mode 100644 index 0000000..8fa9fdf --- /dev/null +++ b/web/src/features/accounts/ProxyInput.tsx @@ -0,0 +1,114 @@ +import { useMutation } from '@tanstack/react-query' +import { AlertCircle, AlertTriangle, CheckCircle2, Radio } from 'lucide-react' +import { useState } from 'react' + +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { importMessages } from '@/features/accounts/messages' +import type { ProxyDiagnostic } from '@/features/settings/types' +import { api } from '@/lib/api' +import { useMessages } from '@/lib/i18n' + +// ProxyInput 提供代理输入框与连接测试按钮,即时回显连接状态、延迟与风控诊断 +export function ProxyInput({ + id, + value, + placeholder = 'http://127.0.0.1:7890', + testLabel, + disabled = false, + onChange, +}: { + id: string + value: string + placeholder?: string + testLabel?: string + disabled?: boolean + onChange: (value: string) => void +}) { + const m = useMessages(importMessages) + const [diagnostic, setDiagnostic] = useState(null) + + const test = useMutation({ + mutationFn: (target: string) => + api('/api/proxies/test', { + method: 'POST', + json: { proxy: target }, + }), + onSuccess: (data, target) => { + // 避免慢请求返回后覆盖用户已修改的新输入 + if (target === value.trim()) { + setDiagnostic(data) + } + }, + onError: (err, target) => { + if (target === value.trim()) { + setDiagnostic({ + ok: false, + status: 'network_error', + latency_ms: 0, + message: err.message, + }) + } + }, + }) + + return ( +
+
+ { + if (diagnostic) setDiagnostic(null) + onChange(event.target.value) + }} + /> + +
+ + {diagnostic && ( +
+ {diagnostic.ok ? ( + + ) : diagnostic.status === 'captcha_blocked' ? ( + + ) : ( + + )} +
+ {diagnostic.message} + {diagnostic.status === 'captcha_blocked' && ( + {m.captchaDiagnosticHint} + )} + {diagnostic.error && diagnostic.status !== 'captcha_blocked' && ( + {diagnostic.error} + )} +
+
+ )} +
+ ) +} diff --git a/web/src/features/accounts/messages.ts b/web/src/features/accounts/messages.ts index d8b6a73..678f5b8 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: '无', @@ -179,6 +182,7 @@ export const accountsMessages = defineMessages({ runtimeInfo: '运行状态', proxyTitle: '修改代理', proxyHint: '留空时从代理池分配,填 direct 表示直连;支持 http、https、socks4、socks5', + testProxy: '测试连接', direct: '直连', proxySourceFixed: '固定', proxySourcePool: '代理池', @@ -196,6 +200,11 @@ export const accountsMessages = defineMessages({ setProxy: '设置代理', batchProxyTitle: '为 {count} 个账号设置代理', batchProxyTitleOne: '为 {count} 个账号设置代理', + batchDelete: '删除账号', + batchDeleteTitle: '删除 {count} 个账号', + batchDeleteTitleOne: '删除 {count} 个账号', + batchDeleteHint: '删除后会移除本机的认证文件,需要重新导入。确定删除所选的 {count} 个账号?', + batchDeleteHintOne: '删除后会移除本机的认证文件,需要重新导入。确定删除所选的 {count} 个账号?', clearSelection: '取消选择', batchResult: '{action}:成功 {succeeded} 个,失败 {failed} 个', batchFailures: '{result}。失败项:{items}', @@ -216,6 +225,9 @@ export const accountsMessages = defineMessages({ emptyHint: '從本機 Chrome、Cookie 或遠端配對匯入帳號', import: '匯入帳號', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: '從未', none: '無', @@ -253,6 +265,7 @@ export const accountsMessages = defineMessages({ runtimeInfo: '執行狀態', proxyTitle: '修改代理', proxyHint: '留空時從代理池分配,填 direct 表示直連;支援 http、https、socks4、socks5', + testProxy: '測試連線', direct: '直連', proxySourceFixed: '固定', proxySourcePool: '代理池', @@ -270,6 +283,11 @@ export const accountsMessages = defineMessages({ setProxy: '設定代理', batchProxyTitle: '為 {count} 個帳號設定代理', batchProxyTitleOne: '為 {count} 個帳號設定代理', + batchDelete: '刪除帳號', + batchDeleteTitle: '刪除 {count} 個帳號', + batchDeleteTitleOne: '刪除 {count} 個帳號', + batchDeleteHint: '刪除後會移除本機的認證檔案,需要重新匯入。確定刪除所選取的 {count} 個帳號?', + batchDeleteHintOne: '刪除後會移除本機的認證檔案,需要重新匯入。確定刪除所選取的 {count} 個帳號?', clearSelection: '取消選擇', batchResult: '{action}:成功 {succeeded} 個,失敗 {failed} 個', batchFailures: '{result}。失敗項目:{items}', @@ -290,6 +308,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', @@ -329,6 +350,7 @@ export const accountsMessages = defineMessages({ proxyTitle: 'Edit proxy', proxyHint: 'Leave empty to use the proxy pool, or enter direct to connect without a proxy; http, https, socks4 and socks5', + testProxy: 'Test', direct: 'Direct', proxySourceFixed: 'Fixed', proxySourcePool: 'Pool', @@ -346,6 +368,13 @@ export const accountsMessages = defineMessages({ setProxy: 'Set proxy', batchProxyTitle: 'Set proxy for {count} accounts', batchProxyTitleOne: 'Set proxy for {count} account', + batchDelete: 'Delete accounts', + batchDeleteTitle: 'Delete {count} accounts', + batchDeleteTitleOne: 'Delete {count} account', + batchDeleteHint: + 'Deletion removes the local auth files and requires re-import. Delete {count} selected accounts?', + batchDeleteHintOne: + 'Deletion removes the local auth file and requires re-import. Delete {count} selected account?', clearSelection: 'Clear selection', batchResult: '{action}: {succeeded} succeeded, {failed} failed', batchFailures: '{result}. Failed: {items}', @@ -366,6 +395,9 @@ export const accountsMessages = defineMessages({ emptyHint: 'ローカルの Chrome、Cookie、またはリモートペアリングでインポートします', import: 'アカウントをインポート', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: 'Cookie', never: '未使用', none: 'なし', @@ -405,6 +437,7 @@ export const accountsMessages = defineMessages({ proxyTitle: 'プロキシを変更', proxyHint: '空欄の場合はプロキシプールから割り当て、direct と入力すると直接接続します。http、https、socks4、socks5 に対応', + testProxy: '接続テスト', direct: '直接接続', proxySourceFixed: '固定', proxySourcePool: 'プール', @@ -423,6 +456,13 @@ export const accountsMessages = defineMessages({ setProxy: 'プロキシを設定', batchProxyTitle: '{count} 件のアカウントにプロキシを設定', batchProxyTitleOne: '{count} 件のアカウントにプロキシを設定', + batchDelete: 'アカウントを削除', + batchDeleteTitle: '{count} 件のアカウントを削除', + batchDeleteTitleOne: '{count} 件のアカウントを削除', + batchDeleteHint: + '削除するとローカルの認証ファイルが消え、再インポートが必要です。選択した {count} 件のアカウントを削除しますか?', + batchDeleteHintOne: + '削除するとローカルの認証ファイルが消え、再インポートが必要です。選択した {count} 件のアカウントを削除しますか?', clearSelection: '選択を解除', batchResult: '{action}:成功 {succeeded} 件、失敗 {failed} 件', batchFailures: '{result}。失敗:{items}', @@ -443,6 +483,9 @@ export const accountsMessages = defineMessages({ emptyHint: '로컬 Chrome, 쿠키 또는 원격 페어링으로 가져옵니다', import: '계정 가져오기', sourceChrome: 'Chrome', + sourceBrave: 'Brave', + sourceEdge: 'Edge', + sourceFirefox: 'Firefox', sourceCookie: '쿠키', never: '사용 기록 없음', none: '없음', @@ -482,6 +525,7 @@ export const accountsMessages = defineMessages({ proxyTitle: '프록시 수정', proxyHint: '비워 두면 프록시 풀에서 할당하고, direct를 입력하면 직접 연결합니다. http, https, socks4, socks5 지원', + testProxy: '연결 테스트', direct: '직접 연결', proxySourceFixed: '고정', proxySourcePool: '프록시 풀', @@ -499,6 +543,13 @@ export const accountsMessages = defineMessages({ setProxy: '프록시 설정', batchProxyTitle: '계정 {count}개에 프록시 설정', batchProxyTitleOne: '계정 {count}개에 프록시 설정', + batchDelete: '계정 삭제', + batchDeleteTitle: '계정 {count}개 삭제', + batchDeleteTitleOne: '계정 {count}개 삭제', + batchDeleteHint: + '삭제하면 로컬 인증 파일이 제거되어 다시 가져와야 합니다. 선택한 계정 {count}개를 삭제할까요?', + batchDeleteHintOne: + '삭제하면 로컬 인증 파일이 제거되어 다시 가져와야 합니다. 선택한 계정 {count}개를 삭제할까요?', clearSelection: '선택 해제', batchResult: '{action}: 성공 {succeeded}개, 실패 {failed}개', batchFailures: '{result}. 실패: {items}', @@ -519,6 +570,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', @@ -558,6 +612,7 @@ export const accountsMessages = defineMessages({ proxyTitle: 'Modifier le proxy', proxyHint: 'Laissez vide pour utiliser le pool de proxys, ou saisissez direct pour une connexion directe ; http, https, socks4 et socks5', + testProxy: 'Tester', direct: 'Direct', proxySourceFixed: 'Fixe', proxySourcePool: 'Pool', @@ -576,6 +631,13 @@ export const accountsMessages = defineMessages({ setProxy: 'Définir le proxy', batchProxyTitle: 'Définir le proxy de {count} comptes', batchProxyTitleOne: 'Définir le proxy de {count} compte', + batchDelete: 'Supprimer les comptes', + batchDeleteTitle: 'Supprimer {count} comptes', + batchDeleteTitleOne: 'Supprimer {count} compte', + batchDeleteHint: + 'La suppression efface les fichiers d’authentification locaux et impose une nouvelle importation. Supprimer les {count} comptes sélectionnés ?', + batchDeleteHintOne: + 'La suppression efface le fichier d’authentification local et impose une nouvelle importation. Supprimer le compte sélectionné ?', clearSelection: 'Désélectionner', batchResult: '{action} — réussis : {succeeded}, échecs : {failed}', batchFailures: '{result}. Échecs : {items}', @@ -596,6 +658,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', @@ -635,6 +700,7 @@ export const accountsMessages = defineMessages({ proxyTitle: 'Proxy ändern', proxyHint: 'Leer lassen für den Proxy-Pool, direct für eine direkte Verbindung; http, https, socks4 und socks5', + testProxy: 'Testen', direct: 'Direkt', proxySourceFixed: 'Fest', proxySourcePool: 'Pool', @@ -653,6 +719,13 @@ export const accountsMessages = defineMessages({ setProxy: 'Proxy festlegen', batchProxyTitle: 'Proxy für {count} Konten festlegen', batchProxyTitleOne: 'Proxy für {count} Konto festlegen', + batchDelete: 'Konten löschen', + batchDeleteTitle: '{count} Konten löschen', + batchDeleteTitleOne: '{count} Konto löschen', + batchDeleteHint: + 'Beim Löschen werden die lokalen Authentifizierungsdateien entfernt; danach ist ein erneuter Import nötig. Die ausgewählten {count} Konten löschen?', + batchDeleteHintOne: + 'Beim Löschen wird die lokale Authentifizierungsdatei entfernt; danach ist ein erneuter Import nötig. Das ausgewählte {count} Konto löschen?', clearSelection: 'Auswahl aufheben', batchResult: '{action}: {succeeded} erfolgreich, {failed} fehlgeschlagen', batchFailures: '{result}. Fehlgeschlagen: {items}', @@ -665,15 +738,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: '账号标识', @@ -684,8 +764,15 @@ export const importMessages = defineMessages({ '浏览器登录多个 Google 账号时,填官网地址 gemini.google.com/u/N/app 中的 N;只登录一个账号时为 0', proxy: '代理', proxyHint: '留空时从代理池分配;填写后作为该账号的固定代理,填 direct 表示直连', + testProxy: '测试连接', + captchaBlockedTitle: '导入失败:出口 IP 遭 Google 人机验证拦截', + captchaBlockedHint: + '当前出口 IP 被 Google 判定为异常流量(302 重定向到 sorry/index)。建议在“代理”填入可用的 SOCKS5/HTTP 代理,或在“设置”页配置代理池后重试。', + captchaDiagnosticHint: + 'Google 判定为异常流量并下发了人机验证(HTTP 302)。建议更换其他地区节点或使用干净的家宽代理。', importCookie: '导入', importDone: '导入完成', + importPartial: '成功导入 {succeeded} 个,失败 {failed} 个', pairingTitle: '远程配对', pairingHint: '服务所在的机器没有 Chrome 时,在登录了 Chrome 的电脑上运行下面的命令,按提示粘贴配对令牌', @@ -700,15 +787,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: '帳號識別', @@ -719,8 +813,15 @@ export const importMessages = defineMessages({ '瀏覽器登入多個 Google 帳號時,填官網網址 gemini.google.com/u/N/app 中的 N;只登入一個帳號時為 0', proxy: '代理', proxyHint: '留空時從代理池分配;填寫後作為該帳號的固定代理,填 direct 表示直連', + testProxy: '測試連線', + captchaBlockedTitle: '匯入失敗:出口 IP 遭 Google 人機驗證攔截', + captchaBlockedHint: + '目前網路出口 IP 被 Google 風控判定為異常流量(302 重定向到 sorry/index)。建議在下方「代理」填入可用之 SOCKS5/HTTP 代理,或至「設定」頁面配置代理池後重試。', + captchaDiagnosticHint: + 'Google 判定為異常流量並下發了人機驗證(HTTP 302)。建議更換其他地區節點或使用乾淨的家寬代理。', importCookie: '匯入', importDone: '匯入完成', + importPartial: '成功匯入 {succeeded} 個,失敗 {failed} 個', pairingTitle: '遠端配對', pairingHint: '服務所在的機器沒有 Chrome 時,在登入了 Chrome 的電腦上執行下列指令,依提示貼上配對權杖', @@ -735,15 +836,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', @@ -755,8 +863,15 @@ export const importMessages = defineMessages({ proxy: 'Proxy', proxyHint: 'Leave empty to use the proxy pool; a value becomes this account’s fixed proxy, and direct connects without a proxy', + testProxy: 'Test', + captchaBlockedTitle: 'Import Failed: Blocked by Google Captcha', + captchaBlockedHint: + 'Your outbound IP was flagged by Google (HTTP 302 redirect to sorry/index). Please enter a valid SOCKS5/HTTP proxy below, or configure the proxy pool in Settings.', + captchaDiagnosticHint: + 'Google flagged this IP as suspicious traffic (HTTP 302). Please switch to another node or use a clean residential 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 +887,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', @@ -792,8 +914,15 @@ export const importMessages = defineMessages({ proxy: 'プロキシ', proxyHint: '空欄の場合はプロキシプールから割り当てます。入力するとこのアカウントの固定プロキシになり、direct で直接接続します', + testProxy: '接続テスト', + captchaBlockedTitle: 'インポート失敗:Google Captcha による制限', + captchaBlockedHint: + '現在の送信元 IP が Google に検出されました(302 sorry/index)。プロキシを入力するか、設定ページでプロキシプールを設定してください。', + captchaDiagnosticHint: + 'Google により異常なトラフィックと判定され、Captcha(HTTP 302)が返されました。別のノードに変更してください。', importCookie: 'インポート', importDone: 'インポートしました', + importPartial: '{succeeded} 件のインポートに成功、{failed} 件が失敗', pairingTitle: 'リモートペアリング', pairingHint: 'サービスを動かすマシンに Chrome がない場合は、Chrome にログインしたパソコンで次のコマンドを実行し、表示に従ってペアリングトークンを貼り付けます', @@ -808,15 +937,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: '계정 식별자', @@ -828,8 +964,15 @@ export const importMessages = defineMessages({ proxy: '프록시', proxyHint: '비워 두면 프록시 풀에서 할당합니다. 입력하면 이 계정의 고정 프록시가 되며, direct는 직접 연결합니다', + testProxy: '연결 테스트', + captchaBlockedTitle: '가져오기 실패: Google 보안 봇에 의해 차단됨', + captchaBlockedHint: + '현재 아웃바운드 IP가 Google에 의해 비정상 트래픽으로 감지되었습니다 (302 sorry/index). 프록시를 입력하거나 설정 페이지에서 프록시 풀을 구성하세요.', + captchaDiagnosticHint: + 'Google이 비정상 트래픽으로 감지하여 Captcha(HTTP 302)를 보냈습니다. 다른 노드로 전환하세요.', importCookie: '가져오기', importDone: '가져오기 완료', + importPartial: '{succeeded}개 가져오기 성공, {failed}개 실패', pairingTitle: '원격 페어링', pairingHint: '서비스가 실행되는 머신에 Chrome이 없으면 Chrome에 로그인된 컴퓨터에서 아래 명령을 실행하고 안내에 따라 페어링 토큰을 붙여 넣습니다', @@ -845,15 +988,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', @@ -865,8 +1015,15 @@ export const importMessages = defineMessages({ proxy: 'Proxy', proxyHint: 'Laissez vide pour utiliser le pool de proxys ; une valeur devient le proxy fixe de ce compte, direct pour une connexion directe', + testProxy: 'Tester', + captchaBlockedTitle: 'Échec de l’importation : Bloqué par Google Captcha', + captchaBlockedHint: + 'Votre adresse IP de sortie a été bloquée par Google (HTTP 302 sorry/index). Veuillez entrer un proxy SOCKS5/HTTP ou configurer le pool de proxys.', + captchaDiagnosticHint: + 'Google a signalé cette adresse IP comme suspecte (HTTP 302). Veuillez changer de nœud proxy.', 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 +1039,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', @@ -902,8 +1066,15 @@ export const importMessages = defineMessages({ proxy: 'Proxy', proxyHint: 'Leer lassen für den Proxy-Pool; ein Wert wird zum festen Proxy dieses Kontos, direct für eine direkte Verbindung', + testProxy: 'Testen', + captchaBlockedTitle: 'Import fehlgeschlagen: Von Google Captcha blockiert', + captchaBlockedHint: + 'Ihre ausgehende IP-Adresse wurde von Google blockiert (HTTP 302 sorry/index). Bitte geben Sie einen SOCKS5/HTTP-Proxy an oder konfigurieren Sie den Proxy-Pool.', + captchaDiagnosticHint: + 'Google hat diese IP als verdächtigen Datenverkehr markiert (HTTP 302). Bitte wechseln Sie den Proxy-Knoten.', 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 diff --git a/web/src/features/settings/ProxyPoolCard.tsx b/web/src/features/settings/ProxyPoolCard.tsx index 5af730b..284cc40 100644 --- a/web/src/features/settings/ProxyPoolCard.tsx +++ b/web/src/features/settings/ProxyPoolCard.tsx @@ -63,7 +63,9 @@ export function ProxyPoolCard() { )} - {!proxy.checked_at ? ( + {proxy.cooldown_until && new Date(proxy.cooldown_until) > new Date() ? ( + {m.poolCooldown} + ) : !proxy.checked_at ? ( {m.poolUnchecked} ) : proxy.healthy ? ( {m.poolHealthy} diff --git a/web/src/features/settings/SettingsPage.tsx b/web/src/features/settings/SettingsPage.tsx index f4aca80..06f3440 100644 --- a/web/src/features/settings/SettingsPage.tsx +++ b/web/src/features/settings/SettingsPage.tsx @@ -3,6 +3,7 @@ import { type ReactNode, useState } from 'react' import { useNavigate, useParams } from 'react-router' import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' import { Callout } from '@/components/ui/callout' import { Input, Textarea } from '@/components/ui/input' import { NumberInput } from '@/components/ui/number-input' @@ -29,6 +30,7 @@ const editableKeys = [ 'proxy_api_key', 'proxy', 'save_history', + 'account_fallback', 'model_mapping', 'account_cooldown', 'session_ttl', @@ -268,6 +270,15 @@ export function SettingsPage() { }} /> + + { + set('account_fallback', next) + }} + /> + -