diff --git a/SKILL.md b/SKILL.md index 6de1f71..ad0eb1c 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.0 +version: 2.0.1 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/scripts/service/account_add_commands.py b/scripts/service/account_add_commands.py index 9a09a11..1e818f7 100644 --- a/scripts/service/account_add_commands.py +++ b/scripts/service/account_add_commands.py @@ -38,6 +38,7 @@ from service.secret_store import ( ) from util.logging_config import get_skill_logger from util.platforms import get_platform_spec, seed_platforms +from util.profile_shortcut import create_profile_browser_shortcut from util.runtime_paths import get_default_profile_dir_for_web @@ -160,8 +161,10 @@ def cmd_account_add_web( finally: conn.close() + shortcut_path = create_profile_browser_shortcut(resolved_profile_dir, safe_label, log) + log.info("account_add_web_success id=%s platform=%s profile_dir=%s", new_id, key, resolved_profile_dir) - _say(_ok_json({ + ok_payload = { "id": new_id, "platform_key": key, "account_label": safe_label, @@ -176,7 +179,10 @@ def cmd_account_add_web( "auth_strategy": resolved_auth, "session_persistent": sp, "device_fingerprint": device_fingerprint, - })) + } + if shortcut_path: + ok_payload["profile_shortcut"] = shortcut_path + _say(_ok_json(ok_payload)) def cmd_account_add_secret( diff --git a/scripts/service/browser_service.py b/scripts/service/browser_service.py index 1f39b4a..a8d6075 100644 --- a/scripts/service/browser_service.py +++ b/scripts/service/browser_service.py @@ -33,13 +33,12 @@ def _win_find_exe(candidates): return None -def resolve_chromium_channel(): +def resolve_chromium_executable(): """ - Playwright channel: 'chrome' | 'msedge' | None - Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。 + Windows 上返回 chrome.exe 或 msedge.exe 的绝对路径;未找到或非 Windows 返回 None。 """ if sys.platform != "win32": - return "chrome" + return None pf = os.environ.get("ProgramFiles", r"C:\Program Files") pfx86 = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") @@ -53,7 +52,7 @@ def resolve_chromium_channel(): ] ) if chrome: - return "chrome" + return chrome edge = _win_find_exe( [ @@ -63,11 +62,27 @@ def resolve_chromium_channel(): ] ) if edge: - return "msedge" + return edge return None +def resolve_chromium_channel(): + """ + Playwright channel: 'chrome' | 'msedge' | None + Windows 优先 Chrome,其次 Edge;其它系统默认 chrome(由 Playwright 解析 PATH)。 + """ + if sys.platform != "win32": + return "chrome" + + exe = resolve_chromium_executable() + if not exe: + return None + if os.path.basename(exe).lower() == "msedge.exe": + return "msedge" + return "chrome" + + def _print_browser_install_hint(): print("❌ 未检测到 Google Chrome 或 Microsoft Edge,请先安装:") print(" • Chrome: https://www.google.com/chrome/") diff --git a/scripts/util/profile_shortcut.py b/scripts/util/profile_shortcut.py new file mode 100644 index 0000000..581ff75 --- /dev/null +++ b/scripts/util/profile_shortcut.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""在 Profile 目录内创建浏览器快捷方式(Windows .lnk)。""" +from __future__ import annotations + +import logging +import os +import re +import subprocess +import sys +from typing import Optional + +from service.browser_service import resolve_chromium_executable + +_WIN_LNK_FORBIDDEN = re.compile(r'[<>:"/\\|?*\x00-\x1f]') + + +def _lnk_safe_filename(name: str, fallback: str = "browser") -> str: + t = _WIN_LNK_FORBIDDEN.sub("_", (name or "").strip()) + t = t.rstrip(" .") + return t or fallback + + +def _ps_single_quote(value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + +def create_profile_browser_shortcut( + profile_dir: str, + account_label: str, + log: Optional[logging.Logger] = None, +) -> Optional[str]: + """ + 在 profile_dir 内创建 {account_label}.lnk,目标为系统 Chrome/Edge + --user-data-dir。 + + 非 Windows 或未安装 Chrome/Edge 时静默跳过,返回 None。 + 成功返回 .lnk 绝对路径。 + """ + if log is None: + log = logging.getLogger(__name__) + + if sys.platform != "win32": + log.debug("profile_shortcut_skip platform=%s", sys.platform) + return None + + browser_exe = resolve_chromium_executable() + if not browser_exe: + log.warning("profile_shortcut_skip reason=no_chromium") + return None + + profile_abs = os.path.abspath(profile_dir) + os.makedirs(profile_abs, exist_ok=True) + + lnk_name = f"{_lnk_safe_filename(account_label)}.lnk" + lnk_path = os.path.join(profile_abs, lnk_name) + arguments = f'--user-data-dir="{profile_abs}"' + working_dir = os.path.dirname(browser_exe) + + ps = ( + "$WshShell = New-Object -ComObject WScript.Shell; " + f"$s = $WshShell.CreateShortcut({_ps_single_quote(lnk_path)}); " + f"$s.TargetPath = {_ps_single_quote(browser_exe)}; " + f"$s.Arguments = {_ps_single_quote(arguments)}; " + f"$s.IconLocation = {_ps_single_quote(browser_exe + ',0')}; " + f"$s.WorkingDirectory = {_ps_single_quote(working_dir)}; " + "$s.Save()" + ) + + try: + subprocess.run( + ["powershell", "-NoProfile", "-NonInteractive", "-Command", ps], + check=True, + capture_output=True, + text=True, + ) + except (OSError, subprocess.CalledProcessError) as exc: + log.warning("profile_shortcut_failed profile_dir=%s error=%s", profile_abs, exc) + return None + + if not os.path.isfile(lnk_path): + log.warning("profile_shortcut_missing_after_create path=%s", lnk_path) + return None + + log.info("profile_shortcut_created path=%s browser=%s", lnk_path, browser_exe) + return lnk_path diff --git a/tests/test_profile_shortcut.py b/tests/test_profile_shortcut.py new file mode 100644 index 0000000..5f3e396 --- /dev/null +++ b/tests/test_profile_shortcut.py @@ -0,0 +1,75 @@ +# -*- coding: utf-8 -*- +"""Profile 目录浏览器快捷方式测试。""" +import os +import sys + +import pytest + +from util.profile_shortcut import _lnk_safe_filename, create_profile_browser_shortcut + + +class TestLnkSafeFilename: + def test_strips_forbidden_chars(self) -> None: + assert _lnk_safe_filename('小红书/测试:账号') == "小红书_测试_账号" + + def test_empty_uses_fallback(self) -> None: + assert _lnk_safe_filename(" ") == "browser" + + +class TestCreateProfileBrowserShortcut: + def test_skips_non_windows(self, tmp_path, monkeypatch) -> None: + monkeypatch.setattr("util.profile_shortcut.sys.platform", "linux") + out = create_profile_browser_shortcut(str(tmp_path), "demo") + assert out is None + + def test_skips_when_no_browser(self, tmp_path, monkeypatch) -> None: + monkeypatch.setattr("util.profile_shortcut.sys.platform", "win32") + monkeypatch.setattr( + "util.profile_shortcut.resolve_chromium_executable", + lambda: None, + ) + out = create_profile_browser_shortcut(str(tmp_path), "demo") + assert out is None + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only") + def test_creates_lnk_on_windows(self, tmp_path, monkeypatch) -> None: + from service.browser_service import resolve_chromium_executable + + browser_exe = resolve_chromium_executable() + if not browser_exe: + pytest.skip("no Chrome/Edge installed") + + label = "ShortcutTest" + out = create_profile_browser_shortcut(str(tmp_path), label) + assert out is not None + assert os.path.isfile(out) + assert out.endswith(f"{label}.lnk") + + @pytest.mark.skipif(sys.platform != "win32", reason="Windows .lnk only") + def test_add_web_creates_shortcut(self, clean_db, fake_env, monkeypatch) -> None: + from service.browser_service import resolve_chromium_executable + from service.account_service import cmd_account_add_web + from db.accounts_repo import find_accounts + + if not resolve_chromium_executable(): + pytest.skip("no Chrome/Edge installed") + + label = "Maersk shortcut test" + cmd_account_add_web( + platform_input="maersk", + login_id="shortcut@test.com", + login_id_type="email", + label=label, + tenant_id="tenant-test", + environment="production", + role="booking", + provider_code=None, + url=None, + extra_json_str=None, + profile_dir_override=None, + ) + accs = find_accounts(platform_key="maersk") + assert len(accs) == 1 + profile_dir = accs[0]["profile_dir"] + lnk_path = os.path.join(profile_dir, f"{label}.lnk") + assert os.path.isfile(lnk_path), f"expected shortcut at {lnk_path}"