feat(sandbox): Windows 沙箱环境检测弹窗与一键安装向导
This commit is contained in:
parent
6c0a32330c
commit
55b3ee44b7
@ -152,6 +152,68 @@ MESSAGES = {
|
||||
"en-US": "bubblewrap is not installed inside the WSL sandbox distro '{distro}'. Re-run scripts/setup-wsl-sandbox.ps1 or run 'apk add bubblewrap' inside the distro",
|
||||
},
|
||||
|
||||
# ── modules/sandbox_setup_manager.py(沙箱环境一键安装) ──
|
||||
"sandbox.setup_wsl_missing": {
|
||||
"zh-CN": "未检测到可用的 WSL2(Windows Subsystem for Linux)",
|
||||
"en-US": "No usable WSL2 (Windows Subsystem for Linux) detected",
|
||||
},
|
||||
"sandbox.setup_distro_missing": {
|
||||
"zh-CN": "未找到沙箱发行版 '{distro}'",
|
||||
"en-US": "Sandbox distro '{distro}' not found",
|
||||
},
|
||||
"sandbox.setup_bwrap_missing": {
|
||||
"zh-CN": "沙箱发行版 '{distro}' 缺少 bubblewrap 组件",
|
||||
"en-US": "Sandbox distro '{distro}' is missing bubblewrap",
|
||||
},
|
||||
"sandbox.setup_not_windows": {
|
||||
"zh-CN": "仅 Windows 宿主机模式支持一键安装沙箱",
|
||||
"en-US": "One-click sandbox setup is only supported on Windows host mode",
|
||||
},
|
||||
"sandbox.setup_not_host_mode": {
|
||||
"zh-CN": "当前不是宿主机模式,无法安装沙箱",
|
||||
"en-US": "Not in host mode; sandbox setup unavailable",
|
||||
},
|
||||
"sandbox.setup_script_missing": {
|
||||
"zh-CN": "安装脚本 scripts/setup-wsl-sandbox.ps1 不存在",
|
||||
"en-US": "Setup script scripts/setup-wsl-sandbox.ps1 not found",
|
||||
},
|
||||
"sandbox.setup_already_running": {
|
||||
"zh-CN": "安装任务正在运行中",
|
||||
"en-US": "A setup task is already running",
|
||||
},
|
||||
"sandbox.setup_enabling_wsl_log": {
|
||||
"zh-CN": "正在请求管理员授权…",
|
||||
"en-US": "Requesting administrator approval...",
|
||||
},
|
||||
"sandbox.setup_wsl_installing_log": {
|
||||
"zh-CN": "授权通过,正在下载并安装 WSL2 组件…",
|
||||
"en-US": "Approval granted; downloading and installing WSL2 components...",
|
||||
},
|
||||
"sandbox.setup_wsl_enabled_log": {
|
||||
"zh-CN": "WSL2 已启用,继续安装沙箱发行版…",
|
||||
"en-US": "WSL2 enabled, continuing with sandbox distro setup...",
|
||||
},
|
||||
"sandbox.setup_wsl_enable_failed": {
|
||||
"zh-CN": "启用 WSL2 失败(可能未通过管理员授权)",
|
||||
"en-US": "Failed to enable WSL2 (administrator approval may have been denied)",
|
||||
},
|
||||
"sandbox.setup_uac_timeout": {
|
||||
"zh-CN": "等待管理员授权超时",
|
||||
"en-US": "Timed out waiting for administrator approval",
|
||||
},
|
||||
"sandbox.setup_script_failed": {
|
||||
"zh-CN": "安装脚本执行失败,详情见日志",
|
||||
"en-US": "Setup script failed; see log for details",
|
||||
},
|
||||
"sandbox.setup_verify_failed": {
|
||||
"zh-CN": "安装完成但验收检测未通过: {detail}",
|
||||
"en-US": "Setup finished but verification failed: {detail}",
|
||||
},
|
||||
"sandbox.setup_bad_distro_name": {
|
||||
"zh-CN": "发行版名称 '{distro}' 含非法字符(仅允许字母、数字、点、下划线、连字符)",
|
||||
"en-US": "Distro name '{distro}' contains illegal characters (only letters, digits, dot, underscore, hyphen allowed)",
|
||||
},
|
||||
|
||||
# ── modules/user_container_manager.py(用户容器管理器) ──
|
||||
"container_mgr.quota_exhausted": {
|
||||
"zh-CN": "资源繁忙:容器配额已用尽,请稍候再试。",
|
||||
|
||||
462
modules/sandbox_setup_manager.py
Normal file
462
modules/sandbox_setup_manager.py
Normal file
@ -0,0 +1,462 @@
|
||||
# modules/sandbox_setup_manager.py - Windows WSL 沙箱环境检测与一键安装管理
|
||||
#
|
||||
# 背景:Windows 宿主机模式的命令沙箱依赖专用 WSL2 发行版(默认 astrion-sandbox,
|
||||
# Alpine + bubblewrap,关闭 interop)。此前缺失时只在首次执行命令时被动报错,
|
||||
# 本模块提供:
|
||||
# 1. get_sandbox_status() —— 主动分级检测(wsl_missing / distro_missing / bwrap_missing / ready)
|
||||
# 2. start_setup() —— 后台线程执行 scripts/setup-wsl-sandbox.ps1,逐步解析进度
|
||||
# 3. get_setup_progress() —— 前端轮询进度(阶段 / 步骤 / 日志尾部 / 下载字节数)
|
||||
#
|
||||
# 注意:
|
||||
# - 安装进程由后端 server 直接在宿主机拉起(direct),不走 run_command 沙箱链路
|
||||
# (沙箱尚未建立,属于"鸡生蛋"场景,由前端用户显式点击触发)。
|
||||
# - WSL 功能未启用时先经 UAC 提权执行 wsl --install --no-distribution,可能需要重启。
|
||||
# - 进度仅保存在内存(一次性操作,无需持久化)。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from modules.host_sandbox_runner import WSL_DEFAULT_SANDBOX_DISTRO, _wsl_distro_name
|
||||
from modules.i18n import tr
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[1]
|
||||
_SETUP_SCRIPT = _REPO_ROOT / "scripts" / "setup-wsl-sandbox.ps1"
|
||||
|
||||
# 与 scripts/setup-wsl-sandbox.ps1 默认 RootfsUrl 保持一致(用于 HEAD 估算下载总量)
|
||||
_ROOTFS_URL = (
|
||||
"https://mirrors.aliyun.com/alpine/v3.21/releases/x86_64/"
|
||||
"alpine-minirootfs-3.21.3-x86_64.tar.gz"
|
||||
)
|
||||
# ps1 中下载的临时文件路径(%TEMP%\astrion-alpine-minirootfs.tar.gz)
|
||||
_ROOTFS_TEMP_NAME = "astrion-alpine-minirootfs.tar.gz"
|
||||
|
||||
_STEP_RE = re.compile(r"^==>\s*\[(\d+)/(\d+)\]\s*(.+)$")
|
||||
_STEP_TOTAL = 6
|
||||
_LOG_TAIL_MAX = 30
|
||||
_STATUS_CACHE_TTL = 10.0
|
||||
|
||||
# 阶段常量
|
||||
PHASE_IDLE = "idle"
|
||||
PHASE_ENABLING_WSL = "enabling_wsl"
|
||||
PHASE_INSTALLING_WSL = "installing_wsl"
|
||||
PHASE_INSTALLING = "installing"
|
||||
PHASE_VERIFYING = "verifying"
|
||||
PHASE_DONE = "done"
|
||||
PHASE_NEEDS_REBOOT = "needs_reboot"
|
||||
PHASE_ERROR = "error"
|
||||
|
||||
|
||||
def _wsl_env() -> Dict[str, str]:
|
||||
"""子进程环境:强制 wsl.exe 输出 UTF-8(默认 UTF-16 会乱码)。"""
|
||||
env = dict(os.environ)
|
||||
env["WSL_UTF8"] = "1"
|
||||
return env
|
||||
|
||||
|
||||
def _run_probe(argv: List[str], timeout: float = 20.0) -> subprocess.CompletedProcess:
|
||||
# stdin=DEVNULL 必须:wsl --uninstall 后 stub 会输出交互式提示并等待按键 60 秒,
|
||||
# 继承 stdin 时探测直接卡死;关闭 stdin 后 0.1s 返回 rc=1(实测验证)。
|
||||
return subprocess.run(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=_wsl_env(),
|
||||
timeout=timeout,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
|
||||
|
||||
def _wsl_available() -> bool:
|
||||
"""WSL 功能是否可用(wsl.exe 存在且能正常列出发行版)。
|
||||
|
||||
未安装任何发行版时 `wsl -l -q` 返回空但 exit==0;
|
||||
WSL 功能未启用 / 虚拟机平台缺失时 exit!=0。
|
||||
"""
|
||||
wsl = shutil.which("wsl.exe")
|
||||
if not wsl:
|
||||
return False
|
||||
try:
|
||||
proc = _run_probe([wsl, "-l", "-q"])
|
||||
return proc.returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _distro_usable(distro: str) -> bool:
|
||||
wsl = shutil.which("wsl.exe")
|
||||
if not wsl:
|
||||
return False
|
||||
try:
|
||||
return _run_probe([wsl, "-d", distro, "-e", "true"]).returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _bwrap_ready(distro: str) -> bool:
|
||||
wsl = shutil.which("wsl.exe")
|
||||
if not wsl:
|
||||
return False
|
||||
try:
|
||||
return _run_probe([wsl, "-d", distro, "-e", "bwrap", "--version"]).returncode == 0
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class SandboxSetupManager:
|
||||
"""沙箱状态检测 + 一键安装任务管理(进程级单例)。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._status_cache: Optional[Dict[str, Any]] = None
|
||||
self._status_cache_at = 0.0
|
||||
self._progress: Dict[str, Any] = self._fresh_progress()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
|
||||
# ------------------------------------------------------------------ status
|
||||
|
||||
@staticmethod
|
||||
def _applicable() -> bool:
|
||||
if sys.platform != "win32":
|
||||
return False
|
||||
try:
|
||||
from config import TERMINAL_SANDBOX_MODE
|
||||
except Exception:
|
||||
return False
|
||||
return (TERMINAL_SANDBOX_MODE or "").lower() == "host"
|
||||
|
||||
def get_sandbox_status(self, force: bool = False) -> Dict[str, Any]:
|
||||
"""分级检测沙箱状态,带短 TTL 缓存(前端多处同时调用时防抖)。"""
|
||||
with self._lock:
|
||||
if (
|
||||
not force
|
||||
and self._status_cache is not None
|
||||
and time.time() - self._status_cache_at < _STATUS_CACHE_TTL
|
||||
):
|
||||
cached = dict(self._status_cache)
|
||||
cached["setup_running"] = self._progress.get("active", False)
|
||||
return cached
|
||||
|
||||
result: Dict[str, Any] = {
|
||||
"applicable": False,
|
||||
"platform": sys.platform,
|
||||
"state": "not_applicable",
|
||||
"distro_name": _wsl_distro_name() if self._applicable() else WSL_DEFAULT_SANDBOX_DISTRO,
|
||||
"detail": "",
|
||||
}
|
||||
if self._applicable():
|
||||
result["applicable"] = True
|
||||
distro = result["distro_name"]
|
||||
if not _wsl_available():
|
||||
result["state"] = "wsl_missing"
|
||||
result["detail"] = tr("sandbox.setup_wsl_missing")
|
||||
elif not _distro_usable(distro):
|
||||
result["state"] = "distro_missing"
|
||||
result["detail"] = tr("sandbox.setup_distro_missing", distro=distro)
|
||||
elif not _bwrap_ready(distro):
|
||||
result["state"] = "bwrap_missing"
|
||||
result["detail"] = tr("sandbox.setup_bwrap_missing", distro=distro)
|
||||
else:
|
||||
result["state"] = "ready"
|
||||
|
||||
with self._lock:
|
||||
self._status_cache = result
|
||||
self._status_cache_at = time.time()
|
||||
out = dict(result)
|
||||
out["setup_running"] = self._progress.get("active", False)
|
||||
return out
|
||||
|
||||
def invalidate_status_cache(self) -> None:
|
||||
with self._lock:
|
||||
self._status_cache = None
|
||||
self._status_cache_at = 0.0
|
||||
|
||||
# ----------------------------------------------------------------- progress
|
||||
|
||||
@staticmethod
|
||||
def _fresh_progress() -> Dict[str, Any]:
|
||||
return {
|
||||
"active": False,
|
||||
"phase": PHASE_IDLE,
|
||||
"step_index": 0,
|
||||
"step_total": _STEP_TOTAL,
|
||||
"step_title": "",
|
||||
"log_tail": [],
|
||||
"download_bytes": None,
|
||||
"download_total": None,
|
||||
"error": None,
|
||||
"error_kind": None,
|
||||
"updated_at": time.time(),
|
||||
}
|
||||
|
||||
def get_setup_progress(self) -> Dict[str, Any]:
|
||||
with self._lock:
|
||||
return dict(self._progress)
|
||||
|
||||
def _update_progress(self, **fields: Any) -> None:
|
||||
with self._lock:
|
||||
self._progress.update(fields)
|
||||
self._progress["updated_at"] = time.time()
|
||||
|
||||
def _append_log(self, line: str) -> None:
|
||||
line = line.rstrip()
|
||||
if not line:
|
||||
return
|
||||
with self._lock:
|
||||
tail: List[str] = self._progress["log_tail"]
|
||||
tail.append(line)
|
||||
if len(tail) > _LOG_TAIL_MAX:
|
||||
del tail[: len(tail) - _LOG_TAIL_MAX]
|
||||
self._progress["updated_at"] = time.time()
|
||||
|
||||
# -------------------------------------------------------------------- setup
|
||||
|
||||
def start_setup(self, enable_wsl_if_needed: bool) -> Dict[str, Any]:
|
||||
"""启动安装后台线程。返回 {"started": bool, "error": str|None}。"""
|
||||
if sys.platform != "win32":
|
||||
return {"started": False, "error": tr("sandbox.setup_not_windows")}
|
||||
if not _SETUP_SCRIPT.exists():
|
||||
return {"started": False, "error": tr("sandbox.setup_script_missing")}
|
||||
with self._lock:
|
||||
if self._progress.get("active"):
|
||||
return {"started": False, "error": tr("sandbox.setup_already_running")}
|
||||
self._progress = self._fresh_progress()
|
||||
self._progress["active"] = True
|
||||
self._progress["phase"] = PHASE_INSTALLING
|
||||
|
||||
self._worker = threading.Thread(
|
||||
target=self._run_setup,
|
||||
args=(enable_wsl_if_needed,),
|
||||
name="sandbox-setup",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
return {"started": True, "error": None}
|
||||
|
||||
def _finish(self, phase: str, error: Optional[str] = None, error_kind: Optional[str] = None) -> None:
|
||||
self._update_progress(active=False, phase=phase, error=error, error_kind=error_kind)
|
||||
self.invalidate_status_cache()
|
||||
|
||||
def _run_setup(self, enable_wsl_if_needed: bool) -> None:
|
||||
try:
|
||||
# 阶段一:WSL 功能缺失时先提权安装(UAC 弹窗由用户在系统层确认)
|
||||
if not _wsl_available():
|
||||
if not enable_wsl_if_needed:
|
||||
self._finish(PHASE_ERROR, tr("sandbox.setup_wsl_missing"), "wsl_enable_failed")
|
||||
return
|
||||
if not self._enable_wsl():
|
||||
return # _enable_wsl 内部已 _finish
|
||||
|
||||
# 阶段二:跑安装脚本(6 步)
|
||||
self._update_progress(phase=PHASE_INSTALLING)
|
||||
download_total = self._probe_rootfs_size()
|
||||
if download_total:
|
||||
self._update_progress(download_total=download_total)
|
||||
if not self._run_setup_script():
|
||||
return # 内部已 _finish
|
||||
|
||||
# 阶段三:验收(强制重新探测)
|
||||
self._update_progress(phase=PHASE_VERIFYING)
|
||||
status = self.get_sandbox_status(force=True)
|
||||
if status.get("state") == "ready":
|
||||
self._finish(PHASE_DONE)
|
||||
else:
|
||||
self._finish(
|
||||
PHASE_ERROR,
|
||||
tr("sandbox.setup_verify_failed", detail=status.get("detail") or status.get("state")),
|
||||
"verify_failed",
|
||||
)
|
||||
except Exception as exc: # 兜底,防止线程无声死亡
|
||||
self._finish(PHASE_ERROR, f"{type(exc).__name__}: {exc}", "unexpected")
|
||||
|
||||
def _enable_wsl(self) -> bool:
|
||||
"""UAC 提权执行 wsl --install --no-distribution。返回是否可继续安装。
|
||||
|
||||
Start-Process -Verb RunAs 在 UAC 弹窗期间阻塞:用户确认后返回进程对象、
|
||||
拒绝时抛异常(exit 3)。利用这个时序用标记行把「等待授权」与
|
||||
「下载安装 WSL 组件」拆成两个阶段,reader 线程实时解析,前端及时切换:
|
||||
ASTRION_UAC_CONFIRMED —— UAC 已通过,进入组件下载安装
|
||||
ASTRION_WSL_INSTALL_EXIT —— wsl --install 进程退出码
|
||||
ASTRION_UAC_CANCELLED —— 用户拒绝授权(exit 3)
|
||||
"""
|
||||
self._update_progress(phase=PHASE_ENABLING_WSL)
|
||||
self._append_log(tr("sandbox.setup_enabling_wsl_log"))
|
||||
# 前缀先设控制台输出编码为 UTF-8:PS 5.1 默认按 GBK 输出,后端按 UTF-8 读会乱码
|
||||
ps_command = (
|
||||
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; "
|
||||
"try { $p = Start-Process wsl.exe -Verb RunAs "
|
||||
"-ArgumentList '--install','--no-distribution' -PassThru -ErrorAction Stop } "
|
||||
"catch { Write-Host 'ASTRION_UAC_CANCELLED'; exit 3 }; "
|
||||
"Write-Host 'ASTRION_UAC_CONFIRMED'; $p.WaitForExit(); "
|
||||
'Write-Host "ASTRION_WSL_INSTALL_EXIT=$($p.ExitCode)"'
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_command],
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
install_exit: List[int] = []
|
||||
|
||||
def _reader() -> None:
|
||||
assert proc.stdout is not None
|
||||
for raw in proc.stdout:
|
||||
line = raw.strip()
|
||||
if not line:
|
||||
continue
|
||||
if line == "ASTRION_UAC_CONFIRMED":
|
||||
self._update_progress(phase=PHASE_INSTALLING_WSL)
|
||||
self._append_log(tr("sandbox.setup_wsl_installing_log"))
|
||||
elif line.startswith("ASTRION_WSL_INSTALL_EXIT="):
|
||||
try:
|
||||
install_exit.append(int(line.split("=", 1)[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
elif line != "ASTRION_UAC_CANCELLED": # 由 returncode==3 统一判定,不进日志
|
||||
self._append_log(line)
|
||||
|
||||
reader = threading.Thread(target=_reader, name="sandbox-setup-uac", daemon=True)
|
||||
reader.start()
|
||||
try:
|
||||
proc.wait(timeout=900)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
self._finish(PHASE_ERROR, tr("sandbox.setup_uac_timeout"), "uac_cancelled")
|
||||
return False
|
||||
reader.join(timeout=5)
|
||||
|
||||
if proc.returncode == 3:
|
||||
self._finish(PHASE_ERROR, tr("sandbox.setup_wsl_enable_failed"), "uac_cancelled")
|
||||
return False
|
||||
if proc.returncode != 0 or not install_exit or install_exit[-1] != 0:
|
||||
self._finish(PHASE_ERROR, tr("sandbox.setup_wsl_enable_failed"), "wsl_enable_failed")
|
||||
return False
|
||||
# 提权安装完成后复查:仍不可用 → 大概率需要重启(虚拟机平台刚启用)
|
||||
if not _wsl_available():
|
||||
self._finish(PHASE_NEEDS_REBOOT)
|
||||
return False
|
||||
self._append_log(tr("sandbox.setup_wsl_enabled_log"))
|
||||
return True
|
||||
|
||||
def _run_setup_script(self) -> bool:
|
||||
"""执行 setup-wsl-sandbox.ps1,逐行解析进度。返回是否成功。"""
|
||||
if not _SETUP_SCRIPT.exists():
|
||||
self._finish(PHASE_ERROR, tr("sandbox.setup_script_missing"), "script_failed")
|
||||
return False
|
||||
download_stop = threading.Event()
|
||||
download_thread = threading.Thread(
|
||||
target=self._poll_download_size,
|
||||
args=(download_stop,),
|
||||
name="sandbox-setup-dlsize",
|
||||
daemon=True,
|
||||
)
|
||||
download_thread.start()
|
||||
try:
|
||||
# 与检测逻辑保持同名:检测哪个发行版就装哪个(HOST_SANDBOX_WSL_DISTRO 自定义场景)。
|
||||
# 发行版名来自环境变量,拼入 -Command 字符串前必须白名单校验,防命令注入。
|
||||
distro = _wsl_distro_name()
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]+", distro):
|
||||
self._finish(
|
||||
PHASE_ERROR,
|
||||
tr("sandbox.setup_bad_distro_name", distro=distro),
|
||||
"script_failed",
|
||||
)
|
||||
return False
|
||||
# 非默认名使用独立安装目录,避免与既有发行版的 VHDX 目录冲突(--import 要求空目录)。
|
||||
# 用 -Command 包装并先设输出编码:-File 模式下若脚本解析失败(语法/编码问题),
|
||||
# 脚本内的 OutputEncoding 设置来不及生效,错误消息会按 GBK 输出而后端读成乱码。
|
||||
ps_parts = [f"& '{_SETUP_SCRIPT}'", "-DistroName", f"'{distro}'"]
|
||||
if distro != WSL_DEFAULT_SANDBOX_DISTRO:
|
||||
install_dir = Path.home() / ".astrion" / f"wsl-sandbox-{distro}"
|
||||
ps_parts += ["-InstallDir", f"'{install_dir}'"]
|
||||
ps_command = (
|
||||
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; "
|
||||
+ " ".join(ps_parts)
|
||||
)
|
||||
argv = [
|
||||
"powershell",
|
||||
"-NoProfile",
|
||||
"-ExecutionPolicy",
|
||||
"Bypass",
|
||||
"-Command",
|
||||
ps_command,
|
||||
]
|
||||
proc = subprocess.Popen(
|
||||
argv,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
env=_wsl_env(),
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
assert proc.stdout is not None
|
||||
for raw_line in proc.stdout:
|
||||
line = raw_line.rstrip()
|
||||
m = _STEP_RE.match(line.strip())
|
||||
if m:
|
||||
self._update_progress(
|
||||
step_index=int(m.group(1)),
|
||||
step_total=int(m.group(2)),
|
||||
step_title=m.group(3).strip(),
|
||||
)
|
||||
self._append_log(line)
|
||||
proc.wait()
|
||||
if proc.returncode != 0:
|
||||
self._finish(PHASE_ERROR, tr("sandbox.setup_script_failed"), "script_failed")
|
||||
return False
|
||||
return True
|
||||
except Exception as exc:
|
||||
self._finish(PHASE_ERROR, f"{type(exc).__name__}: {exc}", "script_failed")
|
||||
return False
|
||||
finally:
|
||||
download_stop.set()
|
||||
|
||||
def _poll_download_size(self, stop: threading.Event) -> None:
|
||||
"""安装阶段周期性 stat rootfs 临时文件大小,供前端展示下载量。"""
|
||||
temp_dir = os.environ.get("TEMP") or os.environ.get("TMP") or ""
|
||||
if not temp_dir:
|
||||
return
|
||||
target = Path(temp_dir) / _ROOTFS_TEMP_NAME
|
||||
while not stop.wait(1.0):
|
||||
with self._lock:
|
||||
if self._progress.get("step_index") != 3:
|
||||
continue
|
||||
try:
|
||||
if target.exists():
|
||||
self._update_progress(download_bytes=target.stat().st_size)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def _probe_rootfs_size() -> Optional[int]:
|
||||
"""HEAD 请求 rootfs 下载地址估算总量(失败返回 None,前端退化为只显示已下载量)。"""
|
||||
try:
|
||||
req = urllib.request.Request(_ROOTFS_URL, method="HEAD")
|
||||
with urllib.request.urlopen(req, timeout=8) as resp:
|
||||
length = resp.headers.get("Content-Length")
|
||||
return int(length) if length else None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
sandbox_setup_manager = SandboxSetupManager()
|
||||
@ -1,4 +1,4 @@
|
||||
# setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版
|
||||
# setup-wsl-sandbox.ps1 — 创建 astrion WSL2+bwrap 专用沙箱发行版
|
||||
#
|
||||
# 用法(PowerShell,普通用户即可,无需管理员):
|
||||
# powershell -ExecutionPolicy Bypass -File scripts\setup-wsl-sandbox.ps1
|
||||
@ -22,21 +22,31 @@ param(
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$env:WSL_UTF8 = "1"
|
||||
# 控制台与输出流统一 UTF-8:后端以管道读取本脚本输出,默认 GBK 会导致中文乱码
|
||||
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
$OutputEncoding = [System.Text.Encoding]::UTF8
|
||||
|
||||
function Invoke-Wsl {
|
||||
param([Parameter(ValueFromRemainingArguments=$true)][string[]]$Args)
|
||||
$output = & wsl.exe @Args 2>&1 | Where-Object { $_ -notmatch "localhost 代理|localhost proxy" }
|
||||
# PS 5.1 下 ValueFromRemainingArguments 会把 -d/-e 等 wsl 参数误绑定到函数通用参数
|
||||
# (-e 前缀匹配 -ErrorAction/-ErrorVariable 报二义性错误),故改为单个数组传参,
|
||||
# 调用处一律:Invoke-Wsl @('-d', $Name, '-e', 'true')
|
||||
param([string[]]$WslArgs)
|
||||
# PS 5.1 坑:全局 EAP=Stop 时原生命令写 stderr(如 wsl 的 localhost 代理警告)
|
||||
# 会直接抛 NativeCommandError 终止脚本,Where 过滤来不及生效。
|
||||
# 函数内降为 Continue(函数作用域,不影响全局 Stop 对其它命令的保护)。
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$output = & wsl.exe @WslArgs 2>&1 | Where-Object { $_ -notmatch "localhost 代理|localhost proxy" }
|
||||
return @{ Code = $LASTEXITCODE; Output = ($output -join "`n") }
|
||||
}
|
||||
|
||||
Write-Host "==> [1/6] 检查 WSL 环境"
|
||||
$null = Invoke-Wsl --status
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
$wslStatus = Invoke-Wsl @('--status')
|
||||
if ($wslStatus.Code -ne 0) {
|
||||
throw "WSL 不可用。请先启用 WSL2(wsl --install --no-distribution 或安装 Docker Desktop 后重试)。"
|
||||
}
|
||||
|
||||
Write-Host "==> [2/6] 检查发行版 '$DistroName' 是否已存在"
|
||||
$probe = Invoke-Wsl -d $DistroName -e true
|
||||
$probe = Invoke-Wsl @('-d', $DistroName, '-e', 'true')
|
||||
if ($probe.Code -eq 0) {
|
||||
Write-Host " 发行版已存在,跳过导入(如需重建:wsl --unregister $DistroName)"
|
||||
} else {
|
||||
@ -49,38 +59,44 @@ if ($probe.Code -eq 0) {
|
||||
|
||||
Write-Host "==> [4/6] 导入为 WSL2 发行版"
|
||||
New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null
|
||||
$null = Invoke-Wsl --import $DistroName $InstallDir $rootfs --version 2
|
||||
$check = Invoke-Wsl -d $DistroName -e true
|
||||
$null = Invoke-Wsl @('--import', $DistroName, $InstallDir, $rootfs, '--version', '2')
|
||||
$check = Invoke-Wsl @('-d', $DistroName, '-e', 'true')
|
||||
if ($check.Code -ne 0) { throw "发行版导入失败: $($check.Output)" }
|
||||
}
|
||||
|
||||
Write-Host "==> [5/6] 写入沙箱配置(关闭 interop / 固定 DNS / 国内镜像)"
|
||||
# 关闭 Windows 互操作:防止沙箱内经 cmd.exe 逃逸宿主机(PoC 已验证该逃逸路径)
|
||||
$null = Invoke-Wsl -d $DistroName -- sh -c "printf '[network]\ngenerateResolvConf = false\n\n[interop]\nenabled = false\nappendWindowsPath = false\n' > /etc/wsl.conf"
|
||||
$null = Invoke-Wsl -d $DistroName -- sh -c "rm -f /etc/resolv.conf && printf 'nameserver 223.5.5.5\nnameserver 119.29.29.29\n' > /etc/resolv.conf"
|
||||
$null = Invoke-Wsl -d $DistroName -- sh -c "printf '$ApkMirror/main\n$ApkMirror/community\n' > /etc/apk/repositories"
|
||||
$conf = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "printf '[network]\ngenerateResolvConf = false\n\n[interop]\nenabled = false\nappendWindowsPath = false\n' > /etc/wsl.conf")
|
||||
if ($conf.Code -ne 0) { throw "写入 wsl.conf 失败: $($conf.Output)" }
|
||||
# 坑:首次导入的发行版内 /etc/resolv.conf 是 WSL 生成的 bind mount,直接 rm 会 EBUSY 失败
|
||||
# (旧脚本未检查返回码被静默吞掉,terminate 后 generateResolvConf=false 生效,
|
||||
# 不再自动生成 resolv.conf → 文件缺失 → DNS 完全失效)。必须先 umount 再重建真实文件。
|
||||
$resolv = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "umount /etc/resolv.conf 2>/dev/null; rm -f /etc/resolv.conf; printf 'nameserver 223.5.5.5\nnameserver 119.29.29.29\n' > /etc/resolv.conf && cat /etc/resolv.conf")
|
||||
if ($resolv.Code -ne 0) { throw "写入 resolv.conf 失败: $($resolv.Output)" }
|
||||
$apkRepo = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "printf '$ApkMirror/main\n$ApkMirror/community\n' > /etc/apk/repositories")
|
||||
if ($apkRepo.Code -ne 0) { throw "写入 apk 镜像源失败: $($apkRepo.Output)" }
|
||||
|
||||
# 使 wsl.conf 生效
|
||||
$null = Invoke-Wsl --terminate $DistroName
|
||||
$null = Invoke-Wsl @('--terminate', $DistroName)
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Host "==> [6/6] 安装沙箱工具链(bubblewrap / bash / python3 / git)"
|
||||
$apk = Invoke-Wsl -d $DistroName -- sh -c "apk update && apk add bubblewrap bash ncurses-libs python3 git"
|
||||
$apk = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "apk update && apk add bubblewrap bash ncurses-libs python3 git")
|
||||
if ($apk.Code -ne 0) { throw "apk 安装失败: $($apk.Output)" }
|
||||
|
||||
# 与 Windows 端 Git for Windows 默认的 core.autocrlf=true 对齐:
|
||||
# Windows 检出的工作区文件是 CRLF,沙箱 git 不开 autocrlf 会把全部文件误判为已修改
|
||||
# (全量 +/-),且全量内容读取+xdiff 会让 git 命令慢到触发执行超时。
|
||||
$null = Invoke-Wsl -d $DistroName -e git config --system core.autocrlf true
|
||||
$null = Invoke-Wsl @('-d', $DistroName, '-e', 'git', 'config', '--system', 'core.autocrlf', 'true')
|
||||
# 9P 挂载的 inode/ctime 语义与 Windows git 写入的索引不匹配,会导致沙箱 git 对全部
|
||||
# 文件做内容重读(全树 status/diff >20s);放宽 stat 校验后只需秒级。
|
||||
$null = Invoke-Wsl -d $DistroName -e git config --system core.checkStat minimal
|
||||
$null = Invoke-Wsl -d $DistroName -e git config --system core.trustctime false
|
||||
$null = Invoke-Wsl @('-d', $DistroName, '-e', 'git', 'config', '--system', 'core.checkStat', 'minimal')
|
||||
$null = Invoke-Wsl @('-d', $DistroName, '-e', 'git', 'config', '--system', 'core.trustctime', 'false')
|
||||
|
||||
# 验收
|
||||
$verify = Invoke-Wsl -d $DistroName -- sh -c "bwrap --version && bash -c 'echo bash-ok' && python3 --version && git --version"
|
||||
$verify = Invoke-Wsl @('-d', $DistroName, '--', 'sh', '-c', "bwrap --version && bash -c 'echo bash-ok' && python3 --version && git --version")
|
||||
Write-Host $verify.Output
|
||||
$escape = Invoke-Wsl -d $DistroName -- bwrap --die-with-parent --new-session --unshare-all --ro-bind / / --proc /proc --dev /dev -- bash -c "cmd.exe /c echo escape 2>&1 || echo interop-blocked"
|
||||
$escape = Invoke-Wsl @('-d', $DistroName, '--', 'bwrap', '--die-with-parent', '--new-session', '--unshare-all', '--ro-bind', '/', '/', '--proc', '/proc', '--dev', '/dev', '--', 'bash', '-c', "cmd.exe /c echo escape 2>&1 || echo interop-blocked")
|
||||
if ($escape.Output -match "interop-blocked|not found") {
|
||||
Write-Host " interop 逃逸封堵验证: OK"
|
||||
} else {
|
||||
|
||||
38
scripts/start-sandbox-test-instance.cmd
Normal file
38
scripts/start-sandbox-test-instance.cmd
Normal file
@ -0,0 +1,38 @@
|
||||
@echo off
|
||||
rem start-sandbox-test-instance.cmd — 启动「沙箱安装向导」的隔离测试实例(Windows)
|
||||
rem
|
||||
rem 用途:验证「未安装沙箱时的居中弹窗 + 一键安装流程」。
|
||||
rem 本机 astrion-sandbox 已就绪时,正常实例不会弹窗;本脚本通过
|
||||
rem HOST_SANDBOX_WSL_DISTRO 指向一个不存在的测试发行版,使检测判定为缺失。
|
||||
rem
|
||||
rem 可选参数:
|
||||
rem wsl-missing 额外模拟「WSL 未启用」分支:在 PATH 最前置入一个含同名
|
||||
rem 0 字节假 wsl.exe 的目录(E:\astrion\sandbox-test-fake),
|
||||
rem which/Start-Process 优先命中它而失败,等效于挪走系统
|
||||
rem wsl.exe,但不碰系统文件,删目录即恢复。
|
||||
rem
|
||||
rem 隔离手段(主程序 8091/8092 完全不受影响):
|
||||
rem 1. setlocal —— 环境变量仅存在于本脚本进程及其子进程,
|
||||
rem 脚本退出即失效,外部 cmd 窗口与其他服务实例读不到;
|
||||
rem 2. ASTRION_DATA_ROOT —— 数据目录独立(全新初始化,不动现有对话);
|
||||
rem 3. --port 8093 —— 端口独立。
|
||||
rem
|
||||
rem 测试结束后清理:
|
||||
rem wsl --unregister astrion-sandbox-test
|
||||
rem rmdir /s /q E:\astrion\sandbox-test-data
|
||||
rem rmdir /s /q E:\astrion\sandbox-test-fake
|
||||
rem (若存在 %USERPROFILE%\.astrion\wsl-sandbox-astrion-sandbox-test 残留目录一并删除)
|
||||
|
||||
setlocal
|
||||
set ASTRION_DATA_ROOT=E:\astrion\sandbox-test-data
|
||||
set HOST_SANDBOX_WSL_DISTRO=astrion-sandbox-test
|
||||
|
||||
if /i "%~1"=="wsl-missing" (
|
||||
if not exist E:\astrion\sandbox-test-fake mkdir E:\astrion\sandbox-test-fake
|
||||
if not exist E:\astrion\sandbox-test-fake\wsl.exe type nul > E:\astrion\sandbox-test-fake\wsl.exe
|
||||
set "PATH=E:\astrion\sandbox-test-fake;%PATH%"
|
||||
echo [测试模式] 已模拟 wsl.exe 缺失:PATH 优先命中 0 字节假文件
|
||||
)
|
||||
|
||||
python -m server.app --port 8093
|
||||
endlocal
|
||||
@ -9,3 +9,4 @@ from server.status.file_open import *
|
||||
from server.status.docker import *
|
||||
from server.status.host_workspace import *
|
||||
from server.status.app import *
|
||||
from server.status.sandbox import *
|
||||
|
||||
58
server/status/sandbox.py
Normal file
58
server/status/sandbox.py
Normal file
@ -0,0 +1,58 @@
|
||||
# server/status/sandbox.py - Windows WSL 沙箱环境检测与一键安装 API
|
||||
#
|
||||
# - GET /api/sandbox/status 分级检测沙箱状态(前端进页面自动调用)
|
||||
# - POST /api/sandbox/setup 启动一键安装(后台线程跑 setup-wsl-sandbox.ps1)
|
||||
# - GET /api/sandbox/setup/status 轮询安装进度(阶段 / 步骤 / 日志 / 下载量)
|
||||
#
|
||||
# 仅 Windows + 宿主机模式(TERMINAL_SANDBOX_MODE=host 且会话 host_mode)适用;
|
||||
# 其它环境 applicable=false,前端不展示任何提示。
|
||||
# 安装进程由 server 直接在宿主机拉起(沙箱尚未建立的"鸡生蛋"场景,
|
||||
# 由用户在前端显式点击触发,不经 run_command 沙箱链路)。
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import jsonify, request, session
|
||||
|
||||
from server.status import status_bp
|
||||
from server.auth_helpers import api_login_required
|
||||
from modules.sandbox_setup_manager import sandbox_setup_manager
|
||||
from modules.i18n import tr
|
||||
|
||||
|
||||
def _is_host_mode_request() -> bool:
|
||||
try:
|
||||
from config import TERMINAL_SANDBOX_MODE
|
||||
except Exception:
|
||||
return False
|
||||
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
|
||||
|
||||
|
||||
@status_bp.route('/api/sandbox/status')
|
||||
@api_login_required
|
||||
def get_sandbox_status():
|
||||
"""分级检测沙箱状态。force=1 时绕过短缓存实测。"""
|
||||
if not _is_host_mode_request():
|
||||
return jsonify({"success": True, "data": {"applicable": False, "state": "not_applicable"}})
|
||||
force = request.args.get("force") == "1"
|
||||
data = sandbox_setup_manager.get_sandbox_status(force=force)
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
|
||||
@status_bp.route('/api/sandbox/setup', methods=['POST'])
|
||||
@api_login_required
|
||||
def start_sandbox_setup():
|
||||
"""启动一键安装。body: {"enable_wsl_if_needed": bool}"""
|
||||
if not _is_host_mode_request():
|
||||
return jsonify({"success": False, "error": tr("sandbox.setup_not_host_mode")}), 400
|
||||
payload = request.get_json(silent=True) or {}
|
||||
result = sandbox_setup_manager.start_setup(bool(payload.get("enable_wsl_if_needed")))
|
||||
if not result["started"]:
|
||||
return jsonify({"success": False, "error": result["error"]}), 409
|
||||
return jsonify({"success": True, "data": sandbox_setup_manager.get_setup_progress()})
|
||||
|
||||
|
||||
@status_bp.route('/api/sandbox/setup/status')
|
||||
@api_login_required
|
||||
def get_sandbox_setup_status():
|
||||
"""轮询安装进度。"""
|
||||
return jsonify({"success": True, "data": sandbox_setup_manager.get_setup_progress()})
|
||||
@ -554,6 +554,7 @@
|
||||
@submit="submitPlanApproval"
|
||||
@minimize="minimizePlanApprovalDialog"
|
||||
/>
|
||||
<SandboxSetupDialog />
|
||||
<transition name="overlay-fade">
|
||||
<VersioningDialog
|
||||
v-if="versioningDialogOpen"
|
||||
|
||||
@ -33,6 +33,9 @@ const UserQuestionDialog = defineAsyncComponent(
|
||||
const PlanApprovalDialog = defineAsyncComponent(
|
||||
() => import('../components/overlay/PlanApprovalDialog.vue')
|
||||
);
|
||||
const SandboxSetupDialog = defineAsyncComponent(
|
||||
() => import('../components/overlay/SandboxSetupDialog.vue')
|
||||
);
|
||||
const TutorialOverlay = defineAsyncComponent(
|
||||
() => import('../components/overlay/TutorialOverlay.vue')
|
||||
);
|
||||
@ -66,6 +69,7 @@ export const appComponents = {
|
||||
VersioningDialog,
|
||||
UserQuestionDialog,
|
||||
PlanApprovalDialog,
|
||||
SandboxSetupDialog,
|
||||
TutorialOverlay,
|
||||
NewUserTutorialPrompt,
|
||||
GoalProgressDialog,
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
// @ts-nocheck
|
||||
import { useChatActionStore } from '../stores/chatActions';
|
||||
import { useSandboxSetupStore } from '../stores/sandboxSetup';
|
||||
import { normalizeScrollLock } from '../composables/useScrollControl';
|
||||
import { setupShowImageObserver, teardownShowImageObserver } from './bootstrap';
|
||||
import { debugLog } from './methods/common';
|
||||
@ -48,6 +49,8 @@ export async function mounted() {
|
||||
this.fetchTerminalCount();
|
||||
this.startTerminalCountIdleRefresh();
|
||||
this.checkTutorialPrompt();
|
||||
// Windows 宿主机模式下检测沙箱环境,缺失时弹出居中的安装向导(不分 sandbox/direct 执行环境)
|
||||
void useSandboxSetupStore().autoCheck();
|
||||
if (initialDataPromise && typeof initialDataPromise.then === 'function') {
|
||||
initialDataPromise
|
||||
.then(() => {
|
||||
|
||||
613
static/src/components/overlay/SandboxSetupDialog.vue
Normal file
613
static/src/components/overlay/SandboxSetupDialog.vue
Normal file
@ -0,0 +1,613 @@
|
||||
<template>
|
||||
<transition name="sandbox-setup-fade" appear>
|
||||
<div v-if="store.dialogVisible" class="sandbox-setup-overlay">
|
||||
<section
|
||||
class="sandbox-setup-card"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-label="$t('sandbox.setupAriaLabel')"
|
||||
>
|
||||
<header class="sandbox-setup-windowbar">
|
||||
<div class="sandbox-setup-window-title">{{ $t('sandbox.setupTitle') }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="sandbox-setup-close"
|
||||
:title="$t('common.close')"
|
||||
:aria-label="$t('common.close')"
|
||||
@click="onClose"
|
||||
>
|
||||
<svg viewBox="0 0 20 20" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M5 5l10 10M15 5L5 15" />
|
||||
</svg>
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="sandbox-setup-body">
|
||||
<!-- ── 未开始:说明 + 状态描述 ── -->
|
||||
<template v-if="!progress">
|
||||
<div class="sandbox-setup-hero">
|
||||
<svg viewBox="0 0 24 24" width="34" height="34" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<path d="M12 3l7 3v5c0 4.5-3 8.5-7 10-4-1.5-7-5.5-7-10V6l7-3z" />
|
||||
<path d="M9 12l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<p v-if="stateDescription" class="sandbox-setup-state-desc">{{ stateDescription }}</p>
|
||||
<p v-else class="sandbox-setup-state-desc">{{ $t('sandbox.stateChecking') }}</p>
|
||||
<ul class="sandbox-setup-facts">
|
||||
<li>{{ $t('sandbox.introWhat') }}</li>
|
||||
<li>{{ $t('sandbox.introEffect') }}</li>
|
||||
<li>{{ $t('sandbox.introDisk', { path: installPathHint }) }}</li>
|
||||
<li>{{ $t('sandbox.introUninstall', { distro: distroName }) }}</li>
|
||||
<li>{{ $t('sandbox.introSecure') }}</li>
|
||||
</ul>
|
||||
</template>
|
||||
|
||||
<!-- ── 安装中 / 终态:阶段 + 步骤进度 ── -->
|
||||
<template v-else>
|
||||
<div v-if="phaseLineText" class="sandbox-setup-phase-line">
|
||||
<span class="sandbox-setup-spinner" aria-hidden="true"></span>
|
||||
{{ phaseLineText }}
|
||||
</div>
|
||||
|
||||
<ul class="sandbox-setup-steps">
|
||||
<li
|
||||
v-for="(step, i) in steps"
|
||||
:key="i"
|
||||
class="sandbox-setup-step"
|
||||
:class="stepStatus(i + 1)"
|
||||
>
|
||||
<span class="sandbox-setup-step-icon" aria-hidden="true">
|
||||
<svg v-if="stepStatus(i + 1) === 'done'" viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M4 10.5l4 4 8-9" />
|
||||
</svg>
|
||||
<span v-else-if="stepStatus(i + 1) === 'active'" class="sandbox-setup-spinner small"></span>
|
||||
<svg v-else-if="stepStatus(i + 1) === 'error'" viewBox="0 0 20 20" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round">
|
||||
<path d="M5 5l10 10M15 5L5 15" />
|
||||
</svg>
|
||||
<span v-else class="sandbox-setup-step-dot"></span>
|
||||
</span>
|
||||
<span class="sandbox-setup-step-title">{{ step }}</span>
|
||||
<span v-if="i + 1 === 3 && progress.step_index === 3 && downloadText" class="sandbox-setup-step-extra">
|
||||
{{ downloadText }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sandbox-setup-progress">
|
||||
<div class="sandbox-setup-progress-fill" :style="{ width: progressPercent + '%' }"></div>
|
||||
</div>
|
||||
|
||||
<div v-if="progress.phase === 'done'" class="sandbox-setup-result ok">
|
||||
{{ $t('sandbox.phaseDone') }}
|
||||
</div>
|
||||
<div v-else-if="progress.phase === 'needs_reboot'" class="sandbox-setup-result warn">
|
||||
{{ $t('sandbox.phaseNeedsReboot') }}
|
||||
</div>
|
||||
<div v-else-if="progress.phase === 'error'" class="sandbox-setup-result error">
|
||||
<p class="sandbox-setup-error-line">{{ progress.error || $t('sandbox.phaseError') }}</p>
|
||||
<p v-if="progress.error_kind === 'uac_cancelled'" class="sandbox-setup-error-hint">
|
||||
{{ $t('sandbox.uacCancelledHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="progress.log_tail.length" class="sandbox-setup-log-block">
|
||||
<div class="sandbox-setup-log-label">{{ $t('sandbox.logLabel') }}</div>
|
||||
<pre ref="logEl" class="sandbox-setup-log">{{ progress.log_tail.join('\n') }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<footer v-if="!progress || !progress.active" class="sandbox-setup-footer">
|
||||
<!-- 未开始 -->
|
||||
<template v-if="!progress">
|
||||
<label class="sandbox-setup-never">
|
||||
<input type="checkbox" :checked="neverChecked" @change="neverChecked = ($event.target as HTMLInputElement).checked" />
|
||||
<FancyCheck :checked="neverChecked" :size="16" />
|
||||
<span>{{ $t('sandbox.neverAgain') }}</span>
|
||||
</label>
|
||||
<div class="sandbox-setup-spacer"></div>
|
||||
<button type="button" class="sandbox-setup-btn ghost" @click="onClose">
|
||||
{{ $t('sandbox.later') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="sandbox-setup-btn primary"
|
||||
:disabled="store.starting || store.checking || !store.status"
|
||||
@click="store.startSetup()"
|
||||
>
|
||||
{{ store.starting ? $t('common.loading') : $t('sandbox.installNow') }}
|
||||
</button>
|
||||
</template>
|
||||
<!-- 终态 -->
|
||||
<template v-else>
|
||||
<div class="sandbox-setup-spacer"></div>
|
||||
<button
|
||||
v-if="progress.phase === 'error' || progress.phase === 'needs_reboot'"
|
||||
type="button"
|
||||
class="sandbox-setup-btn primary"
|
||||
:disabled="store.starting"
|
||||
@click="store.retrySetup()"
|
||||
>
|
||||
{{ progress.phase === 'needs_reboot' ? $t('sandbox.rebootDone') : $t('common.retry') }}
|
||||
</button>
|
||||
<button type="button" class="sandbox-setup-btn ghost" @click="onClose">
|
||||
{{ $t('common.close') }}
|
||||
</button>
|
||||
</template>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
|
||||
|
||||
defineOptions({ name: 'SandboxSetupDialog' });
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useSandboxSetupStore();
|
||||
|
||||
const neverChecked = ref(false);
|
||||
const logEl = ref<HTMLElement | null>(null);
|
||||
|
||||
// 安装日志常显且自动滚动到底部(新行到达时)
|
||||
watch(
|
||||
() => store.progress?.log_tail.length,
|
||||
async () => {
|
||||
await nextTick();
|
||||
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight;
|
||||
}
|
||||
);
|
||||
|
||||
const progress = computed(() => store.progress);
|
||||
const distroName = computed(() => store.status?.distro_name || 'astrion-sandbox');
|
||||
// 安装目录与 scripts/setup-wsl-sandbox.ps1 的默认 InstallDir 保持一致
|
||||
const installPathHint = computed(() => '%USERPROFILE%\\.astrion\\wsl-sandbox');
|
||||
|
||||
const stateDescription = computed(() => {
|
||||
const s = store.status;
|
||||
if (!s || !s.applicable) return '';
|
||||
if (s.state === 'wsl_missing') return t('sandbox.stateWslMissing');
|
||||
if (s.state === 'distro_missing') return t('sandbox.stateDistroMissing');
|
||||
if (s.state === 'bwrap_missing') return t('sandbox.stateBwrapMissing');
|
||||
return '';
|
||||
});
|
||||
|
||||
const steps = computed(() => [
|
||||
t('sandbox.stepCheckWsl'),
|
||||
t('sandbox.stepCheckDistro'),
|
||||
t('sandbox.stepDownloadRootfs'),
|
||||
t('sandbox.stepImportDistro'),
|
||||
t('sandbox.stepWriteConfig'),
|
||||
t('sandbox.stepInstallTools')
|
||||
]);
|
||||
|
||||
function stepStatus(step1Based: number): 'pending' | 'active' | 'done' | 'error' {
|
||||
const p = progress.value;
|
||||
if (!p) return 'pending';
|
||||
if (p.phase === 'verifying' || p.phase === 'done') return 'done';
|
||||
if (p.phase === 'enabling_wsl' || p.step_index === 0) return 'pending';
|
||||
if (step1Based < p.step_index) return 'done';
|
||||
if (step1Based === p.step_index) return p.phase === 'error' ? 'error' : 'active';
|
||||
return 'pending';
|
||||
}
|
||||
|
||||
const downloadFraction = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p || p.step_index !== 3 || !p.download_bytes || !p.download_total) return 0;
|
||||
return Math.min(1, p.download_bytes / p.download_total);
|
||||
});
|
||||
|
||||
const progressPercent = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p) return 0;
|
||||
switch (p.phase) {
|
||||
case 'enabling_wsl':
|
||||
return 4;
|
||||
case 'installing_wsl':
|
||||
return 6;
|
||||
case 'installing': {
|
||||
const total = p.step_total || 6;
|
||||
const doneSteps = Math.max(0, p.step_index - 1);
|
||||
return Math.min(96, Math.round(((doneSteps + downloadFraction.value) / total) * 100));
|
||||
}
|
||||
case 'verifying':
|
||||
return 96;
|
||||
case 'done':
|
||||
return 100;
|
||||
case 'needs_reboot':
|
||||
return 8;
|
||||
case 'error': {
|
||||
const total = p.step_total || 6;
|
||||
return Math.round((Math.max(0, p.step_index - 1) / total) * 100);
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
const phaseLineText = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p) return '';
|
||||
if (p.phase === 'enabling_wsl') return t('sandbox.phaseEnablingWsl');
|
||||
if (p.phase === 'installing_wsl') return t('sandbox.phaseInstallingWsl');
|
||||
if (p.phase === 'verifying') return t('sandbox.phaseVerifying');
|
||||
return '';
|
||||
});
|
||||
|
||||
function formatMB(bytes: number): string {
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
const downloadText = computed(() => {
|
||||
const p = progress.value;
|
||||
if (!p || !p.download_bytes) return '';
|
||||
if (p.download_total) {
|
||||
return t('sandbox.downloadProgress', {
|
||||
size: `${formatMB(p.download_bytes)} / ${formatMB(p.download_total)}`
|
||||
});
|
||||
}
|
||||
return t('sandbox.downloadProgress', { size: formatMB(p.download_bytes) });
|
||||
});
|
||||
|
||||
function onClose() {
|
||||
// 安装中关闭仅收起弹窗(安装继续在后台进行,可从个人空间重新打开)
|
||||
if (store.progress?.active) {
|
||||
store.dialogVisible = false;
|
||||
return;
|
||||
}
|
||||
store.closeDialog(neverChecked.value);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sandbox-setup-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1400;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: var(--overlay-scrim);
|
||||
}
|
||||
|
||||
.sandbox-setup-card {
|
||||
position: relative;
|
||||
width: min(560px, calc(100vw - 36px));
|
||||
max-height: min(720px, calc(100vh - 36px));
|
||||
max-height: min(720px, calc(100dvh - 36px));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sandbox-setup-windowbar {
|
||||
position: relative;
|
||||
height: 40px;
|
||||
flex: 0 0 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-bottom: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.sandbox-setup-window-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sandbox-setup-close {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 7px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sandbox-setup-close:hover {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sandbox-setup-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 20px 24px 8px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sandbox-setup-hero {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
color: var(--accent);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sandbox-setup-state-desc {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sandbox-setup-facts {
|
||||
margin: 0;
|
||||
padding: 0 0 0 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sandbox-setup-facts li {
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sandbox-setup-phase-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 30px;
|
||||
margin-bottom: 10px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sandbox-setup-steps {
|
||||
list-style: none;
|
||||
margin: 0 0 14px;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.sandbox-setup-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
height: 30px;
|
||||
font-size: 13px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.sandbox-setup-step.active {
|
||||
color: var(--text-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sandbox-setup-step.done {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sandbox-setup-step.error {
|
||||
color: var(--state-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sandbox-setup-step-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sandbox-setup-step.done .sandbox-setup-step-icon {
|
||||
color: var(--state-success);
|
||||
}
|
||||
|
||||
.sandbox-setup-step-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
background: var(--border-strong);
|
||||
}
|
||||
|
||||
.sandbox-setup-step-extra {
|
||||
margin-left: auto;
|
||||
font-size: 11.5px;
|
||||
font-weight: 400;
|
||||
color: var(--text-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.sandbox-setup-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border-default);
|
||||
border-top-color: var(--accent);
|
||||
animation: sandbox-setup-spin 0.9s linear infinite;
|
||||
}
|
||||
|
||||
.sandbox-setup-spinner.small {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
border-width: 1.6px;
|
||||
}
|
||||
|
||||
@keyframes sandbox-setup-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.sandbox-setup-progress {
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--surface-muted);
|
||||
overflow: hidden;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.sandbox-setup-progress-fill {
|
||||
height: 100%;
|
||||
border-radius: 3px;
|
||||
background: var(--accent);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.sandbox-setup-result {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.6;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sandbox-setup-result.ok {
|
||||
background: color-mix(in srgb, var(--state-success) 12%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sandbox-setup-result.warn {
|
||||
background: color-mix(in srgb, var(--state-warning) 14%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sandbox-setup-result.error {
|
||||
background: color-mix(in srgb, var(--state-danger) 12%, transparent);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sandbox-setup-error-line {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sandbox-setup-error-hint {
|
||||
margin: 6px 0 0;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sandbox-setup-log-block {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sandbox-setup-log-label {
|
||||
height: 22px;
|
||||
line-height: 22px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sandbox-setup-log {
|
||||
margin: 2px 0 0;
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: var(--surface-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
color: var(--text-secondary);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sandbox-setup-footer {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 24px 16px;
|
||||
border-top: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
.sandbox-setup-never {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
height: 30px;
|
||||
font-size: 12.5px;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.sandbox-setup-never input {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.sandbox-setup-spacer {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
|
||||
.sandbox-setup-btn {
|
||||
height: 32px;
|
||||
padding: 0 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sandbox-setup-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.sandbox-setup-btn.ghost {
|
||||
background: transparent;
|
||||
border-color: var(--border-default);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sandbox-setup-btn.ghost:hover:not(:disabled) {
|
||||
background: var(--hover-bg);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sandbox-setup-btn.primary {
|
||||
background: var(--accent);
|
||||
color: var(--on-accent);
|
||||
}
|
||||
|
||||
.sandbox-setup-btn.primary:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sandbox-setup-fade-enter-active,
|
||||
.sandbox-setup-fade-leave-active {
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.sandbox-setup-fade-enter-from,
|
||||
.sandbox-setup-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@ -1,9 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from 'vue';
|
||||
import { computed, inject, onMounted } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import FancyCheck from '@/components/common/FancyCheck.vue';
|
||||
import { useSandboxSetupStore } from '@/stores/sandboxSetup';
|
||||
|
||||
defineOptions({ name: 'GeneralTab' });
|
||||
|
||||
const { t } = useI18n();
|
||||
const sandboxSetup = useSandboxSetupStore();
|
||||
|
||||
// 沙箱环境区块:进入通用页时补一次检测(复用 store 内部防抖)
|
||||
onMounted(() => {
|
||||
if (!sandboxSetup.status) void sandboxSetup.fetchStatus();
|
||||
});
|
||||
|
||||
const sandboxStatusText = computed(() => {
|
||||
const s = sandboxSetup.status;
|
||||
if (!s || sandboxSetup.checking) return t('sandbox.sectionChecking');
|
||||
if (!s.applicable) return t('sandbox.sectionUnavailable');
|
||||
return s.state === 'ready' ? t('sandbox.sectionReady') : t('sandbox.sectionMissing');
|
||||
});
|
||||
|
||||
const sandboxShowWizard = computed(() => {
|
||||
const s = sandboxSetup.status;
|
||||
return !!s && s.applicable && s.state !== 'ready';
|
||||
});
|
||||
|
||||
/**
|
||||
* 共享上下文由 PersonalizationDrawer.vue 通过 provide 注入。
|
||||
* 解构出的名称与主文件 script 顶层绑定一致,模板可直接引用。
|
||||
@ -84,6 +106,44 @@ const {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-action-row sandbox-env-row">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">{{ $t('sandbox.sectionTitle') }}</span>
|
||||
<span class="settings-row-desc">{{ $t('sandbox.sectionDesc') }}</span>
|
||||
</span>
|
||||
<div class="settings-inline-actions">
|
||||
<span class="settings-mini-status" :class="{ warning: sandboxShowWizard }">{{
|
||||
sandboxStatusText
|
||||
}}</span>
|
||||
<span v-if="sandboxSetup.neverAsk" class="settings-mini-status">{{
|
||||
$t('sandbox.neverAgainSet')
|
||||
}}</span>
|
||||
<button
|
||||
v-if="sandboxSetup.neverAsk"
|
||||
type="button"
|
||||
class="settings-secondary-button"
|
||||
@click="sandboxSetup.resetNeverAsk()"
|
||||
>
|
||||
{{ $t('sandbox.resetNeverAgain') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="settings-secondary-button"
|
||||
:disabled="sandboxSetup.checking"
|
||||
@click="sandboxSetup.recheck()"
|
||||
>
|
||||
{{ sandboxSetup.checking ? $t('common.refreshing') : $t('sandbox.recheck') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="sandboxShowWizard"
|
||||
type="button"
|
||||
class="settings-primary-button"
|
||||
@click="sandboxSetup.openWizard()"
|
||||
>
|
||||
{{ $t('sandbox.openWizard') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-action-row danger-zone">
|
||||
<span class="settings-row-copy">
|
||||
<span class="settings-row-title">{{ $t('personalization.logoutTitle') }}</span>
|
||||
@ -99,3 +159,16 @@ const {
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 沙箱环境区块:上下结构(标题描述在上,状态徽标与按钮组整行在下),
|
||||
避免默认左右结构中右侧按钮组挤压左侧文案空间 */
|
||||
.sandbox-env-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sandbox-env-row .settings-inline-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -25,6 +25,7 @@ import shell from './en-US/shell';
|
||||
import sidebar from './en-US/sidebar';
|
||||
import auth from './en-US/auth';
|
||||
import utils from './en-US/utils';
|
||||
import sandbox from './en-US/sandbox';
|
||||
|
||||
type DeepString<T> = { [K in keyof T]: T[K] extends string ? string : DeepString<T[K]> };
|
||||
|
||||
@ -53,6 +54,7 @@ const enUS: DeepString<typeof zhCN> = {
|
||||
sidebar,
|
||||
auth,
|
||||
utils,
|
||||
sandbox,
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
55
static/src/locales/en-US/sandbox.ts
Normal file
55
static/src/locales/en-US/sandbox.ts
Normal file
@ -0,0 +1,55 @@
|
||||
// Locale namespace: sandbox (en-US)
|
||||
// Keys must exactly mirror zh-CN/sandbox.ts (enforced by tsc DeepString).
|
||||
export default {
|
||||
// ── Dialog frame ──
|
||||
setupTitle: 'Set Up Sandbox Environment',
|
||||
setupAriaLabel: 'Sandbox environment setup wizard',
|
||||
|
||||
// ── Intro ──
|
||||
introWhat: 'The sandbox is an isolated command execution environment (based on WSL2). All terminal commands run by the AI execute inside the sandbox, isolated from your system.',
|
||||
introEffect: 'Without the sandbox, commands that require isolation cannot run and tool calls will fail with errors.',
|
||||
introDisk: 'An Alpine mini system (~3MB) will be downloaded and installed to {path} (~100-300MB total with toolchain).',
|
||||
introUninstall: 'You can fully uninstall anytime with: wsl --unregister {distro}.',
|
||||
introSecure: 'A dedicated sandbox distro is installed (Windows interop disabled); a daily distro like Ubuntu cannot be used instead.',
|
||||
|
||||
// ── Detection states ──
|
||||
stateWslMissing: 'WSL2 is not enabled on this system. Setup will first enable WSL2 (a system administrator prompt will appear, and a reboot may be required afterwards).',
|
||||
stateDistroMissing: 'WSL2 is ready, but the dedicated sandbox distro is not installed yet. Click install to finish automatically.',
|
||||
stateBwrapMissing: 'The sandbox distro exists, but the bubblewrap component is missing. Click install to repair automatically.',
|
||||
stateChecking: 'Checking sandbox environment...',
|
||||
|
||||
// ── Phases and steps ──
|
||||
phaseEnablingWsl: 'Requesting administrator approval to enable WSL2 (please confirm in the system prompt)...',
|
||||
phaseInstallingWsl: 'Downloading and installing WSL2 components; this may take a few minutes...',
|
||||
phaseVerifying: 'Verifying installation...',
|
||||
phaseDone: 'Setup complete. The sandbox environment is ready.',
|
||||
phaseNeedsReboot: 'WSL2 has been enabled, but a reboot is required to continue. Please reboot and reopen this wizard to finish setup.',
|
||||
phaseError: 'Setup failed',
|
||||
stepCheckWsl: 'Check WSL environment',
|
||||
stepCheckDistro: 'Check distro',
|
||||
stepDownloadRootfs: 'Download Alpine system',
|
||||
stepImportDistro: 'Import WSL2 distro',
|
||||
stepWriteConfig: 'Write sandbox configuration',
|
||||
stepInstallTools: 'Install sandbox toolchain',
|
||||
downloadProgress: 'Downloaded {size}',
|
||||
logLabel: 'Setup log',
|
||||
|
||||
// ── Buttons and options ──
|
||||
installNow: 'Install Now',
|
||||
later: 'Not Now',
|
||||
neverAgain: "Don't ask again",
|
||||
rebootDone: 'I have rebooted, continue setup',
|
||||
uacCancelledHint: 'Administrator approval was cancelled. Enabling WSL2 requires administrator permission; please retry and choose "Yes" in the system prompt.',
|
||||
|
||||
// ── Personalization → General: sandbox section ──
|
||||
sectionTitle: 'Sandbox Environment',
|
||||
sectionReady: 'Ready',
|
||||
sectionMissing: 'Not installed',
|
||||
sectionChecking: 'Checking...',
|
||||
sectionUnavailable: 'Not applicable in this environment',
|
||||
sectionDesc: 'In Windows host mode, commands run isolated inside a WSL2-based sandbox.',
|
||||
openWizard: 'Open Setup Wizard',
|
||||
recheck: 'Re-check',
|
||||
neverAgainSet: '"Don\'t ask again" is on',
|
||||
resetNeverAgain: 'Re-enable prompts',
|
||||
};
|
||||
@ -24,6 +24,7 @@ import shell from './zh-CN/shell';
|
||||
import sidebar from './zh-CN/sidebar';
|
||||
import auth from './zh-CN/auth';
|
||||
import utils from './zh-CN/utils';
|
||||
import sandbox from './zh-CN/sandbox';
|
||||
|
||||
export default {
|
||||
common,
|
||||
@ -50,4 +51,5 @@ export default {
|
||||
sidebar,
|
||||
auth,
|
||||
utils,
|
||||
sandbox,
|
||||
} as const;
|
||||
|
||||
58
static/src/locales/zh-CN/sandbox.ts
Normal file
58
static/src/locales/zh-CN/sandbox.ts
Normal file
@ -0,0 +1,58 @@
|
||||
// 文案命名空间:sandbox(zh-CN 源语言)
|
||||
// 沙箱环境检测与一键安装向导(Windows 宿主机模式)。
|
||||
// 使用方:static/src/components/overlay/SandboxSetupDialog.vue
|
||||
// static/src/components/personalization/tabs/GeneralTab.vue
|
||||
// static/src/stores/sandboxSetup.ts
|
||||
export default {
|
||||
// ── 弹窗框架 ──
|
||||
setupTitle: '安装沙箱环境',
|
||||
setupAriaLabel: '沙箱环境安装向导',
|
||||
|
||||
// ── 说明区 ──
|
||||
introWhat: '沙箱是一个隔离的命令执行环境(基于 WSL2)。AI 执行的所有终端命令都在沙箱内运行,与您的系统隔离。',
|
||||
introEffect: '未安装沙箱时,需要隔离执行的命令将无法运行,工具调用会直接报错。',
|
||||
introDisk: '将下载约 3MB 的 Alpine 迷你系统并安装到 {path}(含工具链总占用约 100~300MB)。',
|
||||
introUninstall: '可随时通过命令 wsl --unregister {distro} 完全卸载。',
|
||||
introSecure: '安装的是专用沙箱发行版(已关闭 Windows 互操作),不能使用已安装的 Ubuntu 等日常发行版代替。',
|
||||
|
||||
// ── 状态描述(检测分级) ──
|
||||
stateWslMissing: '检测到系统尚未启用 WSL2。安装过程需要先启用 WSL2(系统将弹出管理员授权,授权后可能需要重启电脑)。',
|
||||
stateDistroMissing: '检测到 WSL2 已就绪,但尚未安装沙箱专用发行版。点击安装即可自动完成。',
|
||||
stateBwrapMissing: '检测到沙箱发行版存在,但缺少 bubblewrap 组件。点击安装将自动修复。',
|
||||
stateChecking: '正在检测沙箱环境…',
|
||||
|
||||
// ── 阶段与步骤 ──
|
||||
phaseEnablingWsl: '正在请求管理员授权以启用 WSL2(请在系统弹窗中确认)…',
|
||||
phaseInstallingWsl: '正在下载并安装 WSL2 组件,可能需要几分钟…',
|
||||
phaseVerifying: '正在验收安装结果…',
|
||||
phaseDone: '安装完成,沙箱环境已就绪。',
|
||||
phaseNeedsReboot: 'WSL2 已启用,但需要重启电脑后才能继续。请重启后重新打开本向导完成安装。',
|
||||
phaseError: '安装失败',
|
||||
stepCheckWsl: '检查 WSL 环境',
|
||||
stepCheckDistro: '检查发行版',
|
||||
stepDownloadRootfs: '下载 Alpine 系统',
|
||||
stepImportDistro: '导入 WSL2 发行版',
|
||||
stepWriteConfig: '写入沙箱配置',
|
||||
stepInstallTools: '安装沙箱工具链',
|
||||
downloadProgress: '已下载 {size}',
|
||||
logLabel: '安装日志',
|
||||
|
||||
// ── 按钮与选项 ──
|
||||
installNow: '立即安装',
|
||||
later: '暂不安装',
|
||||
neverAgain: '不再提示',
|
||||
rebootDone: '我已重启,继续安装',
|
||||
uacCancelledHint: '已取消管理员授权。启用 WSL2 需要管理员权限,请点击重试并在系统弹窗中选择"是"。',
|
||||
|
||||
// ── 个人空间 → 通用:沙箱环境区块 ──
|
||||
sectionTitle: '沙箱环境',
|
||||
sectionReady: '已就绪',
|
||||
sectionMissing: '未安装',
|
||||
sectionChecking: '检测中…',
|
||||
sectionUnavailable: '当前环境不适用',
|
||||
sectionDesc: 'Windows 宿主机模式下,命令在基于 WSL2 的沙箱中隔离执行。',
|
||||
openWizard: '打开安装向导',
|
||||
recheck: '重新检测',
|
||||
neverAgainSet: '已选择"不再提示"',
|
||||
resetNeverAgain: '恢复提示',
|
||||
};
|
||||
289
static/src/stores/sandboxSetup.ts
Normal file
289
static/src/stores/sandboxSetup.ts
Normal file
@ -0,0 +1,289 @@
|
||||
// static/src/stores/sandboxSetup.ts - 沙箱环境检测与一键安装向导状态
|
||||
//
|
||||
// 职责:
|
||||
// - 进页面自动检测(Windows 宿主机模式且沙箱未就绪时弹出安装向导,不分 sandbox/direct 执行环境)
|
||||
// - 「暂不安装」= 12 小时内不再弹(sessionStorage 时间戳);「不再提示」= 持久关闭(localStorage)
|
||||
// - 安装任务进度轮询(1s),完成后刷新检测状态
|
||||
//
|
||||
// 使用方:app/lifecycle.ts(自动检测)、overlay/SandboxSetupDialog.vue(弹窗)、
|
||||
// personalization/tabs/GeneralTab.vue(沙箱环境区块)
|
||||
import { defineStore } from 'pinia';
|
||||
|
||||
const NEVER_KEY = 'agents_sandbox_setup_never';
|
||||
const SNOOZE_KEY = 'agents_sandbox_setup_snoozed';
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
/** 「暂不安装」免打扰窗口:12 小时。
|
||||
* 承诺语义是「本次会话不再弹、下次进页面仍提示」,但 sessionStorage 生命周期
|
||||
* 是标签页会话——标签页长期不关会永久拦截弹窗(实测踩坑:用户卸载 WSL 重测时
|
||||
* 被数小时前点的「暂不安装」挡住)。时间戳窗口是对该语义的诚实实现;
|
||||
* 旧值 '1' 会被解析为 1970 年时间戳而必然过期,旧拦截自动失效。 */
|
||||
const SNOOZE_WINDOW_MS = 12 * 60 * 60 * 1000;
|
||||
|
||||
function readSnoozed(): boolean {
|
||||
try {
|
||||
const raw = sessionStorage.getItem(SNOOZE_KEY);
|
||||
if (!raw) return false;
|
||||
const at = Number(raw);
|
||||
if (!Number.isFinite(at) || Date.now() - at > SNOOZE_WINDOW_MS) {
|
||||
sessionStorage.removeItem(SNOOZE_KEY);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export type SandboxState =
|
||||
| 'ready'
|
||||
| 'wsl_missing'
|
||||
| 'distro_missing'
|
||||
| 'bwrap_missing'
|
||||
| 'not_applicable'
|
||||
| 'error';
|
||||
|
||||
export interface SandboxStatus {
|
||||
applicable: boolean;
|
||||
platform: string;
|
||||
state: SandboxState;
|
||||
distro_name: string;
|
||||
detail: string;
|
||||
setup_running: boolean;
|
||||
}
|
||||
|
||||
export type SetupPhase =
|
||||
| 'idle'
|
||||
| 'enabling_wsl'
|
||||
| 'installing_wsl'
|
||||
| 'installing'
|
||||
| 'verifying'
|
||||
| 'done'
|
||||
| 'needs_reboot'
|
||||
| 'error';
|
||||
|
||||
export interface SetupProgress {
|
||||
active: boolean;
|
||||
phase: SetupPhase;
|
||||
step_index: number;
|
||||
step_total: number;
|
||||
step_title: string;
|
||||
log_tail: string[];
|
||||
download_bytes: number | null;
|
||||
download_total: number | null;
|
||||
error: string | null;
|
||||
error_kind: string | null;
|
||||
updated_at: number;
|
||||
}
|
||||
|
||||
interface SandboxSetupState {
|
||||
status: SandboxStatus | null;
|
||||
checking: boolean;
|
||||
dialogVisible: boolean;
|
||||
progress: SetupProgress | null;
|
||||
starting: boolean;
|
||||
snoozed: boolean;
|
||||
pollTimer: ReturnType<typeof setInterval> | null;
|
||||
}
|
||||
|
||||
export const useSandboxSetupStore = defineStore('sandboxSetup', {
|
||||
state: (): SandboxSetupState => ({
|
||||
status: null,
|
||||
checking: false,
|
||||
dialogVisible: false,
|
||||
progress: null,
|
||||
starting: false,
|
||||
snoozed: readSnoozed(),
|
||||
pollTimer: null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
neverAsk(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(NEVER_KEY) === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
/** 沙箱未就绪(仅统计 applicable 且非 ready 的三种缺失状态) */
|
||||
missing(): boolean {
|
||||
const s = this.status;
|
||||
if (!s || !s.applicable) return false;
|
||||
return s.state === 'wsl_missing' || s.state === 'distro_missing' || s.state === 'bwrap_missing';
|
||||
},
|
||||
/** 进页面是否应自动弹出向导 */
|
||||
shouldAutoPrompt(): boolean {
|
||||
if (this.dialogVisible || this.snoozed || this.neverAsk) return false;
|
||||
if (this.progress?.active) return true; // 刷新页面时安装仍在进行,恢复弹窗
|
||||
return this.missing;
|
||||
},
|
||||
/** 安装是否处于终态(done/needs_reboot/error) */
|
||||
setupFinished(): boolean {
|
||||
const p = this.progress;
|
||||
return !!p && !p.active && p.phase !== 'idle';
|
||||
}
|
||||
},
|
||||
|
||||
actions: {
|
||||
async fetchStatus(force = false): Promise<void> {
|
||||
if (this.checking) return;
|
||||
this.checking = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/sandbox/status${force ? '?force=1' : ''}`, {
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data?.success) {
|
||||
this.status = data.data as SandboxStatus;
|
||||
}
|
||||
} catch {
|
||||
// 检测失败保持沉默(不打扰正常使用)
|
||||
} finally {
|
||||
this.checking = false;
|
||||
}
|
||||
},
|
||||
|
||||
/** 进页面自动检测:未就绪且未被打断偏好时弹出向导 */
|
||||
async autoCheck(): Promise<void> {
|
||||
await this.fetchStatus();
|
||||
// 安装任务可能因页面刷新而仍在后台运行:恢复进度并重新打开弹窗
|
||||
if (this.status?.setup_running) {
|
||||
this.dialogVisible = true;
|
||||
this.startPolling();
|
||||
return;
|
||||
}
|
||||
if (this.shouldAutoPrompt) {
|
||||
this.dialogVisible = true;
|
||||
}
|
||||
},
|
||||
|
||||
async recheck(): Promise<void> {
|
||||
await this.fetchStatus(true);
|
||||
},
|
||||
|
||||
openWizard(): void {
|
||||
this.dialogVisible = true;
|
||||
if (this.progress?.active) this.startPolling();
|
||||
if (!this.status) void this.fetchStatus();
|
||||
},
|
||||
|
||||
/** 「暂不安装」:12 小时内不再自动弹出 */
|
||||
snooze(): void {
|
||||
this.snoozed = true;
|
||||
try {
|
||||
sessionStorage.setItem(SNOOZE_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
this.dialogVisible = false;
|
||||
},
|
||||
|
||||
setNeverAsk(): void {
|
||||
try {
|
||||
localStorage.setItem(NEVER_KEY, '1');
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
resetNeverAsk(): void {
|
||||
try {
|
||||
localStorage.removeItem(NEVER_KEY);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
|
||||
/** 「不再提示」勾选后随暂不安装一起生效;安装中不允许关闭 */
|
||||
closeDialog(never: boolean): void {
|
||||
if (this.progress?.active) return;
|
||||
if (never) this.setNeverAsk();
|
||||
this.snooze();
|
||||
},
|
||||
|
||||
async startSetup(): Promise<void> {
|
||||
if (this.starting || this.progress?.active) return;
|
||||
this.starting = true;
|
||||
try {
|
||||
const resp = await fetch('/api/sandbox/setup', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify({ enable_wsl_if_needed: true })
|
||||
});
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (resp.ok && data?.success) {
|
||||
this.progress = data.data as SetupProgress;
|
||||
this.startPolling();
|
||||
} else {
|
||||
// 启动失败(如并发):以进度对象呈现错误
|
||||
this.progress = {
|
||||
active: false,
|
||||
phase: 'error',
|
||||
step_index: 0,
|
||||
step_total: 6,
|
||||
step_title: '',
|
||||
log_tail: [],
|
||||
download_bytes: null,
|
||||
download_total: null,
|
||||
error: data?.error || 'start failed',
|
||||
error_kind: 'start_failed',
|
||||
updated_at: Date.now() / 1000
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
this.progress = {
|
||||
active: false,
|
||||
phase: 'error',
|
||||
step_index: 0,
|
||||
step_total: 6,
|
||||
step_title: '',
|
||||
log_tail: [],
|
||||
download_bytes: null,
|
||||
download_total: null,
|
||||
error: 'network error',
|
||||
error_kind: 'start_failed',
|
||||
updated_at: Date.now() / 1000
|
||||
};
|
||||
} finally {
|
||||
this.starting = false;
|
||||
}
|
||||
},
|
||||
|
||||
startPolling(): void {
|
||||
if (this.pollTimer) return;
|
||||
void this.pollOnce();
|
||||
this.pollTimer = setInterval(() => void this.pollOnce(), POLL_INTERVAL_MS);
|
||||
},
|
||||
|
||||
stopPolling(): void {
|
||||
if (this.pollTimer) {
|
||||
clearInterval(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
},
|
||||
|
||||
async pollOnce(): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch('/api/sandbox/setup/status', { credentials: 'same-origin' });
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok || !data?.success) return;
|
||||
this.progress = data.data as SetupProgress;
|
||||
if (!this.progress.active) {
|
||||
this.stopPolling();
|
||||
if (this.progress.phase === 'done') {
|
||||
// 安装成功:刷新检测状态(应为 ready)
|
||||
void this.fetchStatus(true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 轮询失败静默,下个周期重试 */
|
||||
}
|
||||
},
|
||||
|
||||
/** 安装失败/需重启后重试:清空进度重新发起 */
|
||||
async retrySetup(): Promise<void> {
|
||||
this.progress = null;
|
||||
await this.startSetup();
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user