feat: expand module workflows, dynamic config, notifications and console API

This commit is contained in:
2026-09-04 09:49:37 +08:00
parent 8df5266abb
commit b124d757b0
309 changed files with 89358 additions and 6232 deletions
+860
View File
@@ -0,0 +1,860 @@
"""Account-level browser login states shared by many script bindings.
One account owns a vault under ``state/accounts/<account_id>/`` holding the
authoritative cookies.json / storage_state.json and a dedicated login
profile. Member scripts keep their own isolated browser profiles (required
for parallel execution) but receive a merged copy of the vault cookies on
every run, so a single manual login refreshes the whole account group.
"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import time
from collections.abc import Callable
from datetime import datetime, timezone
from pathlib import Path, PurePosixPath
from typing import Any, TextIO
from urllib.parse import urlparse
from gyxx_flow.adapters.browser import BrowserCookieStore
from gyxx_flow.adapters.integration import (
RuntimeIntegrationCatalog,
_account_cookie_valid,
)
from gyxx_flow.adapters.scrapling import ScraplingBrowser, is_browser_timeout_error
from gyxx_flow.core.artifacts import atomic_write_json
from gyxx_flow.core.config import Settings
from gyxx_flow.core.locks import LockManager, ResourceBusyError
DEFAULT_LOGIN_TIMEOUT_SECONDS = 600
_DEFAULT_TARGET_URL = (
"https://fxg.jinritemai.com/ffa/merchant/campaign-square"
"?list_tab=access&f_tab=0"
)
_POLL_INTERVAL_SECONDS = 5
_PROGRESS_INTERVAL_SECONDS = 30
_KEEPALIVE_RETRY_SECONDS = 60
_KEEPALIVE_SETTLE_MILLISECONDS = 3_000
_KEEPALIVE_RESOURCE_PREFIX = "browser-account"
def _load_catalog(settings: Settings) -> RuntimeIntegrationCatalog:
return RuntimeIntegrationCatalog.load_default(
project_root=settings.project_root,
data_root=settings.data_root,
)
def account_status(
catalog: RuntimeIntegrationCatalog,
account_id: str,
) -> dict[str, object]:
account = catalog.account_for(account_id)
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
valid = False
if account.cookie_file.exists():
try:
valid = _account_cookie_valid(account, store.load_cookies())
except (OSError, ValueError):
valid = False
keepalive_status = _read_keepalive_status(account)
return {
"account_id": account.account_id,
"cdp_port": account.cdp_port,
"login_mode": account.login_mode,
"login_command_id": account.login_command_id,
"required_cookie_domains": list(account.required_cookie_domains),
"required_cookie_names": list(account.required_cookie_names),
"members": [
{
"script_id": binding.script_id,
"command_id": binding.command_id,
"cookie_file": str(binding.cookie_file),
}
for binding in catalog.bindings_for_account(account_id)
],
"vault_cookie_file": str(account.cookie_file),
"vault_exists": account.cookie_file.exists(),
"vault_valid": valid,
"keepalive": {
"enabled": account.keepalive_enabled,
"url": account.keepalive_url,
"initial_delay_seconds": account.keepalive_initial_delay_seconds,
"interval_seconds": account.keepalive_interval_seconds,
"timeout_ms": account.keepalive_timeout_ms,
"headless": account.keepalive_headless,
"real_chrome": account.keepalive_real_chrome,
"status": keepalive_status,
},
}
def list_accounts(settings: Settings) -> dict[str, object]:
catalog = _load_catalog(settings)
return {
"accounts": [
account_status(catalog, account.account_id)
for account in catalog.accounts
]
}
def sync_account(
settings: Settings,
account_id: str | None,
output: TextIO,
) -> dict[str, object]:
catalog = _load_catalog(settings)
targets = (
[account_id]
if account_id is not None
else [account.account_id for account in catalog.accounts]
)
if not targets:
raise ValueError("no runtime accounts are configured")
results: list[dict[str, object]] = []
for target in targets:
output.write(f"[sync] account={target}\n")
for item in catalog.sync_account_state(target):
flag = "updated" if item["synced"] else "up-to-date"
output.write(f" [{flag}] {item['script_id']}\n")
results.append(item)
return {"synced": results}
def seed_account(
settings: Settings,
account_id: str,
source_binding_id: str,
output: TextIO,
*,
force: bool = False,
) -> dict[str, object]:
"""Import one existing binding's authenticated state into an account vault.
Only cookies whose domains belong to the target account are copied. This
prevents a legacy browser profile containing several platforms from
leaking unrelated sessions into a new account vault.
"""
catalog = _load_catalog(settings)
account = catalog.account_for(account_id)
source = catalog.binding_for(source_binding_id)
if source.account and source.account != account_id:
raise ValueError(
f"source binding {source_binding_id} already belongs to account "
f"{source.account}"
)
target_store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
if account.cookie_file.exists() and not force:
if _vault_cookie_valid(account, target_store):
output.write(
f"[seed] account={account_id} already has a valid vault; "
"use --force to replace it\n"
)
return {"account_id": account_id, "status": "already_valid"}
raise ValueError(
f"account {account_id} has an existing invalid vault; "
"use --force to replace it"
)
source_store = BrowserCookieStore(source.cookie_file, source.storage_state_file)
source_cookies = source_store.load_cookies()
cookies = [
item
for item in source_cookies
if _cookie_matches_account(item, account.required_cookie_domains)
]
if not cookies:
raise ValueError(
f"source binding {source_binding_id} has no cookies for account "
f"{account_id}"
)
if not _account_cookie_valid(account, cookies):
raise ValueError(
f"source binding {source_binding_id} does not contain a valid "
f"login cookie for account {account_id}"
)
source_state = source_store.load_storage_state() or {}
state = _filter_storage_state(source_state, account.required_cookie_domains)
state["cookies"] = cookies
target_store.save_cookies(cookies)
target_store.save_storage_state(state)
pushed = catalog.sync_account_state(account_id)
synced_count = sum(1 for item in pushed if item["synced"])
output.write(
f"[seed] account={account_id} imported {len(cookies)} cookies from "
f"{source_binding_id}; synced {synced_count} members\n"
)
return {
"account_id": account_id,
"status": "seeded",
"cookie_count": len(cookies),
"source_binding": source_binding_id,
"synced_members": [item["script_id"] for item in pushed],
}
class AccountKeepaliveManager:
"""Refresh configured account sessions from the resident scheduler.
A refresh is deliberately published only after the safe URL remains on an
authenticated page and the required cookies are still live. This keeps a
redirect to login, a hard server-side expiry, or a challenge page from
replacing the last known-good vault state.
"""
def __init__(
self,
settings: Settings,
*,
catalog: RuntimeIntegrationCatalog | None = None,
clock: Callable[[], float] = time.monotonic,
login_runner: Callable[[Any], dict[str, object]] | None = None,
) -> None:
self.settings = settings
self.catalog = catalog or _load_catalog(settings)
self._clock = clock
self._next_due: dict[str, float] = {}
self._locks = LockManager(settings.data_root / "state" / "locks")
self._login_runner = login_runner or self._run_configured_login
def tick(self) -> list[dict[str, object]]:
"""Run due account refreshes once; never starts work in dry-run mode."""
now = self._clock()
outcomes: list[dict[str, object]] = []
for account in self.catalog.accounts:
if not account.keepalive_enabled:
continue
if account.account_id not in self._next_due:
self._next_due[account.account_id] = (
now + account.keepalive_initial_delay_seconds
)
if now < self._next_due.get(account.account_id, 0.0):
continue
outcome = self.refresh_account(account.account_id)
outcomes.append(outcome)
interval = account.keepalive_interval_seconds
if outcome["status"] in {"not_logged_in", "busy"}:
interval = min(interval, _KEEPALIVE_RETRY_SECONDS)
self._next_due[account.account_id] = now + interval
return outcomes
def refresh_account(self, account_id: str) -> dict[str, object]:
"""Refresh one configured account and return redacted operational evidence."""
account = self.catalog.account_for(account_id)
if not account.keepalive_enabled:
return self._record_keepalive_status(
account,
{"status": "disabled"},
)
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
resource = f"{_KEEPALIVE_RESOURCE_PREFIX}:{account.account_id}"
try:
with self._locks.acquire(
resource,
owner=f"keepalive:{account.account_id}",
timeout_seconds=0,
):
# Login can happen between the first check and lock acquisition.
# A prior authenticated-page failure also requires recovery even
# when the browser still reports a structurally live cookie.
vault_valid = _vault_cookie_valid(account, store)
needs_relogin = not vault_valid or _keepalive_requires_relogin(
_read_keepalive_status(account)
)
if needs_relogin and account.login_mode == "C":
return self._recover_locked(account, store)
if not vault_valid:
return self._record_keepalive_status(
account,
{"status": "not_logged_in"},
)
return self._refresh_locked(account)
except ResourceBusyError:
return self._record_keepalive_status(account, {"status": "busy"})
def _recover_locked(
self,
account: Any,
store: BrowserCookieStore,
) -> dict[str, object]:
"""Run the configured credential login and publish only verified state."""
try:
result = self._login_runner(account)
except Exception as exc: # pragma: no cover - runtime dependent
result = {
"status": "not_logged_in",
"login_attempted": True,
"login_error_type": type(exc).__name__,
}
if isinstance(result, dict) and result.get("status") in {
"success",
"logged_in",
} and _vault_cookie_valid(account, store):
try:
cookie_count = len(store.load_cookies())
except (OSError, ValueError):
cookie_count = 0
try:
pushed = self.catalog.sync_account_state(account.account_id)
except Exception as exc: # pragma: no cover - runtime dependent
return self._record_keepalive_status(
account,
{
"status": "error",
"error_type": type(exc).__name__,
"login_recovered": True,
},
)
return self._record_keepalive_status(
account,
{
"status": "success",
"login_recovered": True,
"cookie_count": cookie_count,
"synced_members": sum(
1 for item in pushed if item.get("synced")
),
},
)
evidence: dict[str, object] = {
"status": "not_logged_in",
"login_attempted": True,
}
if isinstance(result, dict):
for key in ("login_reason", "login_error_type", "login_exit_code"):
value = result.get(key)
if isinstance(value, (str, int)) and not isinstance(value, bool):
evidence[key] = value
attempted = result.get("login_attempted")
if isinstance(attempted, bool):
evidence["login_attempted"] = attempted
return self._record_keepalive_status(account, evidence)
def _run_configured_login(self, account: Any) -> dict[str, object]:
"""Invoke a binding-owned login script without putting secrets in argv."""
command_id = str(getattr(account, "login_command_id", "") or "").strip()
if not command_id:
return {
"status": "not_logged_in",
"login_attempted": False,
"login_reason": "no_login_command",
}
try:
binding = self.catalog.binding_for(command_id)
except Exception: # pragma: no cover - invalid deployment configuration
return {
"status": "not_logged_in",
"login_attempted": False,
"login_reason": "login_command_unavailable",
}
try:
environment = self.catalog.environment_for(
binding.command_id,
os.environ,
)
except Exception as exc: # pragma: no cover - runtime configuration dependent
return {
"status": "not_logged_in",
"login_attempted": False,
"login_error_type": type(exc).__name__,
}
config_path = Path(
environment.get("GYXX_PRODUCT_CONFIG", "").strip()
or (
self.settings.data_root
/ "state"
/ "product_commerce"
/ "auto-flow-config.json"
)
).expanduser()
environment["GYXX_PRODUCT_CONFIG"] = str(config_path)
credential_names = tuple(getattr(account, "credential_env_names", ()) or ())
username = environment.get(credential_names[0], "") if credential_names else ""
password = ""
for name in credential_names[1:]:
candidate = environment.get(name, "")
if candidate.strip():
password = candidate
break
# Product ERP login historically keeps its login block in the ignored
# runtime config. Let the child script read that file when the
# conventional credential env vars are absent; secrets still never
# enter argv or this manager's diagnostic result.
if (not username.strip() or not password) and not config_path.is_file():
return {
"status": "not_logged_in",
"login_attempted": False,
"login_reason": "credentials_unavailable",
}
# The ERP login script reads these conventional names. Values stay in
# the child environment and never appear in the process argument list.
environment["ERP_USERNAME"] = username
environment["ERP_PASSWORD"] = password
try:
script_path = self._login_script_path(binding)
except (OSError, ValueError):
return {
"status": "not_logged_in",
"login_attempted": False,
"login_reason": "login_script_unavailable",
}
timeout_seconds = max(
DEFAULT_LOGIN_TIMEOUT_SECONDS,
(int(account.keepalive_timeout_ms) // 1000) * 3,
)
try:
completed = subprocess.run(
[sys.executable, str(script_path), "--login-only"],
cwd=str(self.settings.project_root),
env=environment,
check=False,
timeout=timeout_seconds,
)
except subprocess.TimeoutExpired:
return {
"status": "not_logged_in",
"login_attempted": True,
"login_error_type": "TimeoutExpired",
}
except OSError as exc: # pragma: no cover - runtime dependent
return {
"status": "not_logged_in",
"login_attempted": True,
"login_error_type": type(exc).__name__,
}
try:
exit_code = int(getattr(completed, "returncode", 1))
except (TypeError, ValueError):
exit_code = 1
return {
"status": "success" if exit_code == 0 else "not_logged_in",
"login_attempted": True,
"login_exit_code": exit_code,
}
def _login_script_path(self, binding: Any) -> Path:
project_root = Path(self.settings.project_root).expanduser().resolve()
module_root = (
project_root / "src" / "gyxx_flow" / "modules" / str(binding.module)
).resolve()
entry = PurePosixPath(str(binding.entry))
if entry.is_absolute() or ".." in entry.parts:
raise ValueError("login script entry must stay inside its module")
script_path = module_root.joinpath(*entry.parts).resolve()
if not script_path.is_relative_to(module_root) or not script_path.is_file():
raise ValueError("configured login script is unavailable")
return script_path
def _refresh_locked(
self,
account: Any,
) -> dict[str, object]:
browser = ScraplingBrowser(
user_data_dir=str(account.profile_dir),
cookie_file=account.cookie_file,
storage_state_file=account.storage_state_file,
headless=account.keepalive_headless,
real_chrome=account.keepalive_real_chrome,
retries=1,
persist_state_on_close=False,
)
try:
browser.start()
response, page_state = browser.run_fetch_action(
account.keepalive_url,
lambda page: _probe_account_page(page, account),
wait=0,
network_idle=False,
timeout=account.keepalive_timeout_ms,
)
final_url = str(page_state.get("url", ""))
identity_matches = bool(page_state.get("identity_matches", False))
status = getattr(response, "status", None)
if isinstance(status, int) and status >= 400:
return self._record_keepalive_status(
account,
{"status": "http_error", "http_status": status},
)
context = browser.context
if context is None:
raise RuntimeError("browser context was not created")
cookies = context.cookies()
if (
_looks_like_login_url(final_url)
or not identity_matches
or not _account_cookie_valid(account, cookies)
):
return self._record_keepalive_status(
account,
{
"status": "expired",
"final_url_login": _looks_like_login_url(final_url),
"identity_mismatch": not identity_matches,
},
)
# Scrapling may have received Set-Cookie headers while navigating.
# Publish only this verified authenticated snapshot.
browser.persist_bound_state()
pushed = self.catalog.sync_account_state(account.account_id)
return self._record_keepalive_status(
account,
{
"status": "success",
"cookie_count": len(cookies),
"synced_members": sum(
1 for item in pushed if item.get("synced")
),
},
)
except Exception as exc: # pragma: no cover - browser/runtime dependent
return self._record_keepalive_status(
account,
{
"status": "timeout" if is_browser_timeout_error(exc) else "error",
"error_type": type(exc).__name__,
},
)
finally:
try:
browser.close()
except Exception: # pragma: no cover - browser/runtime dependent
pass
@staticmethod
def _status_path(account: Any):
return account.cookie_file.with_name("keepalive_status.json")
def _record_keepalive_status(
self,
account: Any,
result: dict[str, object],
) -> dict[str, object]:
path = self._status_path(account)
previous: dict[str, object] = {}
if path.exists():
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if isinstance(payload, dict):
previous = payload
except (OSError, json.JSONDecodeError):
previous = {}
if result.get("status") == "success":
# A prior failed probe may have recorded these diagnostic fields.
# They must not survive a later verified successful refresh.
previous.pop("final_url_login", None)
previous.pop("identity_mismatch", None)
previous.pop("error_type", None)
previous.pop("http_status", None)
previous.pop("login_attempted", None)
previous.pop("login_recovered", None)
previous.pop("login_reason", None)
previous.pop("login_error_type", None)
previous.pop("login_exit_code", None)
timestamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
payload = {
**previous,
"account_id": account.account_id,
"updated_at": timestamp,
**result,
}
if result.get("status") == "success":
payload["last_success_at"] = timestamp
atomic_write_json(path, payload)
return dict(result)
def _vault_cookie_valid(account: Any, store: BrowserCookieStore) -> bool:
if not account.cookie_file.exists():
return False
try:
return _account_cookie_valid(account, store.load_cookies())
except (OSError, ValueError):
return False
def _cookie_matches_account(
cookie: dict[str, object], required_domains: tuple[str, ...]
) -> bool:
domain = str(cookie.get("domain", "")).lstrip(".").casefold()
return bool(domain) and any(
domain == required.casefold()
or domain.endswith("." + required.casefold())
for required in required_domains
)
def _filter_storage_state(
state: dict[str, object], required_domains: tuple[str, ...]
) -> dict[str, object]:
filtered = dict(state)
origins = state.get("origins")
if isinstance(origins, list):
filtered["origins"] = [
origin
for origin in origins
if isinstance(origin, dict)
and _origin_matches_account(origin, required_domains)
]
else:
filtered["origins"] = []
return filtered
def _origin_matches_account(
origin: dict[str, object], required_domains: tuple[str, ...]
) -> bool:
hostname = urlparse(str(origin.get("origin", ""))).hostname or ""
hostname = hostname.casefold()
return bool(hostname) and any(
hostname == required.casefold()
or hostname.endswith("." + required.casefold())
for required in required_domains
)
def _looks_like_login_url(url: str) -> bool:
parsed = urlparse(str(url))
hostname = (parsed.hostname or "").casefold()
path = parsed.path.casefold()
markers = ("login", "passport", "signin", "sign-in")
return any(marker in hostname or marker in path for marker in markers)
def _probe_account_page(page: Any, account: Any) -> dict[str, object]:
"""Read account state only after client-side redirects have settled."""
wait_for_timeout = getattr(page, "wait_for_timeout", None)
if callable(wait_for_timeout):
wait_for_timeout(_KEEPALIVE_SETTLE_MILLISECONDS)
return {
"url": str(getattr(page, "url", "")),
"identity_matches": _page_matches_account_identity(page, account),
}
def _page_matches_account_identity(page: Any, account: Any) -> bool:
"""Require the authenticated page to identify the configured account."""
if _looks_like_login_url(str(getattr(page, "url", ""))):
return False
markers = tuple(getattr(account, "login_identity_markers", ()) or ())
if not markers:
return True
evaluate = getattr(page, "evaluate", None)
if not callable(evaluate):
return False
result = evaluate(
"""
(markers) => {
const text = (document.body && document.body.innerText)
|| document.documentElement.outerHTML
|| "";
const normalized = text.toLocaleLowerCase();
return markers.some((marker) => normalized.includes(
String(marker).toLocaleLowerCase()
));
}
""",
list(markers),
)
return bool(result)
def _read_keepalive_status(account: Any) -> dict[str, object] | None:
path = account.cookie_file.with_name("keepalive_status.json")
if not path.exists():
return None
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
return payload if isinstance(payload, dict) else None
def _keepalive_requires_relogin(status: dict[str, object] | None) -> bool:
"""Do not let a structurally valid cookie bypass a failed identity check."""
if not status:
return False
return bool(status.get("identity_mismatch")) or status.get("status") in {
"expired",
"not_logged_in",
}
def login_account(
settings: Settings,
account_id: str,
*,
output: TextIO,
timeout_seconds: int = DEFAULT_LOGIN_TIMEOUT_SECONDS,
target_url: str | None = None,
force: bool = False,
) -> dict[str, object]:
"""Open the account login browser, wait for a human login, and save the vault."""
catalog = _load_catalog(settings)
account = catalog.account_for(account_id)
target_url = target_url or account.keepalive_url or _DEFAULT_TARGET_URL
store = BrowserCookieStore(account.cookie_file, account.storage_state_file)
current_status = account_status(catalog, account_id)
existing_valid = current_status["vault_valid"]
keepalive_status = current_status["keepalive"]["status"]
needs_relogin = _keepalive_requires_relogin(keepalive_status)
if existing_valid and not needs_relogin and not force:
output.write(
f"[login] 账号 {account_id} 的登录态已有效,跳过(--force 强制重登)\n"
)
return {"account_id": account_id, "status": "already_valid"}
output.write(
f"[login] 账号 {account_id}CDP 端口 {account.cdp_port}\n"
f"[login] 正在打开浏览器: {account.profile_dir}\n"
f"[login] 请在浏览器窗口中完成登录(扫码/验证码),最长等待 {timeout_seconds}s\n"
)
started = time.monotonic()
deadline = started + timeout_seconds
last_progress = started
vault_saved = False
cookie_count = 0
locks = LockManager(settings.data_root / "state" / "locks")
try:
browser_lock = locks.acquire(
f"{_KEEPALIVE_RESOURCE_PREFIX}:{account.account_id}",
owner=f"login:{account.account_id}",
timeout_seconds=0,
)
browser_lock.__enter__()
except ResourceBusyError as exc:
raise ValueError(
f"账号 {account_id} 的浏览器正在被 keepalive 使用,请稍后重试"
) from exc
browser = ScraplingBrowser(
stealthy=True,
user_data_dir=str(account.profile_dir),
headless=False,
real_chrome=True,
retries=1,
persist_state_on_close=False,
extra_flags=[
f"--remote-debugging-port={account.cdp_port}",
"--remote-debugging-address=127.0.0.1",
"--disable-session-crashed-bubble",
"--disable-features=InfiniteSessionRestore",
],
)
try:
try:
browser.start()
except Exception as exc: # pragma: no cover - browser dependent
raise ValueError(
"无法启动账号登录浏览器(可能已被占用)。"
"请先关闭该账号的浏览器进程后重试"
) from exc
context = browser.context
if context is None:
raise ValueError("账号登录浏览器未创建上下文")
page = context.new_page()
if force:
# ``--force`` is used for account switching as well as expiry
# recovery. Clear only this account's live browser context so a
# still-valid old cookie cannot make the loop accept the old
# identity before the operator enters the new one. The
# authoritative vault is not overwritten until verification
# succeeds below.
clear_cookies = getattr(context, "clear_cookies", None)
if not callable(clear_cookies):
raise ValueError("强制切换账号需要浏览器支持清理 cookie")
clear_cookies()
try:
page.goto(target_url, wait_until="domcontentloaded", timeout=60_000)
page.evaluate(
"() => { localStorage.clear(); sessionStorage.clear(); }"
)
except Exception:
# Some login pages deny storage access before their first
# navigation; cookie clearing is still sufficient and the
# official page is opened again below.
pass
page.goto(target_url, wait_until="domcontentloaded", timeout=60_000)
while True:
cookies = context.cookies()
page_state = _probe_account_page(page, account)
if _account_cookie_valid(account, cookies) and bool(
page_state["identity_matches"]
):
break
now = time.monotonic()
if now - last_progress >= _PROGRESS_INTERVAL_SECONDS:
elapsed = int(now - started)
output.write(
f"[login] 等待登录中... {elapsed}s / {timeout_seconds}s "
f"(共 {len(cookies)} 个 cookie)\n"
)
last_progress = now
if now >= deadline:
raise TimeoutError(
f"等待登录超时({timeout_seconds}s),未检测到登录态;"
"浏览器状态保留在账号 profile 中,可再次运行本命令继续"
)
time.sleep(_POLL_INTERVAL_SECONDS)
cookies = context.cookies()
storage_state = context.storage_state()
store.save_cookies(cookies)
store.save_storage_state(storage_state)
vault_saved = True
cookie_count = len(cookies)
output.write(
f"[login] 登录成功,已保存 {cookie_count} 个 cookie "
f"{account.cookie_file}\n"
)
except KeyboardInterrupt:
output.write(
"[login] 已中断;未保存账号登录态,profile 保留以便继续\n"
)
return {"account_id": account_id, "status": "interrupted"}
finally:
try:
browser.close()
except Exception: # pragma: no cover - browser dependent
pass
browser_lock.__exit__(None, None, None)
if not vault_saved:
return {"account_id": account_id, "status": "not_logged_in"}
pushed = catalog.sync_account_state(account_id)
for item in pushed:
output.write(
f" [sync] {'updated' if item['synced'] else 'up-to-date'} "
f"{item['script_id']}\n"
)
return {
"account_id": account_id,
"status": "logged_in",
"cookie_count": cookie_count,
"synced_members": [item["script_id"] for item in pushed],
}