From b93b2ea36ebe6440fe096099b60b7a288c597706 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Tue, 15 Sep 2026 11:46:08 +0800 Subject: [PATCH] fix: reuse browser, harden session restore without URL clash (v2.0.27) --- CHANGELOG.md | 4 ++ SKILL.md | 2 +- references/README.md | 2 +- scripts/service/browser_service.py | 94 +++++++++++++++++++++++-- scripts/service/chrome_profile_prefs.py | 80 ++++++++++++++++----- 5 files changed, 160 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f3c787..0becab7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ 本文件对应技能市场 **「更新日志」** Tab。 发布 CI 会按当前 tag 版本提取匹配的 `##` 小节。 +## 2.0.27 + +- 「打开浏览器」:Profile 已占用时复用窗口;加固 Preferences/Local State「继续上次会话」;有历史会话时冷启动不带 URL,避免冲掉登录态 + ## 2.0.26 - 「打开浏览器」启动前写入 Profile「继续上次会话」,减轻独立 Profile 下 Session Cookie 关窗后丢失(科力普等站点更明显) diff --git a/SKILL.md b/SKILL.md index b55b187..6aab8cd 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 账号与凭据管理 description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA 账号、API Key/Token;支持 9 种登录策略(账密自动、扫码、2FA、SSO 等);提供加密本地存储(Fernet);提供 rpa_helpers 库供业务 skill 同进程调用 ensure_logged_in 完成多平台自动登录。涵盖物流、LLM、内容、开发、API 五类平台。" -version: 2.0.26 +version: 2.0.27 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/references/README.md b/references/README.md index 0df5eec..13ee0d2 100644 --- a/references/README.md +++ b/references/README.md @@ -59,4 +59,4 @@ description: "通用账号 & 凭据 & 登录流程管理器。管理网页/RPA --- -**作者**:深圳匠厂科技有限公司 · **版本**:2.0.26 +**作者**:深圳匠厂科技有限公司 · **版本**:2.0.27 diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index b8262b9..1da2a31 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -192,6 +192,51 @@ def cmd_reveal_profile(account_id): ) +def _list_profile_browser_pids(profile_dir: str): + """找出已占用本 Profile 的 chrome/msedge PID(与采集技能同逻辑)。""" + import json as _json + + profile_abs = os.path.abspath(profile_dir) + needle = os.path.normcase(profile_abs) + if sys.platform != "win32": + return [] + ps = ( + "Get-CimInstance Win32_Process -Filter " + "\"name='chrome.exe' or name='msedge.exe'\" |" + " Select-Object ProcessId,CommandLine | ConvertTo-Json -Compress" + ) + try: + completed = subprocess.run( + ["powershell", "-NoProfile", "-Command", ps], + capture_output=True, + text=True, + timeout=20, + check=False, + ) + except Exception: + return [] + raw = (completed.stdout or "").strip() + if not raw: + return [] + try: + payload = _json.loads(raw) + except Exception: + return [] + rows = payload if isinstance(payload, list) else [payload] + pids = [] + for row in rows: + if not isinstance(row, dict): + continue + cmdline = str(row.get("CommandLine") or "") + if needle not in os.path.normcase(cmdline): + continue + try: + pids.append(int(row.get("ProcessId"))) + except (TypeError, ValueError): + continue + return sorted(set(pids)) + + def cmd_open(account_id): """ 打开该账号的持久化浏览器,仅用于肉眼核对 / 手工登录 / 抓 DOM。 @@ -226,6 +271,39 @@ def cmd_open(account_id): profile_abs = os.path.abspath(profile_dir) os.makedirs(profile_abs, exist_ok=True) + existing = _list_profile_browser_pids(profile_abs) + if existing: + log.info( + "browser_open_reuse account_id=%s profile_dir=%s pids=%s", + account_id, + profile_abs, + existing, + ) + print( + json.dumps( + { + "type": "result", + "schema_version": 1, + "status": "success", + "message": "已复用打开中的浏览器窗口", + "success": True, + "data": { + "account_id": int(target["id"]) if target.get("id") is not None else account_id, + "account_label": target.get("account_label"), + "browser": "Google Chrome", + "browser_exe": browser_exe, + "profile_dir": profile_abs, + "url": resolve_account_browser_url(target), + "pid": existing[0], + "reused": True, + "pids": existing, + }, + }, + ensure_ascii=False, + ) + ) + return + # 独立 Profile 默认不会「继续上次会话」,Session Cookie 关窗即丢;打开前写入 Preferences prefs_result = ensure_session_restore(profile_abs) log.info("browser_open_prefs %s", prefs_result) @@ -236,14 +314,22 @@ def cmd_open(account_id): finally: conn.close() - # 与 profile_shortcut 同一套人用参数:独立 user-data-dir 保存登录态 - cmd = [browser_exe, *human_chrome_launch_args(profile_abs, url)] + # 有可恢复会话时不带 URL;首次/空 Profile 仍打开平台首页便于登录 + session_dir = os.path.join(profile_abs, "Default") + has_session = any( + os.path.isfile(os.path.join(session_dir, name)) + for name in ("Current Session", "Last Session", "Current Tabs", "Last Tabs") + ) + launch_url = None if has_session else url + cmd = [browser_exe, *human_chrome_launch_args(profile_abs, launch_url)] log.info( - "browser_open_launch exe=%s profile_dir=%s url=%s platform=%s restore_on_startup=%s", + "browser_open_launch exe=%s profile_dir=%s launch_url=%s has_session=%s " + "platform=%s restore_on_startup=%s", browser_exe, profile_abs, - url, + launch_url or "(restore)", + has_session, target.get("platform_key"), prefs_result.get("restore_after"), ) diff --git a/scripts/service/chrome_profile_prefs.py b/scripts/service/chrome_profile_prefs.py index 655cabc..7dd58d6 100644 --- a/scripts/service/chrome_profile_prefs.py +++ b/scripts/service/chrome_profile_prefs.py @@ -3,11 +3,10 @@ from __future__ import annotations import json -import logging import os from typing import Any, Dict -logger = logging.getLogger(__name__) +from util.logging_config import get_skill_logger RESTORE_CONTINUE_WHERE_LEFT_OFF = 1 @@ -16,8 +15,60 @@ def preferences_path(profile_dir: str) -> str: return os.path.join(os.path.abspath(profile_dir), "Default", "Preferences") +def local_state_path(profile_dir: str) -> str: + return os.path.join(os.path.abspath(profile_dir), "Local State") + + +def _patch_preferences_dict(data: Dict[str, Any]) -> Dict[str, Any]: + before = int((data.get("session") or {}).get("restore_on_startup") or 0) + exit_before = str((data.get("profile") or {}).get("exit_type") or "") + + session = data.setdefault("session", {}) + session["restore_on_startup"] = RESTORE_CONTINUE_WHERE_LEFT_OFF + session.pop("startup_urls", None) + + profile = data.setdefault("profile", {}) + if str(profile.get("exit_type") or "").lower() == "crashed": + profile["exit_type"] = "Normal" + profile["exited_cleanly"] = True + + browser = data.setdefault("browser", {}) + browser["has_seen_welcome_page"] = True + browser["check_default_browser"] = False + + return {"restore_before": before, "exit_type_before": exit_before} + + +def _patch_local_state(profile_dir: str, log) -> Dict[str, Any]: + path = local_state_path(profile_dir) + if not os.path.isfile(path): + return {"ok": True, "skipped": True, "path": path} + try: + with open(path, "r", encoding="utf-8") as fh: + data = json.load(fh) + except Exception as exc: + log.warning("chrome_local_state_read_failed path=%s err=%s", path, exc) + return {"ok": False, "path": path, "error": str(exc)} + + profile = data.setdefault("profile", {}) + info = profile.setdefault("info_cache", {}).setdefault("Default", {}) + info["active_time"] = float(info.get("active_time") or 0.0) or 1.0 + exited = profile.get("exited_cleanly", True) + profile["exited_cleanly"] = True + try: + with open(path, "w", encoding="utf-8") as fh: + json.dump(data, fh, ensure_ascii=False, separators=(",", ":")) + except Exception as exc: + log.warning("chrome_local_state_write_failed path=%s err=%s", path, exc) + return {"ok": False, "path": path, "error": str(exc), "exited_cleanly_before": exited} + + log.info("chrome_local_state_patched path=%s exited_cleanly %s->True", path, exited) + return {"ok": True, "path": path, "exited_cleanly_before": exited} + + def ensure_session_restore(profile_dir: str) -> Dict[str, Any]: - """启动浏览器前写入 session.restore_on_startup=1;Crashed → Normal。""" + """启动浏览器前写入 session.restore_on_startup=1;清 startup_urls;Local State 干净退出。""" + log = get_skill_logger() profile_abs = os.path.abspath(profile_dir) prefs = preferences_path(profile_abs) os.makedirs(os.path.dirname(prefs), exist_ok=True) @@ -28,24 +79,18 @@ def ensure_session_restore(profile_dir: str) -> Dict[str, Any]: with open(prefs, "r", encoding="utf-8") as fh: data = json.load(fh) except Exception: - logger.exception("chrome_prefs_read_failed path=%s", prefs) + log.exception("chrome_prefs_read_failed path=%s", prefs) data = {} - before = int((data.get("session") or {}).get("restore_on_startup") or 0) - exit_before = str((data.get("profile") or {}).get("exit_type") or "") - - session = data.setdefault("session", {}) - session["restore_on_startup"] = RESTORE_CONTINUE_WHERE_LEFT_OFF - - profile = data.setdefault("profile", {}) - if str(profile.get("exit_type") or "").lower() == "crashed": - profile["exit_type"] = "Normal" + meta = _patch_preferences_dict(data) + before = int(meta["restore_before"]) + exit_before = str(meta["exit_type_before"] or "") try: with open(prefs, "w", encoding="utf-8") as fh: json.dump(data, fh, ensure_ascii=False, separators=(",", ":")) except Exception as exc: - logger.warning("chrome_prefs_write_failed path=%s err=%s", prefs, exc) + log.warning("chrome_prefs_write_failed path=%s err=%s", prefs, exc) return { "ok": False, "path": prefs, @@ -58,9 +103,10 @@ def ensure_session_restore(profile_dir: str) -> Dict[str, Any]: after = int((data.get("session") or {}).get("restore_on_startup") or 0) exit_after = str((data.get("profile") or {}).get("exit_type") or "") - logger.info( + local = _patch_local_state(profile_abs, log) + log.info( "chrome_session_restore_ensured profile_dir=%s prefs=%s existed=%s " - "restore_on_startup %s->%s exit_type %s->%s", + "restore_on_startup %s->%s exit_type %s->%s local_state=%s", profile_abs, prefs, existed, @@ -68,6 +114,7 @@ def ensure_session_restore(profile_dir: str) -> Dict[str, Any]: after, exit_before or "-", exit_after or "-", + local, ) return { "ok": True, @@ -77,4 +124,5 @@ def ensure_session_restore(profile_dir: str) -> Dict[str, Any]: "restore_after": after, "exit_type_before": exit_before, "exit_type_after": exit_after, + "local_state": local, }