Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Binaries and executables
*.exe
*.exe~
*.dll
*.so
*.dylib
Expand Down
10 changes: 5 additions & 5 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <arguments>` so the prompt returns after the program exits:
Expand All @@ -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
Expand All @@ -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:
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <参数>`,命令行等程序结束后返回:
Expand All @@ -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/` 都在上表的数据目录中

### 手动启动
Expand All @@ -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 续签能力:
Expand Down
214 changes: 186 additions & 28 deletions internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -106,6 +110,7 @@ func (m *Manager) Start(accounts []auth.LoadedAccount) {
m.launchGuest(wait.Done)
wait.Wait()
go m.watchProxies()
go m.watchQuotas()
}

// guestReloadInterval 是访客会话重读首页与模型目录的间隔,与账号的默认轮换间隔相同
Expand Down Expand Up @@ -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()
}
Expand All @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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 导入账号并启动
Expand Down
Loading
Loading