Release v1.0.10: shared media_assets and screencast media_assets_root override
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
All checks were successful
Publish Python Package to Gitea / publish (push) Successful in 17s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -23,8 +23,11 @@ from .unified_logging import (
|
||||
)
|
||||
from .media_assets import (
|
||||
MediaAssetsStatus,
|
||||
background_music_issue,
|
||||
ensure_media_assets,
|
||||
pick_background_music,
|
||||
probe_ffmpeg,
|
||||
probe_media_assets,
|
||||
resolve_ffmpeg,
|
||||
resolve_ffprobe,
|
||||
resolve_media_assets_root,
|
||||
@@ -43,7 +46,10 @@ __all__ = [
|
||||
"EntitlementResult",
|
||||
"EntitlementServiceError",
|
||||
"MediaAssetsStatus",
|
||||
"background_music_issue",
|
||||
"ensure_media_assets",
|
||||
"probe_ffmpeg",
|
||||
"probe_media_assets",
|
||||
"pick_background_music",
|
||||
"resolve_ffmpeg",
|
||||
"resolve_ffprobe",
|
||||
|
||||
@@ -69,6 +69,69 @@ def reset_cache() -> None:
|
||||
_example_defaults_cache = None
|
||||
|
||||
|
||||
def get_env_file_path() -> str | None:
|
||||
return _env_file_path
|
||||
|
||||
|
||||
def get_example_path() -> str | None:
|
||||
return _example_path
|
||||
|
||||
|
||||
def _iter_env_assignments(path: str) -> list[tuple[str, str]]:
|
||||
"""按文件顺序返回 (key, 完整赋值行)。"""
|
||||
result: list[tuple[str, str]] = []
|
||||
if not path or not os.path.isfile(path):
|
||||
return result
|
||||
with open(path, encoding="utf-8") as f:
|
||||
for raw_line in f:
|
||||
line = raw_line.rstrip("\n\r")
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
if "=" not in stripped:
|
||||
continue
|
||||
key, _, _ = stripped.partition("=")
|
||||
key = key.strip()
|
||||
if key:
|
||||
result.append((key, stripped))
|
||||
return result
|
||||
|
||||
|
||||
def merge_missing_env_keys(
|
||||
example_path: str,
|
||||
dest_path: str,
|
||||
*,
|
||||
comment_skill: str | None = None,
|
||||
) -> list[str]:
|
||||
"""将 example 中有而用户 .env 没有的 key 追加到 dest 末尾,不修改已有项。"""
|
||||
if not example_path or not os.path.isfile(example_path):
|
||||
return []
|
||||
if not dest_path or not os.path.isfile(dest_path):
|
||||
return []
|
||||
|
||||
dest_keys = set(_parse_env_file(dest_path).keys())
|
||||
to_append: list[tuple[str, str]] = []
|
||||
for key, assignment in _iter_env_assignments(example_path):
|
||||
if key not in dest_keys:
|
||||
to_append.append((key, assignment))
|
||||
|
||||
if not to_append:
|
||||
return []
|
||||
|
||||
header = (
|
||||
f"# Added by {comment_skill} from .env.example"
|
||||
if comment_skill
|
||||
else "# Added from .env.example"
|
||||
)
|
||||
with open(dest_path, "a", encoding="utf-8") as f:
|
||||
f.write("\n\n" + header + "\n")
|
||||
for _, assignment in to_append:
|
||||
f.write(assignment + "\n")
|
||||
|
||||
reset_cache()
|
||||
return [key for key, _ in to_append]
|
||||
|
||||
|
||||
def ensure_env_file(skill_slug: str, example_path: str) -> str:
|
||||
"""首次把 .env.example copy 到 {data_root}/{user}/{slug}/.env(已存在不覆盖),返回路径。"""
|
||||
global _skill_slug, _example_path, _env_file_path
|
||||
|
||||
@@ -27,6 +27,67 @@ _WIN_DEFAULT_DATA_ROOT = Path(r"D:\jiangchang-data")
|
||||
_NON_WIN_DEFAULT_DATA_ROOT = Path.home() / ".jiangchang"
|
||||
|
||||
_AUDIO_EXTENSIONS = {".mp3", ".wav", ".m4a", ".aac"}
|
||||
_GIT_LFS_POINTER_PREFIX = b"version https://git-lfs.github.com/spec/v1"
|
||||
_MIN_AUDIO_BYTES = 128
|
||||
|
||||
|
||||
def _is_git_lfs_pointer(path: Path) -> bool:
|
||||
try:
|
||||
head = path.read_bytes()[:256]
|
||||
except OSError:
|
||||
return False
|
||||
return head.startswith(_GIT_LFS_POINTER_PREFIX)
|
||||
|
||||
|
||||
def _enumerate_audio_like(music_dir: Path) -> list[Path]:
|
||||
return [
|
||||
path
|
||||
for path in music_dir.rglob("*")
|
||||
if path.is_file() and path.suffix.lower() in _AUDIO_EXTENSIONS
|
||||
]
|
||||
|
||||
|
||||
def _music_content_issue(music_dir: Optional[Path]) -> Optional[str]:
|
||||
if music_dir is None or not music_dir.is_dir():
|
||||
return "music_dir_missing"
|
||||
files = _enumerate_audio_like(music_dir)
|
||||
if not files:
|
||||
return "background_music_dir_empty"
|
||||
if all(_is_git_lfs_pointer(path) for path in files):
|
||||
return "background_music_lfs_pointer_only"
|
||||
if not any(_is_usable_audio_file(path) for path in files):
|
||||
return "background_music_invalid"
|
||||
return None
|
||||
|
||||
|
||||
def _music_is_ready(music_dir: Optional[Path]) -> bool:
|
||||
return _music_content_issue(music_dir) is None
|
||||
|
||||
|
||||
def _needs_music_content_repair(status: MediaAssetsStatus) -> bool:
|
||||
if status.music_dir is None:
|
||||
return "music_dir_missing" in status.warnings
|
||||
issue = _music_content_issue(status.music_dir)
|
||||
return issue in (
|
||||
"background_music_dir_empty",
|
||||
"background_music_lfs_pointer_only",
|
||||
"background_music_invalid",
|
||||
)
|
||||
|
||||
|
||||
def _is_usable_audio_file(path: Path) -> bool:
|
||||
if not path.is_file():
|
||||
return False
|
||||
if path.suffix.lower() not in _AUDIO_EXTENSIONS:
|
||||
return False
|
||||
if _is_git_lfs_pointer(path):
|
||||
return False
|
||||
try:
|
||||
if path.stat().st_size < _MIN_AUDIO_BYTES:
|
||||
return False
|
||||
except OSError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -39,6 +100,7 @@ class MediaAssetsStatus:
|
||||
ffmpeg_path: Optional[Path] = None
|
||||
ffprobe_path: Optional[Path] = None
|
||||
music_dir: Optional[Path] = None
|
||||
music_ready: bool = False
|
||||
fonts_dir: Optional[Path] = None
|
||||
watermark_dir: Optional[Path] = None
|
||||
|
||||
@@ -275,6 +337,13 @@ def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
|
||||
if not ffprobe_exists:
|
||||
warnings.append("ffprobe_missing")
|
||||
|
||||
music_ready = False
|
||||
if music_exists:
|
||||
music_issue = _music_content_issue(music_dir)
|
||||
if music_issue and music_issue != "music_dir_missing":
|
||||
warnings.append(music_issue)
|
||||
music_ready = music_issue is None
|
||||
|
||||
ready = (
|
||||
exists
|
||||
and music_exists
|
||||
@@ -282,6 +351,7 @@ def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
|
||||
and watermark_exists
|
||||
and ffmpeg_exists
|
||||
and ffprobe_exists
|
||||
and music_ready
|
||||
)
|
||||
|
||||
return MediaAssetsStatus(
|
||||
@@ -293,6 +363,7 @@ def _inspect_media_assets(root: Path, *, source: str) -> MediaAssetsStatus:
|
||||
ffmpeg_path=ffmpeg_path if ffmpeg_exists else None,
|
||||
ffprobe_path=ffprobe_path if ffprobe_exists else None,
|
||||
music_dir=music_dir if music_exists else None,
|
||||
music_ready=music_ready,
|
||||
fonts_dir=fonts_dir if fonts_exists else None,
|
||||
watermark_dir=watermark_dir if watermark_exists else None,
|
||||
)
|
||||
@@ -487,10 +558,43 @@ def _needs_media_repo_content(status: MediaAssetsStatus) -> bool:
|
||||
"music_dir_missing",
|
||||
"fonts_dir_missing",
|
||||
"watermark_dir_missing",
|
||||
"background_music_dir_empty",
|
||||
"background_music_lfs_pointer_only",
|
||||
"background_music_invalid",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _maybe_repair_music_content(root: Path, status: MediaAssetsStatus) -> MediaAssetsStatus:
|
||||
if not _needs_music_content_repair(status):
|
||||
return status
|
||||
if _media_assets_root_overridden(None):
|
||||
return status
|
||||
|
||||
warnings: list[str] = []
|
||||
if _is_git_repo(root) and root.exists():
|
||||
if _try_download_via_git(root, warnings, clone=False):
|
||||
refreshed = _inspect_media_assets(root, source="git")
|
||||
for warning in warnings:
|
||||
if warning not in refreshed.warnings:
|
||||
refreshed.warnings.append(warning)
|
||||
return _maybe_ensure_ffmpeg_tools(root, refreshed)
|
||||
|
||||
shared = _shared_parent(root)
|
||||
if _try_download_via_zip(shared, root, warnings):
|
||||
refreshed = _inspect_media_assets(root, source="zip")
|
||||
for warning in warnings:
|
||||
if warning not in refreshed.warnings:
|
||||
refreshed.warnings.append(warning)
|
||||
return _maybe_ensure_ffmpeg_tools(root, refreshed)
|
||||
|
||||
refreshed = _inspect_media_assets(root, source=status.source)
|
||||
for warning in warnings:
|
||||
if warning not in refreshed.warnings:
|
||||
refreshed.warnings.append(warning)
|
||||
return refreshed
|
||||
|
||||
|
||||
def ensure_media_assets(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
@@ -530,16 +634,22 @@ def ensure_media_assets(
|
||||
if _try_download_via_zip(shared, root, warnings):
|
||||
return _maybe_ensure_ffmpeg_tools(
|
||||
root,
|
||||
_inspect_media_assets(root, source="zip"),
|
||||
_maybe_repair_music_content(
|
||||
root,
|
||||
_inspect_media_assets(root, source="zip"),
|
||||
),
|
||||
)
|
||||
|
||||
clone_needed = not root.exists() or not status.ready or update
|
||||
if _try_download_via_git(root, warnings, clone=clone_needed):
|
||||
return _maybe_ensure_ffmpeg_tools(
|
||||
root,
|
||||
_inspect_media_assets(
|
||||
_maybe_repair_music_content(
|
||||
root,
|
||||
source="git" if _is_git_repo(root) else "local",
|
||||
_inspect_media_assets(
|
||||
root,
|
||||
source="git" if _is_git_repo(root) else "local",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -547,7 +657,29 @@ def ensure_media_assets(
|
||||
for warning in warnings:
|
||||
if warning not in final.warnings:
|
||||
final.warnings.append(warning)
|
||||
return _maybe_ensure_ffmpeg_tools(root, final)
|
||||
repaired = _maybe_repair_music_content(root, final)
|
||||
for warning in warnings:
|
||||
if warning not in repaired.warnings:
|
||||
repaired.warnings.append(warning)
|
||||
return _maybe_ensure_ffmpeg_tools(root, repaired)
|
||||
|
||||
|
||||
def probe_media_assets(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> MediaAssetsStatus:
|
||||
"""只检查本地 media-assets 目录,不触发下载。"""
|
||||
env_map = _env_dict(env)
|
||||
root = resolve_media_assets_root(data_root, env_map)
|
||||
return _inspect_media_assets(root, source="local")
|
||||
|
||||
|
||||
def probe_ffmpeg(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> Optional[Path]:
|
||||
status = probe_media_assets(data_root, env)
|
||||
return status.ffmpeg_path
|
||||
|
||||
|
||||
def resolve_ffmpeg(
|
||||
@@ -574,6 +706,18 @@ def resolve_music_dir(
|
||||
return status.music_dir
|
||||
|
||||
|
||||
def background_music_issue(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
) -> Optional[str]:
|
||||
"""若 music 目录存在但无可用音频,返回标准 warning code。"""
|
||||
if pick_background_music(data_root, env) is not None:
|
||||
return None
|
||||
|
||||
status = ensure_media_assets(data_root, env)
|
||||
return _music_content_issue(status.music_dir)
|
||||
|
||||
|
||||
def pick_background_music(
|
||||
data_root: str | Path | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
@@ -583,10 +727,11 @@ def pick_background_music(
|
||||
if music_dir is None or not music_dir.is_dir():
|
||||
return None
|
||||
|
||||
candidates: list[Path] = []
|
||||
for path in music_dir.rglob("*"):
|
||||
if path.is_file() and path.suffix.lower() in _AUDIO_EXTENSIONS:
|
||||
candidates.append(path)
|
||||
candidates = [
|
||||
path
|
||||
for path in music_dir.rglob("*")
|
||||
if _is_usable_audio_file(path)
|
||||
]
|
||||
|
||||
if not candidates:
|
||||
return None
|
||||
|
||||
@@ -4,8 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -15,6 +13,12 @@ from pathlib import Path
|
||||
from typing import Any, Dict, IO, List, Optional
|
||||
|
||||
from .. import config
|
||||
from ..media_assets import (
|
||||
background_music_issue,
|
||||
ensure_media_assets,
|
||||
pick_background_music,
|
||||
resolve_ffmpeg,
|
||||
)
|
||||
|
||||
_JIANGCHANG_SUBTITLE_STYLE = (
|
||||
"FontName=Arial,FontSize=14,"
|
||||
@@ -22,11 +26,6 @@ _JIANGCHANG_SUBTITLE_STYLE = (
|
||||
"Outline=2,Shadow=1,Alignment=2"
|
||||
)
|
||||
|
||||
_DEFAULT_MUSIC_ROOT = os.environ.get(
|
||||
"MEDIA_ASSETS_ROOT",
|
||||
r"D:\OpenClaw\client-commons\media-assets",
|
||||
)
|
||||
|
||||
_CAPTURE_FRAMERATE = 15
|
||||
|
||||
|
||||
@@ -34,14 +33,23 @@ def _record_video_enabled() -> bool:
|
||||
return config.get_bool("OPENCLAW_RECORD_VIDEO", default=False)
|
||||
|
||||
|
||||
def _resolve_music_file() -> Optional[Path]:
|
||||
music_dir = Path(_DEFAULT_MUSIC_ROOT) / "music"
|
||||
if not music_dir.is_dir():
|
||||
def _resolve_ffmpeg_exe() -> Optional[Path]:
|
||||
ffmpeg = resolve_ffmpeg()
|
||||
if ffmpeg is None:
|
||||
return None
|
||||
files = list(music_dir.rglob("*.mp3"))
|
||||
if not files:
|
||||
return None
|
||||
return random.choice(files)
|
||||
path = Path(ffmpeg)
|
||||
return path if path.is_file() else None
|
||||
|
||||
|
||||
def _resolve_music_file(warnings: List[str]) -> Optional[Path]:
|
||||
music = pick_background_music()
|
||||
if music is not None:
|
||||
return music
|
||||
|
||||
issue = background_music_issue()
|
||||
if issue:
|
||||
warnings.append(issue)
|
||||
return None
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -87,7 +95,12 @@ def _escape_srt_path(path: str) -> str:
|
||||
return path.replace("\\", "/").replace(":", "\\:")
|
||||
|
||||
|
||||
def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
|
||||
def _ffmpeg_cmd(ffmpeg_exe: Path, *args: str) -> List[str]:
|
||||
return [str(ffmpeg_exe), *args]
|
||||
|
||||
|
||||
def _run_ffmpeg(ffmpeg_exe: Path, cmd_tail: List[str]) -> tuple[bool, str]:
|
||||
cmd = _ffmpeg_cmd(ffmpeg_exe, *cmd_tail)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
@@ -104,12 +117,12 @@ def _run_ffmpeg(cmd: List[str]) -> tuple[bool, str]:
|
||||
return True, ""
|
||||
|
||||
|
||||
def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
|
||||
def _build_desktop_record_cmd(ffmpeg_exe: Path, capture_path: str) -> Optional[List[str]]:
|
||||
"""Windows gdigrab 全桌面录制;非 Windows 返回 None。"""
|
||||
if sys.platform != "win32":
|
||||
return None
|
||||
return [
|
||||
"ffmpeg",
|
||||
return _ffmpeg_cmd(
|
||||
ffmpeg_exe,
|
||||
"-y",
|
||||
"-f",
|
||||
"gdigrab",
|
||||
@@ -124,10 +137,11 @@ def _build_desktop_record_cmd(capture_path: str) -> Optional[List[str]]:
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
capture_path,
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _compose_capture_mp4(
|
||||
ffmpeg_exe: Path,
|
||||
capture_path: Path,
|
||||
srt_path: Path,
|
||||
output_path: Path,
|
||||
@@ -147,8 +161,7 @@ def _compose_capture_mp4(
|
||||
f"[0:v]{subtitle_filter}[vout];"
|
||||
f"[1:a]volume={music_volume},apad[aout]"
|
||||
)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
cmd_tail = [
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_path),
|
||||
@@ -176,8 +189,7 @@ def _compose_capture_mp4(
|
||||
str(output_path),
|
||||
]
|
||||
else:
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
cmd_tail = [
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture_path),
|
||||
@@ -197,7 +209,7 @@ def _compose_capture_mp4(
|
||||
"yuv420p",
|
||||
str(output_path),
|
||||
]
|
||||
return _run_ffmpeg(cmd)
|
||||
return _run_ffmpeg(ffmpeg_exe, cmd_tail)
|
||||
|
||||
|
||||
class RpaVideoSession:
|
||||
@@ -287,11 +299,13 @@ class RpaVideoSession:
|
||||
def _start_ffmpeg_capture(self) -> None:
|
||||
if not self.enabled:
|
||||
return
|
||||
if not shutil.which("ffmpeg"):
|
||||
|
||||
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||
if ffmpeg_exe is None:
|
||||
self.warnings.append("ffmpeg_not_found")
|
||||
return
|
||||
|
||||
cmd = _build_desktop_record_cmd(self._capture_path)
|
||||
cmd = _build_desktop_record_cmd(ffmpeg_exe, self._capture_path)
|
||||
if cmd is None:
|
||||
self._append_warning(
|
||||
"ffmpeg_record_start_failed",
|
||||
@@ -391,14 +405,21 @@ class RpaVideoSession:
|
||||
except Exception as exc:
|
||||
self.warnings.append(f"subtitle_write_failed: {exc}")
|
||||
|
||||
music = _resolve_music_file()
|
||||
if music is None:
|
||||
music = _resolve_music_file(self.warnings)
|
||||
if music is None and "background_music_lfs_pointer_only" not in self.warnings and "background_music_invalid" not in self.warnings:
|
||||
self.warnings.append("background_music_missing")
|
||||
|
||||
ffmpeg_exe = _resolve_ffmpeg_exe()
|
||||
if ffmpeg_exe is None:
|
||||
self.warnings.append("ffmpeg_not_found")
|
||||
self.artifacts = self.summary()
|
||||
return
|
||||
|
||||
srt_file = Path(self._srt_path)
|
||||
use_srt = srt_file.is_file() and srt_file.stat().st_size > 0
|
||||
|
||||
ok, err = _compose_capture_mp4(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
@@ -407,6 +428,7 @@ class RpaVideoSession:
|
||||
if not ok and music is not None:
|
||||
self.warnings.append(f"ffmpeg_with_music_failed: {err}")
|
||||
ok, err = _compose_capture_mp4(
|
||||
ffmpeg_exe,
|
||||
capture,
|
||||
srt_file if use_srt else Path(self._srt_path),
|
||||
Path(self._planned_output),
|
||||
@@ -414,8 +436,8 @@ class RpaVideoSession:
|
||||
)
|
||||
if not ok and not use_srt:
|
||||
ok, err = _run_ffmpeg(
|
||||
ffmpeg_exe,
|
||||
[
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
str(capture),
|
||||
@@ -428,7 +450,7 @@ class RpaVideoSession:
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
str(self._planned_output),
|
||||
]
|
||||
],
|
||||
)
|
||||
if not ok:
|
||||
self._append_warning("ffmpeg_compose_failed", err)
|
||||
|
||||
@@ -34,14 +34,22 @@ def platform_default_data_root() -> str:
|
||||
|
||||
|
||||
def get_data_root() -> str:
|
||||
env = (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip()
|
||||
env = (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip()
|
||||
if env:
|
||||
return env
|
||||
return platform_default_data_root()
|
||||
|
||||
|
||||
def get_user_id() -> str:
|
||||
return (os.getenv("JIANGCHANG_USER_ID") or "").strip() or "_anon"
|
||||
return (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip() or "_anon"
|
||||
|
||||
|
||||
def _looks_like_skills_root(path: str) -> bool:
|
||||
@@ -55,6 +63,7 @@ def _looks_like_skills_root(path: str) -> bool:
|
||||
"toutiao-publisher",
|
||||
"logistics-tracker",
|
||||
"api-key-vault",
|
||||
"receive-order",
|
||||
):
|
||||
if os.path.isdir(os.path.join(path, marker)):
|
||||
return True
|
||||
@@ -130,10 +139,21 @@ def apply_cli_local_defaults() -> None:
|
||||
enabled = v in ("1", "true", "yes", "on")
|
||||
if not enabled:
|
||||
return
|
||||
if not (os.getenv("JIANGCHANG_DATA_ROOT") or "").strip():
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = platform_default_data_root()
|
||||
if not (os.getenv("JIANGCHANG_USER_ID") or "").strip():
|
||||
if not (
|
||||
os.getenv("CLAW_DATA_ROOT")
|
||||
or os.getenv("JIANGCHANG_DATA_ROOT")
|
||||
or ""
|
||||
).strip():
|
||||
default_root = platform_default_data_root()
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = default_root
|
||||
os.environ.setdefault("CLAW_DATA_ROOT", default_root)
|
||||
if not (
|
||||
os.getenv("CLAW_USER_ID")
|
||||
or os.getenv("JIANGCHANG_USER_ID")
|
||||
or ""
|
||||
).strip():
|
||||
os.environ["JIANGCHANG_USER_ID"] = DEFAULT_LOCAL_USER_ID.strip()
|
||||
os.environ.setdefault("CLAW_USER_ID", DEFAULT_LOCAL_USER_ID.strip())
|
||||
def find_chrome_executable() -> "str | None":
|
||||
"""查找系统已安装的 Chrome 或 Edge 可执行文件路径。
|
||||
|
||||
|
||||
Reference in New Issue
Block a user