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
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
143 changes: 115 additions & 28 deletions internal/accounts/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -214,10 +214,13 @@ func (m *Manager) Add(account auth.LoadedAccount) error {
func (m *Manager) launch(account auth.LoadedAccount, firstDone func()) {
proxy, source := m.accountProxy(account)
account.ProxyURL = proxy
info := balancer.AccountInfo{Source: "cookie", Profile: account.Profile, Proxy: proxy, ProxySource: source}
if account.OAuth != nil {
info.Source = "chrome"
accountSource := "cookie"
if account.Browser != "" {
accountSource = account.Browser
} else if account.OAuth != nil {
accountSource = "chrome"
}
info := balancer.AccountInfo{Source: accountSource, Profile: account.Profile, Proxy: proxy, ProxySource: source}
if account.File != nil {
info.Path = account.File.Path()
}
Expand All @@ -237,6 +240,8 @@ func (m *Manager) launch(account auth.LoadedAccount, firstDone func()) {

if account.OAuth != nil {
account.Source.Refresh = newChromeRenewal(account).refresh
} else if account.Browser != "" && account.Profile != "" {
account.Source.Refresh = newBrowserRenewal(account).refresh
}
account.Source.Concurrency = settings.Concurrency
client, err := gemini.NewClient(account.Source, account.ProxyURL, settings.SaveHistory)
Expand Down Expand Up @@ -509,6 +514,11 @@ func (m *Manager) Verify(accountID string) error {
return fmt.Errorf("账号 %q 已停用", accountID)
}
logging.Info(accountID, "账号重新验证")
if entry.account.OAuth == nil && entry.account.Browser != "" && entry.account.Profile != "" {
if cookies, err := newBrowserRenewal(entry.account).refresh(m.ctx); err == nil && len(cookies) > 0 {
entry.account.Source.Cookies = cookies
}
}
if err := m.restart(entry.account); err != nil {
return err
}
Expand Down Expand Up @@ -587,54 +597,131 @@ func (m *Manager) IDs() []string {
return append([]string(nil), m.order...)
}

// ChromeAccounts 列出本机 Chrome 中可导入的账号,并标出已导入的账号
func (m *Manager) ChromeAccounts() ([]ChromeAccount, error) {
root, err := chromeauth.DefaultChromeRoot()
// BrowserAccounts 列出本机指定浏览器中可导入的账号,并标出已导入的账号
func (m *Manager) BrowserAccounts(browser string) ([]BrowserAccount, error) {
if browser == "" {
browser = chromeauth.BrowserChrome
}
root, err := chromeauth.DefaultBrowserRoot(browser)
if err != nil {
return nil, err
}
discovered, err := chromeauth.Discover(root)
discovered, err := chromeauth.DiscoverBrowser(browser, root)
if err != nil {
return nil, err
}
imported := make(map[string]bool)
for _, id := range m.IDs() {
imported[id] = true
importedEmails := make(map[string]bool)
importedIDs := make(map[string]bool)
importedNoEmailProfiles := make(map[string]bool)
m.mu.Lock()
for id, entry := range m.accounts {
importedIDs[strings.ToLower(id)] = true
var email string
if client := m.pool.Client(id); client != nil && client.Email() != "" {
email = client.Email()
}
if email != "" {
importedEmails[strings.ToLower(strings.TrimSpace(email))] = true
importedIDs[chromeauth.AccountID(email)] = true
}
if entry != nil {
b := entry.account.Browser
if b == "" && entry.account.OAuth != nil {
b = chromeauth.BrowserChrome
}
prof := strings.ToLower(entry.account.Profile)
if email == "" && b != "" && prof != "" {
importedNoEmailProfiles[b+"/"+prof] = true
}
}
}
result := make([]ChromeAccount, 0, len(discovered))
m.mu.Unlock()

result := make([]BrowserAccount, 0, len(discovered))
for _, account := range discovered {
result = append(result, ChromeAccount{Account: account, Imported: imported[chromeauth.AccountID(account.Email)]})
isImported := false
email := strings.ToLower(strings.TrimSpace(account.Email))
targetID := chromeauth.AccountID(email)

if email != "" && strings.Contains(email, "@") {
if importedEmails[email] || (targetID != "" && importedIDs[targetID]) {
isImported = true
}
} else {
profID := chromeauth.AccountID(account.Profile)
b := strings.ToLower(account.Browser)
if b == "" {
b = browser
}
profKey := b + "/" + strings.ToLower(account.Profile)
if (profID != "" && importedIDs[profID]) || importedNoEmailProfiles[profKey] {
isImported = true
}
}
result = append(result, BrowserAccount{Account: account, Imported: isImported})
}
sort.SliceStable(result, func(i, j int) bool { return result[i].Profile < result[j].Profile })
return result, nil
}

// ChromeAccount 是本机 Chrome 中的一个 Google 账号及其导入状态
type ChromeAccount struct {
// ChromeAccounts 列出本机 Chrome 中可导入的账号,并标出已导入的账号
func (m *Manager) ChromeAccounts() ([]ChromeAccount, error) {
return m.BrowserAccounts(chromeauth.BrowserChrome)
}

// BrowserAccount 是本机浏览器中的一个 Google 账号及其导入状态
type BrowserAccount struct {
chromeauth.Account
Imported bool `json:"imported"`
}

// ImportChrome 按邮箱从本机 Chrome 导入账号并启动
func (m *Manager) ImportChrome(ctx context.Context, emails []string, proxy string) ([]string, error) {
root, err := chromeauth.DefaultChromeRoot()
if err != nil {
return nil, err
// ChromeAccount 是本机 Chrome 中的一个 Google 账号及其导入状态
type ChromeAccount = BrowserAccount

// ImportBrowser 按邮箱或 Profile 从本机指定浏览器导入账号并启动
func (m *Manager) ImportBrowser(ctx context.Context, browser string, targets []string, proxy string) ([]string, []chromeauth.ImportFailure, error) {
if browser == "" {
browser = chromeauth.BrowserChrome
}
settings := m.Settings()
results, _, err := chromeauth.Import(ctx, chromeauth.ImportOptions{ChromeRoot: root, Output: settings.AuthDir, Proxy: proxy, RequestProxy: m.importProxy(), Emails: emails})
root, err := chromeauth.DefaultBrowserRoot(browser)
if err != nil {
return nil, err
return nil, nil, err
}
ids := make([]string, 0, len(results))
for _, result := range results {
id, err := m.addFile(result.Path)
if err != nil {
return ids, err
settings := m.Settings()
res, err := chromeauth.ImportBrowserWithDetails(ctx, chromeauth.BrowserImportOptions{
Browser: browser,
BrowserRoot: root,
Output: settings.AuthDir,
Proxy: proxy,
RequestProxy: m.importProxy(),
Targets: targets,
})
if err != nil && len(res.Results) == 0 {
return nil, res.Failures, err
}
ids := make([]string, 0, len(res.Results))
for _, result := range res.Results {
id, addErr := m.addFile(result.Path)
if addErr != nil {
target := result.Email
if target == "" {
target = result.Profile
}
res.Failures = append(res.Failures, chromeauth.ImportFailure{Target: target, Error: addErr.Error()})
continue
}
ids = append(ids, id)
}
return ids, nil
if len(ids) == 0 && len(res.Failures) > 0 {
return nil, res.Failures, fmt.Errorf("所有所选账号均导入失败: %s", res.Failures[0].Error)
}
return ids, res.Failures, nil
}

// ImportChrome 按邮箱从本机 Chrome 导入账号并启动
func (m *Manager) ImportChrome(ctx context.Context, emails []string, proxy string) ([]string, error) {
ids, _, err := m.ImportBrowser(ctx, chromeauth.BrowserChrome, emails, proxy)
return ids, err
}

// ImportCookie 从 Cookie Header、浏览器导出的 JSON 或 Netscape cookies.txt 导入账号并启动
Expand Down
68 changes: 57 additions & 11 deletions internal/accounts/renewal.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,42 +13,47 @@ 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()
cookies, err := chromeauth.Refresh(ctx, r.material, r.proxy)
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)
Expand All @@ -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
}
Loading
Loading