From c31c50dde384e3e50982c0f912dd8fd0fe602d32 Mon Sep 17 00:00:00 2001 From: Scream034 Date: Wed, 28 Oct 2026 23:10:39 +0500 Subject: [PATCH 1/2] feat(accounts): add multi-account session support (/u/0..u/9) with interactive selection --- internal/aistudio/accounts.go | 4 + internal/aistudio/runtime_go.go | 10 +- internal/aistudio/transport_http.go | 6 + internal/api/admin.go | 21 ++- internal/api/pairing.go | 1 + internal/app/admin.go | 172 ++++++++++++++---- internal/app/lifecycle.go | 7 + internal/app/runtime.go | 1 + internal/camoufoxnative/types.go | 1 + internal/camoufoxnative/worker.go | 10 +- internal/chromeauth/platform_windows.go | 24 ++- internal/chromeauth/verify.go | 212 ++++++++++++++++++++--- internal/chromeauth/verify_multi_test.go | 114 ++++++++++++ internal/setup/setup.go | 41 ++++- web/src/api.ts | 7 + web/src/components/AccountsPanel.vue | 181 ++++++++++++++++--- web/src/types.ts | 2 + 17 files changed, 720 insertions(+), 94 deletions(-) create mode 100644 internal/chromeauth/verify_multi_test.go diff --git a/internal/aistudio/accounts.go b/internal/aistudio/accounts.go index bec03a2d..a7236478 100644 --- a/internal/aistudio/accounts.go +++ b/internal/aistudio/accounts.go @@ -128,6 +128,7 @@ type AccountConfig struct { Proxy string `json:"proxy"` Locale string `json:"locale"` Timezone string `json:"timezone"` + AuthUser int `json:"auth_user,omitempty"` } // ResourceBinding 记录上游资源的创建账户 @@ -583,6 +584,9 @@ func (c AccountConfig) Validate() error { if strings.TrimSpace(c.Timezone) == "" { return fmt.Errorf("账户 timezone 不能为空") } + if c.AuthUser < 0 || c.AuthUser > 9 { + return fmt.Errorf("账户 auth_user 无效: %d", c.AuthUser) + } if err := appconfig.ValidateProxy(c.Proxy); err != nil { return fmt.Errorf("账户 proxy 无效: %w", err) } diff --git a/internal/aistudio/runtime_go.go b/internal/aistudio/runtime_go.go index 4461e7be..a7243853 100644 --- a/internal/aistudio/runtime_go.go +++ b/internal/aistudio/runtime_go.go @@ -174,7 +174,11 @@ func newGoWAARuntime(ctx context.Context, options camoufoxnative.Options) (*goWA } runtime := &goWAARuntime{options: options, client: client, storage: storage} runtime.profile, runtime.userAgent, runtime.acceptLanguage = profileFromFingerprint(fingerprint) - runtime.pageURL = aiStudioOrigin + "/prompts/new_chat?model=" + url.QueryEscape(options.Model) + pagePath := "/prompts/new_chat" + if options.AuthUser > 0 { + pagePath = fmt.Sprintf("/u/%d/prompts/new_chat", options.AuthUser) + } + runtime.pageURL = aiStudioOrigin + pagePath + "?model=" + url.QueryEscape(options.Model) if options.TemporaryChat { runtime.pageURL += "&temporary=true" } @@ -229,7 +233,7 @@ func newGoWAARuntime(ctx context.Context, options camoufoxnative.Options) (*goWA runtime.headers = http.Header{} runtime.headers.Set("user-agent", runtime.userAgent) runtime.headers.Set("x-goog-api-key", apiKey) - runtime.headers.Set("x-goog-authuser", "0") + runtime.headers.Set("x-goog-authuser", strconv.Itoa(options.AuthUser)) runtime.headers.Set("x-user-agent", "grpc-web-javascript/0.1") runtime.headers.Set("x-aistudio-visit-id", visitID) if extension != "" { @@ -462,7 +466,7 @@ func (runtime *goWAARuntime) rpcHeaders(apiKey string) (http.Header, error) { headers.Set("Referer", aiStudioOrigin+"/") headers.Set("Content-Type", "application/json+protobuf") headers.Set("X-Goog-Api-Key", apiKey) - headers.Set("X-Goog-AuthUser", "0") + headers.Set("X-Goog-AuthUser", strconv.Itoa(runtime.options.AuthUser)) headers.Set("X-User-Agent", "grpc-web-javascript/0.1") headers.Set("Authorization", authorization) headers.Set("Origin", aiStudioOrigin) diff --git a/internal/aistudio/transport_http.go b/internal/aistudio/transport_http.go index ecb23d77..e5bfd0a2 100644 --- a/internal/aistudio/transport_http.go +++ b/internal/aistudio/transport_http.go @@ -203,6 +203,9 @@ func (t *MakerSuiteHTTPTransport) UploadAssistantFile(ctx context.Context, accou header.Set("Sec-Fetch-Dest", "empty") header.Set("Sec-Fetch-Mode", "cors") header.Set("Sec-Fetch-Site", "same-origin") + if account.Config.AuthUser > 0 { + header.Set("X-Goog-Authuser", strconv.Itoa(account.Config.AuthUser)) + } if language := account.AcceptLanguage(); language != "" { header.Set("Accept-Language", language) } @@ -363,6 +366,9 @@ func prepareProtocolHeaders( headers.Add(name, value) } } + if account.Config.AuthUser > 0 { + headers.Set("X-Goog-Authuser", strconv.Itoa(account.Config.AuthUser)) + } for _, name := range []string{"User-Agent", "X-Goog-Api-Key", "X-Goog-Authuser", "X-User-Agent"} { if strings.TrimSpace(headers.Get(name)) == "" { return StorageState{}, nil, fmt.Errorf("账户动态公共头缺少 %s", name) diff --git a/internal/api/admin.go b/internal/api/admin.go index 9f6c245c..581bcf24 100644 --- a/internal/api/admin.go +++ b/internal/api/admin.go @@ -20,6 +20,7 @@ type AdminService interface { CreateAccount(context.Context, AccountCreateInput, *camoufoxnative.LiveView) (AdminAccount, error) ChromeImportProfiles(context.Context) ([]ChromeImportProfile, error) CookieBrowsers(context.Context) ([]CookieBrowser, error) + DiscoverCookieAccounts(context.Context, CookieImportInput) ([]CookieBrowser, error) ImportChromeAccounts(context.Context, ChromeImportInput) ([]AdminAccount, error) ImportAccountState(context.Context, AccountStateInput) (AdminAccount, error) UpdateAccount(context.Context, string, AccountInput) (AdminAccount, error) @@ -197,6 +198,7 @@ type CookieImportInput struct { Proxy string `json:"proxy"` Locale string `json:"locale"` Timezone string `json:"timezone"` + AuthUser *int `json:"auth_user,omitempty"` } // ChromeImportProfile 表示可从本机 Chrome 导入的账号 @@ -213,6 +215,7 @@ type CookieBrowser struct { Address string `json:"address"` Browser string `json:"browser"` Email string `json:"email"` + AuthUser int `json:"auth_user"` Locale string `json:"locale"` Timezone string `json:"timezone"` Imported bool `json:"imported"` @@ -305,6 +308,7 @@ func (s *server) registerAdmin(mux *http.ServeMux) { mux.HandleFunc("GET /api/accounts/import/chrome", s.handleChromeImportProfiles) mux.HandleFunc("POST /api/accounts/import/chrome", s.handleImportChromeAccounts) mux.HandleFunc("GET /api/accounts/import/browsers", s.handleCookieBrowsers) + mux.HandleFunc("POST /api/accounts/import/cookies/discover", s.handleDiscoverCookieAccounts) mux.HandleFunc("POST /api/accounts/import/cookies", s.handleImportCookieAccount) mux.HandleFunc("POST /api/pairing", s.handleCreatePairing) mux.HandleFunc("PUT /api/accounts/{id}", s.handleUpdateAccount) @@ -414,6 +418,20 @@ func (s *server) handleCookieBrowsers(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string][]CookieBrowser{"browsers": browsers}) } +func (s *server) handleDiscoverCookieAccounts(w http.ResponseWriter, r *http.Request) { + var input CookieImportInput + if err := decodeJSON(r, &input); err != nil { + writeAdminError(w, http.StatusBadRequest, "invalid_request", err.Error()) + return + } + accounts, err := s.config.Admin.DiscoverCookieAccounts(r.Context(), input) + if err != nil { + writeAdminUpstreamError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string][]CookieBrowser{"accounts": accounts}) +} + func (s *server) handleImportCookieAccount(w http.ResponseWriter, r *http.Request) { var input CookieImportInput if err := decodeJSON(r, &input); err != nil { @@ -440,7 +458,8 @@ func (s *server) handleImportCookieAccount(w http.ResponseWriter, r *http.Reques return } account, err := s.config.Admin.ImportAccountState(r.Context(), AccountStateInput{ - Label: input.Label, Proxy: input.Proxy, Locale: input.Locale, Timezone: input.Timezone, StorageState: state, + Label: input.Label, Proxy: input.Proxy, Locale: input.Locale, Timezone: input.Timezone, + AuthUser: input.AuthUser, StorageState: state, }) if err != nil { writeAdminUpstreamError(w, err) diff --git a/internal/api/pairing.go b/internal/api/pairing.go index 2b645dd4..b923745c 100644 --- a/internal/api/pairing.go +++ b/internal/api/pairing.go @@ -28,6 +28,7 @@ type AccountStateInput struct { Proxy string `json:"-"` Locale string `json:"locale"` Timezone string `json:"timezone"` + AuthUser *int `json:"auth_user,omitempty"` StorageState aistudio.StorageState `json:"storage_state"` Fingerprint json.RawMessage `json:"fingerprint,omitempty"` } diff --git a/internal/app/admin.go b/internal/app/admin.go index 840c2896..2db61cdf 100644 --- a/internal/app/admin.go +++ b/internal/app/admin.go @@ -245,7 +245,7 @@ func (admin *runtimeAdmin) ChromeImportProfiles(context.Context) ([]api.ChromeIm return profiles, nil } -// CookieBrowsers 列出本机开启远程调试的浏览器,读取其 Cookie 并识别其中登录 AI Studio 的账户 +// CookieBrowsers 列出本机开启远程调试的浏览器,读取其 Cookie 并识别其中尚未导入的 AI Studio 会话槽位账户 (u/0, u/1, ...) func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrowser, error) { addresses, err := chromeauth.DebugAddresses(ctx) if err != nil { @@ -255,48 +255,112 @@ func (admin *runtimeAdmin) CookieBrowsers(ctx context.Context) ([]api.CookieBrow for _, status := range admin.pool.Status() { existing[strings.ToLower(strings.TrimSpace(status.ID))] = struct{}{} } - browsers := make([]api.CookieBrowser, len(addresses)) + perAddress := make([][]api.CookieBrowser, len(addresses)) var group sync.WaitGroup for index, address := range addresses { group.Go(func() { - browser := api.CookieBrowser{Address: address} + base := api.CookieBrowser{Address: address} name, err := chromeauth.DebugBrowserName(ctx, address) if err != nil { - browser.Error = err.Error() - browsers[index] = browser + base.Error = err.Error() + perAddress[index] = []api.CookieBrowser{base} return } - browser.Browser = name - err = admin.readCookieBrowser(ctx, &browser) - browser.SignedOut = errors.Is(err, chromeauth.ErrSignedOut) || errors.Is(err, aistudio.ErrLoginCookieMissing) - if err != nil && !browser.SignedOut { - browser.Error = err.Error() + base.Browser = name + entries, err := admin.readCookieBrowserAccounts(ctx, base, existing) + if err != nil { + base.SignedOut = errors.Is(err, chromeauth.ErrSignedOut) || errors.Is(err, aistudio.ErrLoginCookieMissing) + if !base.SignedOut { + base.Error = err.Error() + } + perAddress[index] = []api.CookieBrowser{base} + return } - _, browser.Imported = existing[strings.ToLower(browser.Email)] - browsers[index] = browser + perAddress[index] = entries }) } group.Wait() + var browsers []api.CookieBrowser + for _, list := range perAddress { + browsers = append(browsers, list...) + } return browsers, nil } -// readCookieBrowser 读取调试浏览器的语言、时区与 Cookie,填入其中登录 AI Studio 的账户邮箱 -func (admin *runtimeAdmin) readCookieBrowser(ctx context.Context, browser *api.CookieBrowser) error { - source, err := chromeauth.ReadCDPBrowser(ctx, browser.Address) +// readCookieBrowserAccounts 读取调试浏览器的语言、时区与 Cookie,只列出尚未导入的 u/0..u/9 账户 +func (admin *runtimeAdmin) readCookieBrowserAccounts( + ctx context.Context, + base api.CookieBrowser, + existing map[string]struct{}, +) ([]api.CookieBrowser, error) { + source, err := chromeauth.ReadCDPBrowser(ctx, base.Address) if err != nil { - return err + return nil, err } - browser.Locale, browser.Timezone = source.Locale, source.Timezone + base.Locale, base.Timezone = source.Locale, source.Timezone state, err := aistudio.ParseCookieText(source.Cookies) if err != nil { - return err + return nil, err } - verification, err := chromeauth.Verify(ctx, &state, admin.effectiveProxy("")) + discovered, err := chromeauth.DiscoverCookieAccounts(ctx, &state, admin.effectiveProxy("")) if err != nil { - return err + return nil, err } - browser.Email = strings.ToLower(verification.Email) - return nil + result := make([]api.CookieBrowser, 0, len(discovered)) + for _, item := range discovered { + if _, exists := existing[item.Email]; exists { + continue + } + entry := base + entry.Email = item.Email + entry.AuthUser = item.AuthUser + result = append(result, entry) + } + return result, nil +} + +// DiscoverCookieAccounts 解析粘贴的 Cookie 并返回其中尚未导入的全部 Google 会话槽位账户 (u/0..u/9) +func (admin *runtimeAdmin) DiscoverCookieAccounts(ctx context.Context, input api.CookieImportInput) ([]api.CookieBrowser, error) { + state, err := aistudio.ParseCookieText(input.Cookies) + if err != nil { + return nil, &adminOperationError{status: http.StatusBadRequest, code: "invalid_cookies", message: err.Error()} + } + if err := state.Validate(); err != nil { + return nil, invalidAccount(err) + } + discovered, err := chromeauth.DiscoverCookieAccounts(ctx, &state, admin.effectiveProxy(input.Proxy)) + if errors.Is(err, chromeauth.ErrSignedOut) { + return nil, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} + } + if err != nil { + return nil, invalidAccount(err) + } + existing := make(map[string]struct{}) + for _, status := range admin.pool.Status() { + existing[strings.ToLower(strings.TrimSpace(status.ID))] = struct{}{} + } + available := make([]api.CookieBrowser, 0, len(discovered)) + firstExisting := "" + for _, item := range discovered { + if _, exists := existing[item.Email]; exists { + if firstExisting == "" { + firstExisting = item.Email + } + continue + } + available = append(available, api.CookieBrowser{ + Email: item.Email, + AuthUser: item.AuthUser, + Locale: strings.TrimSpace(input.Locale), + Timezone: strings.TrimSpace(input.Timezone), + }) + } + if len(available) == 0 && firstExisting != "" { + return nil, &adminOperationError{ + status: http.StatusConflict, code: "account_exists", message: "账户已存在: " + firstExisting, + } + } + return available, nil } func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { @@ -352,20 +416,65 @@ func (admin *runtimeAdmin) ImportChromeAccounts(ctx context.Context, input api.C return accounts, nil } -// ImportAccountState 导入远程配对上传或 Cookie 导入的认证状态与 Camoufox 指纹,未给标签时以 Cookie 登录的账户邮箱为标签 +// ImportAccountState 导入远程配对上传或 Cookie 导入的认证状态与 Camoufox 指纹,支持指定或自动选择 u/0..u/9 会话槽位 func (admin *runtimeAdmin) ImportAccountState(ctx context.Context, input api.AccountStateInput) (api.AdminAccount, error) { label := strings.TrimSpace(input.Label) - if label == "" { - verification, err := chromeauth.Verify(ctx, &input.StorageState, admin.effectiveProxy(input.Proxy)) - if errors.Is(err, chromeauth.ErrSignedOut) { - return api.AdminAccount{}, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} - } - if err != nil { - return api.AdminAccount{}, invalidAccount(err) + authUser := 0 + if input.AuthUser != nil { + authUser = *input.AuthUser + } + existing := make(map[string]struct{}) + for _, status := range admin.pool.Status() { + existing[strings.ToLower(strings.TrimSpace(status.ID))] = struct{}{} + } + _, hasExtension, extErr := input.StorageState.AuthExtension() + if extErr != nil { + return api.AdminAccount{}, invalidAccount(extErr) + } + if !hasExtension { + switch { + case input.AuthUser != nil: + verification, err := chromeauth.VerifyAuthUser(ctx, &input.StorageState, admin.effectiveProxy(input.Proxy), authUser) + if errors.Is(err, chromeauth.ErrSignedOut) { + return api.AdminAccount{}, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} + } + if err != nil { + return api.AdminAccount{}, invalidAccount(err) + } + verifiedEmail := strings.ToLower(verification.Email) + if label != "" && !strings.EqualFold(label, verifiedEmail) { + return api.AdminAccount{}, invalidAccount(fmt.Errorf("u/%d 登录邮箱 (%s) 与指定邮箱 (%s) 不一致", authUser, verifiedEmail, label)) + } + label = verifiedEmail + case label != "": + verification, resolvedUser, err := chromeauth.FindCookieAccount(ctx, &input.StorageState, admin.effectiveProxy(input.Proxy), label) + if errors.Is(err, chromeauth.ErrSignedOut) { + return api.AdminAccount{}, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} + } + if err != nil { + return api.AdminAccount{}, invalidAccount(err) + } + label = strings.ToLower(verification.Email) + authUser = resolvedUser + default: + verification, resolvedUser, existingEmail, err := chromeauth.VerifyNextAuthUser(ctx, &input.StorageState, admin.effectiveProxy(input.Proxy), existing) + if errors.Is(err, chromeauth.ErrSignedOut) { + return api.AdminAccount{}, &adminOperationError{status: http.StatusBadRequest, code: "signed_out", message: err.Error()} + } + if err != nil { + return api.AdminAccount{}, invalidAccount(err) + } + if existingEmail != "" { + return api.AdminAccount{}, &adminOperationError{ + status: http.StatusConflict, code: "account_exists", message: "账户已存在: " + existingEmail, + } + } + label = strings.ToLower(verification.Email) + authUser = resolvedUser } - label = strings.ToLower(verification.Email) } accountConfig := aistudio.DefaultAccountConfig(label) + accountConfig.AuthUser = authUser accountConfig.Proxy = strings.TrimSpace(input.Proxy) if locale := strings.TrimSpace(input.Locale); locale != "" { accountConfig.Locale = locale @@ -479,6 +588,7 @@ func (admin *runtimeAdmin) UpdateAccount(ctx context.Context, accountID string, return api.AdminAccount{}, accountOperationError(err) } account := lease.Account() + accountConfig.AuthUser = account.Config.AuthUser enabling := accountConfig.Enabled && !account.Config.Enabled if accountConfig.Proxy == "" && !admin.proxies.Available(account.PoolProxy("")) { account.SetPoolProxy(admin.proxies.Next()) diff --git a/internal/app/lifecycle.go b/internal/app/lifecycle.go index 3c7ef2a7..ab839fb4 100644 --- a/internal/app/lifecycle.go +++ b/internal/app/lifecycle.go @@ -421,6 +421,13 @@ func (manager *runtimeManager) CookieBrowsers(ctx context.Context) ([]api.Cookie return manager.current.admin.CookieBrowsers(ctx) } +// DiscoverCookieAccounts 返回粘贴 Cookie 中尚未导入的会话槽位账户 +func (manager *runtimeManager) DiscoverCookieAccounts(ctx context.Context, input api.CookieImportInput) ([]api.CookieBrowser, error) { + manager.mu.RLock() + defer manager.mu.RUnlock() + return manager.current.admin.DiscoverCookieAccounts(ctx, input) +} + // ImportChromeAccounts 在当前生成服务批量导入 Chrome 账号 func (manager *runtimeManager) ImportChromeAccounts(ctx context.Context, input api.ChromeImportInput) ([]api.AdminAccount, error) { manager.mu.RLock() diff --git a/internal/app/runtime.go b/internal/app/runtime.go index 2b551abf..dc423ea2 100644 --- a/internal/app/runtime.go +++ b/internal/app/runtime.go @@ -779,6 +779,7 @@ func (manager *accountWorkerManager) workerConfigFor( Proxy: proxy, Headless: true, TemporaryChat: manager.temporaryChat, + AuthUser: config.AuthUser, } } diff --git a/internal/camoufoxnative/types.go b/internal/camoufoxnative/types.go index c9f784ea..70922982 100644 --- a/internal/camoufoxnative/types.go +++ b/internal/camoufoxnative/types.go @@ -49,6 +49,7 @@ type Options struct { ProxyBypass string Headless bool TemporaryChat bool + AuthUser int CacheDirectory string ReadyTimeout time.Duration Log io.Writer diff --git a/internal/camoufoxnative/worker.go b/internal/camoufoxnative/worker.go index b68f2cf8..2e68524b 100644 --- a/internal/camoufoxnative/worker.go +++ b/internal/camoufoxnative/worker.go @@ -9,6 +9,7 @@ import ( "net/http" "net/url" "os" + "strconv" "strings" "sync" "time" @@ -323,7 +324,11 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st return err } options.reportStartup(StartupLoadingAIStudio) - target := aiStudioOrigin + "/prompts/new_chat?model=" + url.QueryEscape(options.Model) + pagePath := "/prompts/new_chat" + if options.AuthUser > 0 { + pagePath = fmt.Sprintf("/u/%d/prompts/new_chat", options.AuthUser) + } + target := aiStudioOrigin + pagePath + "?model=" + url.QueryEscape(options.Model) if options.TemporaryChat { target += "&temporary=true" } @@ -491,6 +496,9 @@ func (worker *Worker) bootstrap(ctx context.Context, options Options, storage st headers.Set(name, value) } } + if options.AuthUser > 0 { + headers.Set("x-goog-authuser", strconv.Itoa(options.AuthUser)) + } for _, name := range []string{"user-agent", "x-goog-api-key", "x-goog-authuser", "x-user-agent"} { if headers.Get(name) == "" { return fmt.Errorf("官网 GenerateContent 缺少必要公共头 %s", name) diff --git a/internal/chromeauth/platform_windows.go b/internal/chromeauth/platform_windows.go index c174b5b3..f9da753e 100644 --- a/internal/chromeauth/platform_windows.go +++ b/internal/chromeauth/platform_windows.go @@ -77,14 +77,22 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { records, recordsErr := readTokenServices(chromeRoot, profile) seen := map[string]bool{} for _, identity := range identities { - if identity.Gaia == "" || seen[identity.Gaia] { + gaia := strings.TrimSpace(identity.Gaia) + if gaia == "" { + gaia = strings.TrimSpace(identity.AccountID) + } + if gaia == "" || seen[gaia] { continue } - seen[identity.Gaia] = true - record, found := records[identity.Gaia] + seen[gaia] = true + displayName := info.Name + if fullName := strings.TrimSpace(identity.FullName); fullName != "" && !strings.EqualFold(fullName, info.Name) { + displayName = fullName + " (" + info.Name + ")" + } + record, found := records[gaia] accounts = append(accounts, Account{ - ID: profile + "/" + identity.Gaia, GaiaID: identity.Gaia, - Profile: profile, DisplayName: info.Name, Email: identity.Email, Locale: locale, + ID: profile + "/" + gaia, GaiaID: gaia, + Profile: profile, DisplayName: displayName, Email: identity.Email, Locale: locale, Importable: recordsErr == nil && found && strings.HasPrefix(string(record.encryptedToken), "v20"), }) } @@ -94,8 +102,10 @@ func discoverPlatform(chromeRoot string) ([]Account, error) { // profileIdentity 为 Preferences.account_info 中的一个 Google 账号 type profileIdentity struct { - Gaia string `json:"gaia"` - Email string `json:"email"` + Gaia string `json:"gaia"` + AccountID string `json:"account_id"` + Email string `json:"email"` + FullName string `json:"full_name"` } // profileIdentities 读取 Profile 内全部 Google 账号的 Gaia ID 与邮箱 diff --git a/internal/chromeauth/verify.go b/internal/chromeauth/verify.go index 6d395b39..bf4b531d 100644 --- a/internal/chromeauth/verify.go +++ b/internal/chromeauth/verify.go @@ -9,13 +9,18 @@ import ( "io" "net/http" "regexp" + "slices" + "strconv" "strings" "time" "github.com/Mag1cFall/AIStudio2API/internal/aistudio" ) -const aiStudioChatURL = "https://aistudio.google.com/prompts/new_chat" +const ( + aiStudioChatURL = "https://aistudio.google.com/prompts/new_chat" + maxCookieAuthUsers = 10 +) // ErrSignedOut 表示 Cookie 打开 AI Studio 时被转到登录页 var ErrSignedOut = errors.New("Cookie 未登录 AI Studio 或已失效") @@ -29,44 +34,210 @@ type Verification struct { ModelCount int } -// Verify 验证账号可访问 WAA 页面并读取实时模型目录 +// CookieAccount 描述同一份 Cookie 中单个 Google 会话槽位 (u/0, u/1, ...) 的账户 +type CookieAccount struct { + AuthUser int + Email string + ModelCount int +} + +// Verify 验证默认账号 (u/0) 可访问 WAA 页面并读取实时模型目录 func Verify(ctx context.Context, state *aistudio.StorageState, proxy string) (Verification, error) { - if state == nil { - return Verification{}, fmt.Errorf("storage state 为空") + return VerifyAuthUser(ctx, state, proxy, 0) +} + +// VerifyAuthUser 验证指定会话槽位 (u/0, u/1, ...) 的账号可访问 WAA 页面并读取实时模型目录 +func VerifyAuthUser(ctx context.Context, state *aistudio.StorageState, proxy string, authUser int) (Verification, error) { + if authUser < 0 || authUser >= maxCookieAuthUsers { + return Verification{}, fmt.Errorf("auth_user 无效: %d", authUser) } - if _, err := aistudio.NewSigner().Sign(*state); err != nil { + client, headers, err := newVerifyClient(ctx, state, proxy) + if err != nil { return Verification{}, err } - client, err := aistudio.NewProxyHTTPClient(proxy) + defer client.CloseIdleConnections() + email, err := verifyChatPage(ctx, client, state, headers.Get("User-Agent"), authUser) if err != nil { return Verification{}, err } - defer client.CloseIdleConnections() - headers, err := aistudio.DiscoverPublicHeaders(ctx, client) + models, err := verifyModels(ctx, client, state, headers, authUser) if err != nil { return Verification{}, err } - client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse + return Verification{Email: email, ModelCount: models}, nil +} + +// VerifyNextAuthUser 按 u/0..u/9 依次查找首个不在 existing 中的登录账户并验证其模型目录 +func VerifyNextAuthUser( + ctx context.Context, + state *aistudio.StorageState, + proxy string, + existing map[string]struct{}, +) (Verification, int, string, error) { + client, headers, err := newVerifyClient(ctx, state, proxy) + if err != nil { + return Verification{}, 0, "", err } - email, err := verifyChatPage(ctx, client, state, headers.Get("User-Agent")) + defer client.CloseIdleConnections() + seen := make(map[string]struct{}, maxCookieAuthUsers) + firstExisting := "" + for authUser := 0; authUser < maxCookieAuthUsers; authUser++ { + candidate := cloneStorageState(*state) + email, err := verifyChatPage(ctx, client, &candidate, headers.Get("User-Agent"), authUser) + if err != nil { + if authUser == 0 { + return Verification{}, 0, "", err + } + break + } + normalized := strings.ToLower(strings.TrimSpace(email)) + if _, duplicated := seen[normalized]; duplicated { + break + } + seen[normalized] = struct{}{} + if _, exists := existing[normalized]; exists { + if firstExisting == "" { + firstExisting = normalized + } + continue + } + models, err := verifyModels(ctx, client, &candidate, headers, authUser) + if err != nil { + return Verification{}, authUser, "", err + } + *state = candidate + return Verification{Email: email, ModelCount: models}, authUser, "", nil + } + if firstExisting != "" { + return Verification{Email: firstExisting}, 0, firstExisting, nil + } + return Verification{}, 0, "", ErrSignedOut +} + +// FindCookieAccount 在同一份 Cookie 的 u/0..u/9 会话槽位中定位目标邮箱对应的槽位并验证其模型目录 +func FindCookieAccount( + ctx context.Context, + state *aistudio.StorageState, + proxy string, + targetEmail string, +) (Verification, int, error) { + targetEmail = strings.ToLower(strings.TrimSpace(targetEmail)) + client, headers, err := newVerifyClient(ctx, state, proxy) if err != nil { - return Verification{}, err + return Verification{}, 0, err + } + defer client.CloseIdleConnections() + seen := make(map[string]struct{}, maxCookieAuthUsers) + for authUser := 0; authUser < maxCookieAuthUsers; authUser++ { + candidate := cloneStorageState(*state) + email, err := verifyChatPage(ctx, client, &candidate, headers.Get("User-Agent"), authUser) + if err != nil { + if authUser == 0 { + return Verification{}, 0, err + } + break + } + normalized := strings.ToLower(strings.TrimSpace(email)) + if _, duplicated := seen[normalized]; duplicated { + break + } + seen[normalized] = struct{}{} + if normalized != targetEmail { + continue + } + models, err := verifyModels(ctx, client, &candidate, headers, authUser) + if err != nil { + return Verification{}, authUser, err + } + *state = candidate + return Verification{Email: email, ModelCount: models}, authUser, nil } - models, err := verifyModels(ctx, client, state, headers) + return Verification{}, 0, fmt.Errorf("Cookie 中未找到账户: %s", targetEmail) +} + +// DiscoverCookieAccounts 列出同一份 Cookie 中登录 AI Studio 的全部会话槽位账户 (u/0, u/1, ...) +func DiscoverCookieAccounts(ctx context.Context, state *aistudio.StorageState, proxy string) ([]CookieAccount, error) { + client, headers, err := newVerifyClient(ctx, state, proxy) if err != nil { - return Verification{}, err + return nil, err } - return Verification{Email: email, ModelCount: models}, nil + defer client.CloseIdleConnections() + seen := make(map[string]struct{}, maxCookieAuthUsers) + accounts := make([]CookieAccount, 0, 2) + for authUser := 0; authUser < maxCookieAuthUsers; authUser++ { + candidate := cloneStorageState(*state) + email, err := verifyChatPage(ctx, client, &candidate, headers.Get("User-Agent"), authUser) + if err != nil { + if authUser == 0 { + return nil, err + } + break + } + normalized := strings.ToLower(strings.TrimSpace(email)) + if _, duplicated := seen[normalized]; duplicated { + break + } + seen[normalized] = struct{}{} + models, err := verifyModels(ctx, client, &candidate, headers, authUser) + if err != nil { + if authUser == 0 { + return nil, err + } + break + } + accounts = append(accounts, CookieAccount{ + AuthUser: authUser, + Email: normalized, + ModelCount: models, + }) + } + return accounts, nil +} + +func newVerifyClient(ctx context.Context, state *aistudio.StorageState, proxy string) (*http.Client, http.Header, error) { + if state == nil { + return nil, nil, fmt.Errorf("storage state 为空") + } + if _, err := aistudio.NewSigner().Sign(*state); err != nil { + return nil, nil, err + } + client, err := aistudio.NewProxyHTTPClient(proxy) + if err != nil { + return nil, nil, err + } + headers, err := aistudio.DiscoverPublicHeaders(ctx, client) + if err != nil { + client.CloseIdleConnections() + return nil, nil, err + } + client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error { + return http.ErrUseLastResponse + } + return client, headers, nil +} + +func cloneStorageState(state aistudio.StorageState) aistudio.StorageState { + cloned := state + cloned.Cookies = slices.Clone(state.Cookies) + cloned.Origins = slices.Clone(state.Origins) + return cloned +} + +func chatURLForAuthUser(authUser int) string { + if authUser > 0 { + return fmt.Sprintf("https://aistudio.google.com/u/%d/prompts/new_chat", authUser) + } + return aiStudioChatURL } // verifyChatPage 以账户 Cookie 打开对话页并返回页面中登录账户的邮箱 -func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.StorageState, userAgent string) (string, error) { - cookie, err := state.CookieHeader(aiStudioChatURL, time.Now()) +func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.StorageState, userAgent string, authUser int) (string, error) { + targetURL := chatURLForAuthUser(authUser) + cookie, err := state.CookieHeader(targetURL, time.Now()) if err != nil { return "", err } - request, err := http.NewRequestWithContext(ctx, http.MethodGet, aiStudioChatURL, nil) + request, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil) if err != nil { return "", err } @@ -92,10 +263,10 @@ func verifyChatPage(ctx context.Context, client *http.Client, state *aistudio.St if match := accountEmailPattern.FindSubmatch(body); match == nil || json.Unmarshal(match[1], &email) != nil || !strings.Contains(email, "@") { return "", fmt.Errorf("AI Studio 页面缺少登录账户") } - return email, mergeResponseCookies(state, response, aiStudioChatURL) + return email, mergeResponseCookies(state, response, targetURL) } -func verifyModels(ctx context.Context, client *http.Client, state *aistudio.StorageState, headers http.Header) (int, error) { +func verifyModels(ctx context.Context, client *http.Client, state *aistudio.StorageState, headers http.Header, authUser int) (int, error) { url := aistudio.MakerSuiteRPCBase + "ListModels" cookie, err := state.CookieHeader(url, time.Now()) if err != nil { @@ -119,6 +290,7 @@ func verifyModels(ctx context.Context, client *http.Client, state *aistudio.Stor request.Header.Set("Sec-Fetch-Dest", "empty") request.Header.Set("Sec-Fetch-Mode", "cors") request.Header.Set("Sec-Fetch-Site", "same-site") + request.Header.Set("X-Goog-Authuser", strconv.Itoa(authUser)) response, err := client.Do(request) if err != nil { return 0, fmt.Errorf("读取 AI Studio 模型目录: %w", err) diff --git a/internal/chromeauth/verify_multi_test.go b/internal/chromeauth/verify_multi_test.go new file mode 100644 index 00000000..e55ff1ef --- /dev/null +++ b/internal/chromeauth/verify_multi_test.go @@ -0,0 +1,114 @@ +package chromeauth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Mag1cFall/AIStudio2API/internal/aistudio" +) + +func newMockCookieStorage() aistudio.StorageState { + return aistudio.StorageState{ + Cookies: []aistudio.StateCookie{ + {Name: "SAPISID", Value: "mock_sapisid", Domain: ".google.com", Path: "/"}, + {Name: "__Secure-1PAPISID", Value: "mock_1papisid", Domain: ".google.com", Path: "/"}, + {Name: "__Secure-3PAPISID", Value: "mock_3papisid", Domain: ".google.com", Path: "/"}, + {Name: "SID", Value: "mock_sid", Domain: ".google.com", Path: "/"}, + }, + Origins: []aistudio.StorageOrigin{}, + } +} + +func mockMultiAccountServer() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`"WIu0Nc":"test_api_key"`)) + case r.URL.Path == "/prompts/new_chat": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`"oPEP7c":"first@example.com"`)) + case r.URL.Path == "/u/1/prompts/new_chat": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`"oPEP7c":"second@example.com"`)) + case r.URL.Path == "/u/2/prompts/new_chat": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`"oPEP7c":"third@example.com"`)) + case strings.HasPrefix(r.URL.Path, "/u/"): + w.WriteHeader(http.StatusNotFound) + default: + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`[[{"name":"models/gemini-2.5-flash","supportedGenerationMethods":["generateContent"]}]]`)) + } + })) +} + +func TestChatURLForAuthUser(t *testing.T) { + t.Parallel() + tests := []struct { + name string + authUser int + want string + }{ + {name: "default user zero", authUser: 0, want: "https://aistudio.google.com/prompts/new_chat"}, + {name: "sub-account user one", authUser: 1, want: "https://aistudio.google.com/u/1/prompts/new_chat"}, + {name: "sub-account user two", authUser: 2, want: "https://aistudio.google.com/u/2/prompts/new_chat"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got := chatURLForAuthUser(tc.authUser) + if got != tc.want { + t.Fatalf("chatURLForAuthUser(%d) = %q, want %q", tc.authUser, got, tc.want) + } + }) + } +} + +func TestVerifyAuthUserMock(t *testing.T) { + server := mockMultiAccountServer() + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := newMockCookieStorage() + client := server.Client() + + tests := []struct { + authUser int + wantEmail string + }{ + {authUser: 0, wantEmail: "first@example.com"}, + {authUser: 1, wantEmail: "second@example.com"}, + {authUser: 2, wantEmail: "third@example.com"}, + } + + for _, tc := range tests { + url := server.URL + "/prompts/new_chat" + if tc.authUser > 0 { + url = server.URL + "/u/" + string(rune('0'+tc.authUser)) + "/prompts/new_chat" + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("User-Agent", "TestAgent") + resp, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("auth_user=%d returned status %d", tc.authUser, resp.StatusCode) + } + } + + _ = state +} diff --git a/internal/setup/setup.go b/internal/setup/setup.go index b0ac8dc0..9b5ffb4f 100644 --- a/internal/setup/setup.go +++ b/internal/setup/setup.go @@ -54,6 +54,8 @@ type setupOptions struct { localeSet bool timezone string timezoneSet bool + authUser int + authUserSet bool remote string token string } @@ -147,8 +149,10 @@ func (target remoteTarget) Save( state aistudio.StorageState, fingerprintDirectory string, ) (string, error) { + authUser := accountConfig.AuthUser input := api.AccountStateInput{ - Label: accountConfig.Label, Locale: accountConfig.Locale, Timezone: accountConfig.Timezone, StorageState: state, + Label: accountConfig.Label, Locale: accountConfig.Locale, Timezone: accountConfig.Timezone, + AuthUser: &authUser, StorageState: state, } if fingerprintDirectory != "" { data, err := os.ReadFile(filepath.Join(fingerprintDirectory, "camoufox-fingerprint.json")) @@ -265,9 +269,24 @@ func importCookies(ctx context.Context, target accountTarget, options setupOptio return fmt.Errorf("认证状态无法用于 AI Studio: %w", err) } var email string - if len(options.emails) == 1 { - email = options.emails[0] - } else { + switch { + case options.authUserSet: + verification, err := chromeauth.VerifyAuthUser(ctx, &state, options.egress, options.authUser) + if err != nil { + return fmt.Errorf("识别 Cookie (u/%d) 登录的账户: %w", options.authUser, err) + } + email = strings.ToLower(verification.Email) + if len(options.emails) == 1 && !strings.EqualFold(options.emails[0], email) { + return fmt.Errorf("u/%d 登录邮箱 (%s) 与 --email (%s) 不一致", options.authUser, email, options.emails[0]) + } + case len(options.emails) == 1: + verification, resolvedUser, err := chromeauth.FindCookieAccount(ctx, &state, options.egress, options.emails[0]) + if err != nil { + return fmt.Errorf("识别 Cookie 登录的账户: %w", err) + } + email = strings.ToLower(verification.Email) + options.authUser = resolvedUser + default: verification, err := chromeauth.Verify(ctx, &state, options.egress) if err != nil { return fmt.Errorf("识别 Cookie 登录的账户: %w", err) @@ -434,6 +453,7 @@ func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { proxy := flags.String("proxy", "", "账户固定 HTTP、HTTPS 或 SOCKS5 代理") locale := flags.String("locale", aistudio.DefaultAccountLocale(), "账户语言") timezone := flags.String("timezone", aistudio.DefaultAccountTimezone(), "账户时区") + authUser := flags.Int("authuser", 0, "Google 多账号会话序号 (0..9,对应 /u/0、/u/1...)") remote := flags.String("remote", "", "远程实例 origin,账户上传到该实例") token := flags.String("token", "", "远程实例账户页生成的配对令牌,省略时从标准输入读取") if err := flags.Parse(args); err != nil { @@ -442,12 +462,16 @@ func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { if flags.NArg() != 0 { return setupOptions{}, fmt.Errorf("未知参数 %q", flags.Arg(0)) } + if *authUser < 0 || *authUser > 9 { + return setupOptions{}, fmt.Errorf("--authuser 必须在 0..9 之间") + } options := setupOptions{ storageState: strings.TrimSpace(*storageState), login: *login, chromeRoot: strings.TrimSpace(*chromeRoot), profiles: profiles, emails: emails, - proxy: strings.TrimSpace(*proxy), - locale: strings.TrimSpace(*locale), timezone: strings.TrimSpace(*timezone), - token: strings.TrimSpace(*token), + proxy: strings.TrimSpace(*proxy), + locale: strings.TrimSpace(*locale), timezone: strings.TrimSpace(*timezone), + authUser: *authUser, + token: strings.TrimSpace(*token), } options.egress = options.proxy if proxies := config.ProxyList(cfg.Proxy); options.egress == "" && len(proxies) > 0 { @@ -493,6 +517,8 @@ func parseSetupFlags(args []string, cfg config.Config) (setupOptions, error) { options.localeSet = true case "timezone": options.timezoneSet = true + case "authuser": + options.authUserSet = true } }) chromeSelection := options.cookies == "" && options.cdp == "" && (options.chromeRoot != "" || len(options.profiles) != 0 || len(options.emails) != 0) @@ -516,6 +542,7 @@ func setupAccountConfig(label string, options setupOptions) aistudio.AccountConf accountConfig.Proxy = options.proxy accountConfig.Locale = options.locale accountConfig.Timezone = options.timezone + accountConfig.AuthUser = options.authUser return accountConfig } diff --git a/web/src/api.ts b/web/src/api.ts index 752441d0..bb827d5d 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -187,6 +187,13 @@ export const api = { }), cookieBrowsers: async () => (await requestJSON<{ browsers: CookieBrowser[] }>('/api/accounts/import/browsers')).browsers, + discoverCookieAccounts: async (input: CookieImportInput) => + ( + await requestJSON<{ accounts: CookieBrowser[] }>('/api/accounts/import/cookies/discover', { + method: 'POST', + body: JSON.stringify(input), + }) + ).accounts, importCookieAccount: (input: CookieImportInput) => requestJSON('/api/accounts/import/cookies', { method: 'POST', diff --git a/web/src/components/AccountsPanel.vue b/web/src/components/AccountsPanel.vue index fcbd5f3b..64b95c06 100644 --- a/web/src/components/AccountsPanel.vue +++ b/web/src/components/AccountsPanel.vue @@ -44,6 +44,8 @@ const showBrowserLogin = ref(false) const showChromeImport = ref(false) const showCookieImport = ref(false) const cookieText = ref('') +const cookieCandidates = ref([]) +const selectedCookieUsers = ref([]) const showBrowserImport = ref(false) // cookieBrowsers 是本机找到的调试浏览器与其中登录 AI Studio 的账户 const cookieBrowsers = ref([]) @@ -285,9 +287,28 @@ async function importChromeAccounts(): Promise { } function closeCookieImport(): void { - if (pendingAction.value === 'cookie-import') return + if (pendingAction.value === 'cookie-import' || pendingAction.value === 'cookie-discover') return showCookieImport.value = false cookieText.value = '' + cookieCandidates.value = [] + selectedCookieUsers.value = [] +} + +function resetCookieDiscovery(): void { + cookieCandidates.value = [] + selectedCookieUsers.value = [] +} + +const allCookieCandidatesSelected = computed( + () => + cookieCandidates.value.length > 0 && + selectedCookieUsers.value.length === cookieCandidates.value.length, +) + +function toggleAllCookieCandidates(): void { + selectedCookieUsers.value = allCookieCandidatesSelected.value + ? [] + : cookieCandidates.value.map((item) => item.auth_user) } // pasteSteps 是粘贴 Cookie 的操作步骤 @@ -299,12 +320,24 @@ function openBrowserImport(): void { void scanBrowsers() } -// browserSummary 返回浏览器条目第二行:未登录提示、读取错误或浏览器名、地址、语言与时区 +function browserKey(browser: CookieBrowser): string { + return `${browser.address}#${browser.auth_user}` +} + +// browserSummary 返回浏览器条目第二行:未登录提示、读取错误或浏览器名、地址、u/N、语言与时区 function browserSummary(browser: CookieBrowser): string { if (browser.signed_out) return t('accounts.browserSignedOut') return ( browser.error || - [browser.browser, browser.address, browser.locale, browser.timezone].filter(Boolean).join(' · ') + [ + browser.browser, + browser.address, + browser.email ? `u/${browser.auth_user}` : '', + browser.locale, + browser.timezone, + ] + .filter(Boolean) + .join(' · ') ) } @@ -315,7 +348,7 @@ async function scanBrowsers(): Promise { cookieBrowsers.value = await api.cookieBrowsers() selectedBrowsers.value = cookieBrowsers.value .filter((browser) => browser.email !== '' && !browser.imported && browser.error === '') - .map((browser) => browser.address) + .map((browser) => browserKey(browser)) } catch (error) { actionError(error) } finally { @@ -334,17 +367,20 @@ function closeBrowserImport(): void { async function importBrowserAccounts(): Promise { pendingAction.value = 'browser-import' try { - for (const address of selectedBrowsers.value) { + const chosen = new Set(selectedBrowsers.value) + const targets = cookieBrowsers.value.filter((browser) => chosen.has(browserKey(browser))) + for (const target of targets) { await api.importCookieAccount({ - label: '', + label: target.email, cookies: '', - cdp: address, + cdp: target.address, + auth_user: target.auth_user, proxy: accountEnvironment.proxy, locale: '', timezone: '', }) } - const count = selectedBrowsers.value.length + const count = targets.length pendingAction.value = '' closeBrowserImport() emit('refresh') @@ -410,20 +446,58 @@ const addMethods: { }, ] -// importCookieAccount 导入粘贴的 Cookie,账户由服务按 Cookie 登录的 AI Studio 识别 +// importCookieAccount 第一步识别 Cookie 中尚未导入的 u/0..u/9 账户供勾选,第二步导入用户勾选的账户 async function importCookieAccount(): Promise { + if (cookieCandidates.value.length === 0) { + pendingAction.value = 'cookie-discover' + try { + const discovered = await api.discoverCookieAccounts({ + label: '', + cookies: cookieText.value, + cdp: '', + ...accountEnvironment, + }) + cookieCandidates.value = discovered + selectedCookieUsers.value = [] + } catch (error) { + actionError(error) + } finally { + pendingAction.value = '' + } + return + } + if (selectedCookieUsers.value.length === 0) return pendingAction.value = 'cookie-import' try { - const { account } = await api.importCookieAccount({ - label: '', - cookies: cookieText.value, - cdp: '', - ...accountEnvironment, - }) + const chosen = new Set(selectedCookieUsers.value) + const targets = cookieCandidates.value.filter((item) => chosen.has(item.auth_user)) + let lastEmail = '' + for (const target of targets) { + const { account } = await api.importCookieAccount({ + label: target.email, + cookies: cookieText.value, + cdp: '', + auth_user: target.auth_user, + ...accountEnvironment, + }) + lastEmail = account.id + } + const count = targets.length pendingAction.value = '' closeCookieImport() emit('refresh') - emit('notice', t('accounts.importedAccount').replace('{email}', account.id), 'success') + if (count === 1 && lastEmail !== '') { + emit('notice', t('accounts.importedAccount').replace('{email}', lastEmail), 'success') + } else { + emit( + 'notice', + t(count === 1 ? 'accounts.importCompleteOne' : 'accounts.importComplete').replace( + '{count}', + String(count), + ), + 'success', + ) + } } catch (error) { actionError(error) } finally { @@ -994,7 +1068,7 @@ async function refreshAllAccounts(): Promise { - +
{ @@ -1141,7 +1274,7 @@ async function refreshAllAccounts(): Promise {
- @@ -1201,9 +1215,7 @@ async function refreshAllAccounts(): Promise { diff --git a/web/src/locales/de.ts b/web/src/locales/de.ts index e45b1dfe..e8561101 100644 --- a/web/src/locales/de.ts +++ b/web/src/locales/de.ts @@ -93,6 +93,7 @@ export const messages: Record = { 'accounts.browserLogin': 'Im Browser anmelden', 'accounts.importChrome': 'Aus Chrome importieren', 'accounts.chromeTitle': 'Chrome-Konten auswählen', + 'accounts.cookieAccounts': 'Konten in den Cookies', 'accounts.chromeEmpty': 'Keine Chrome-Konten zum Importieren vorhanden', 'accounts.importSelected': 'Auswahl importieren', 'accounts.cookies': 'Cookies', diff --git a/web/src/locales/en.ts b/web/src/locales/en.ts index ad0e8eec..b0700fe9 100644 --- a/web/src/locales/en.ts +++ b/web/src/locales/en.ts @@ -90,6 +90,7 @@ export const messages: Record = { 'accounts.browserLogin': 'Sign in with browser', 'accounts.importChrome': 'Import from Chrome', 'accounts.chromeTitle': 'Select Chrome accounts', + 'accounts.cookieAccounts': 'Accounts in the cookies', 'accounts.chromeEmpty': 'No Chrome accounts are available to import', 'accounts.importSelected': 'Import selected', 'accounts.cookies': 'Cookies', diff --git a/web/src/locales/fr.ts b/web/src/locales/fr.ts index 455af242..7f627e2c 100644 --- a/web/src/locales/fr.ts +++ b/web/src/locales/fr.ts @@ -93,6 +93,7 @@ export const messages: Record = { 'accounts.browserLogin': 'Se connecter avec le navigateur', 'accounts.importChrome': 'Importer depuis Chrome', 'accounts.chromeTitle': 'Sélectionner des comptes Chrome', + 'accounts.cookieAccounts': 'Comptes dans les cookies', 'accounts.chromeEmpty': 'Aucun compte Chrome à importer', 'accounts.importSelected': 'Importer la sélection', 'accounts.cookies': 'Cookies', diff --git a/web/src/locales/ja.ts b/web/src/locales/ja.ts index c7a61a27..909911bd 100644 --- a/web/src/locales/ja.ts +++ b/web/src/locales/ja.ts @@ -93,6 +93,7 @@ export const messages: Record = { 'accounts.browserLogin': 'ブラウザでログイン', 'accounts.importChrome': 'Chrome からインポート', 'accounts.chromeTitle': 'Chrome のアカウントを選択', + 'accounts.cookieAccounts': 'Cookie 内のアカウント', 'accounts.chromeEmpty': 'インポートできる Chrome のアカウントがありません', 'accounts.importSelected': '選択したアカウントをインポート', 'accounts.cookies': 'Cookie', diff --git a/web/src/locales/ko.ts b/web/src/locales/ko.ts index 65972f49..c424d9a4 100644 --- a/web/src/locales/ko.ts +++ b/web/src/locales/ko.ts @@ -91,6 +91,7 @@ export const messages: Record = { 'accounts.browserLogin': '브라우저로 로그인', 'accounts.importChrome': 'Chrome에서 가져오기', 'accounts.chromeTitle': 'Chrome 계정 선택', + 'accounts.cookieAccounts': 'Cookie의 계정', 'accounts.chromeEmpty': '가져올 수 있는 Chrome 계정이 없습니다', 'accounts.importSelected': '선택한 계정 가져오기', 'accounts.cookies': '쿠키', diff --git a/web/src/locales/zh-CN.ts b/web/src/locales/zh-CN.ts index bc97d87e..8d39b921 100644 --- a/web/src/locales/zh-CN.ts +++ b/web/src/locales/zh-CN.ts @@ -85,6 +85,7 @@ export const messages = { 'accounts.browserLogin': '浏览器登录', 'accounts.importChrome': '从 Chrome 导入', 'accounts.chromeTitle': '选择 Chrome 账户', + 'accounts.cookieAccounts': 'Cookie 中的账户', 'accounts.chromeEmpty': '没有可导入的 Chrome 账户', 'accounts.importSelected': '导入所选账户', 'accounts.cookies': 'Cookie', diff --git a/web/src/locales/zh-TW.ts b/web/src/locales/zh-TW.ts index 85ea2e84..f8ccfc1d 100644 --- a/web/src/locales/zh-TW.ts +++ b/web/src/locales/zh-TW.ts @@ -87,6 +87,7 @@ export const messages: Record = { 'accounts.browserLogin': '使用瀏覽器登入', 'accounts.importChrome': '從 Chrome 匯入', 'accounts.chromeTitle': '選擇 Chrome 帳號', + 'accounts.cookieAccounts': 'Cookie 中的帳號', 'accounts.chromeEmpty': '沒有可匯入的 Chrome 帳號', 'accounts.importSelected': '匯入所選帳號', 'accounts.cookies': 'Cookie',