From 10eb17f450501f8f7cc162beef885182367184f5 Mon Sep 17 00:00:00 2001 From: EytleBB <190077644+EytleBB@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:22:35 +0800 Subject: [PATCH] fix: release CS-Scout 2.1.1 alpha 2 --- README.md | 4 +- RELEASE_NOTES_v2.1.1-alpha.2.md | 48 ++ THIRD_PARTY_NOTICES.md | 12 +- deploy/README.md | 2 +- server/fivee_monitor.py | 742 ++++++++++++++++++++++++----- server/static/app.js | 62 ++- server/templates/index.html | 11 +- server/tests/test_fivee_monitor.py | 100 ++++ server/tests/test_frontend_js.py | 12 + server/tests/test_web.py | 21 + server/web_server.py | 21 +- windows/Build-Windows-Release.ps1 | 6 +- windows/Install-CS-Scout.ps1 | 2 +- windows/README-PLAYER-ZH.md | 18 +- windows/Verify-Windows-Package.ps1 | 13 + 15 files changed, 917 insertions(+), 157 deletions(-) create mode 100644 RELEASE_NOTES_v2.1.1-alpha.2.md diff --git a/README.md b/README.md index 8d7cc9d..69e2098 100644 --- a/README.md +++ b/README.md @@ -10,8 +10,8 @@ Canvas 将多个回合叠加为同步回放,不在服务器端渲染热力图 ## Windows 玩家下载版 普通玩家不需要部署服务器。请从 -[v2.1.1-alpha.1 预发行页面](https://github.com/EytleBB/CS-Scout/releases/tag/v2.1.1-alpha.1) 下载 -`CS-Scout-Windows-x64-v2.1.1-alpha.1.zip`,完整解压后依次双击: +[v2.1.1-alpha.2 预发行页面](https://github.com/EytleBB/CS-Scout/releases/tag/v2.1.1-alpha.2) 下载 +`CS-Scout-Windows-x64-v2.1.1-alpha.2.zip`,完整解压后依次双击: 1. `windows\Install-CS-Scout.cmd`:创建独立 Python 环境并安装固定版本依赖; 2. `windows\Start-CS-Scout.cmd`:启动仅本机可访问的服务并打开浏览器,无需输入密钥。 diff --git a/RELEASE_NOTES_v2.1.1-alpha.2.md b/RELEASE_NOTES_v2.1.1-alpha.2.md new file mode 100644 index 0000000..d87a4ca --- /dev/null +++ b/RELEASE_NOTES_v2.1.1-alpha.2.md @@ -0,0 +1,48 @@ +# CS-Scout v2.1.1-alpha.2 发布说明 + +这是 2.1.1 的第二个公开测试版,重点修复 Alpha 1 在不同 Windows 电脑上的安装和 5E 自动启动问题。 + +## 本次修复 + +- 修复安装结束时可能误报 `No module named perfectworld_experiment` 的路径检查错误。 +- 5E 安装在任意目录时,CS-Scout 会依次从已运行进程、Windows 安装记录、上次选择位置和常见目录中查找。 +- 找不到 5E 时,页面会直接显示“选择 5E”按钮;选中官方 `5EClient.exe` 后会验证数字签名并记住位置,不再要求打开 PowerShell。 +- 5E 默认监听端口被其他程序占用时,会自动选择新的本机端口。 +- 5E 已经运行但没有自动侦察接口、启动超时或连接失败时,不再无限停留在“正在连接”,页面会显示明确原因和处理方法。 +- 监视线程遇到意外错误时会转入可恢复状态,并把错误显示在页面,不再静默停止。 +- 调整左下角状态文字,使较长的处理提示可以完整换行显示。 + +## Windows 下载与更新 + +下载以下三个资产: + +```text +CS-Scout-Windows-x64-v2.1.1-alpha.2.zip +CS-Scout-Windows-x64-v2.1.1-alpha.2.zip.sha256 +SHA256SUMS.txt +``` + +不要下载 GitHub 自动生成的 `Source code` 压缩包。完整解压 ZIP 到新目录,然后依次双击: + +```text +windows\Install-CS-Scout.cmd +windows\Start-CS-Scout.cmd +``` + +从 Alpha 1 更新时,不要覆盖旧目录。停止旧版后,把 Alpha 2 解压到新目录并重新运行安装程序即可。Demo 缓存和分析结果保存在 `%LOCALAPPDATA%\CS-Scout`,不会因更换程序目录而丢失。 + +## 5E 自动模式 + +建议先正常退出已经运行的 5E,再启动 CS-Scout。选择“5E → 自动”后: + +1. CS-Scout 自动查找并启动 5E; +2. 找不到时,在页面点击“选择 5E”并选中官方 `5EClient.exe`; +3. 登录 5E 并正常匹配;识别到地图和对手后确认分析。 + +这是预发行测试版。5E 或完美平台客户端更新后,自动侦察仍可能需要继续适配;自动模式暂不可用时,可以切换到同平台的“手动”模式继续使用。 + +## 验证 + +- 离线测试覆盖非默认安装位置保存与复用、端口冲突、启动超时、已运行但无监听接口、网页选择器和本地接口权限。 +- Windows 发布包继续执行严格文件白名单、PowerShell 语法、依赖、地图资源、敏感文件和 ZIP 清单检查。 +- 发布资产提供 SHA-256 校验文件。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index f65973e..5c715a2 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -1,12 +1,12 @@ # CS-Scout 第三方声明 -本文件适用于 CS-Scout v2.1.1-alpha.1,用于说明项目与第三方平台、软件和素材之间的关系。它只提供事实性信息,不构成法律意见,也不替代各上游项目的完整许可证或服务条款。 +本文件适用于 CS-Scout v2.1.1-alpha.2,用于说明项目与第三方平台、软件和素材之间的关系。它只提供事实性信息,不构成法律意见,也不替代各上游项目的完整许可证或服务条款。 CS-Scout 项目原创源代码采用仓库根目录 `LICENSE` 中的 MIT License。下述第三方组件、 商标和素材说明不因项目主许可证而被重新授权。 -本声明和 MIT 主许可证针对 v2.1.1-alpha.1 的当前源代码快照与发布包。Git 历史中已经删除的 -第三方文件仍遵循其原先标示的单独条款,不因本次发布而被重新授权,也不属于 v2.1.1-alpha.1 +本声明和 MIT 主许可证针对 v2.1.1-alpha.2 的当前源代码快照与发布包。Git 历史中已经删除的 +第三方文件仍遵循其原先标示的单独条款,不因本次发布而被重新授权,也不属于 v2.1.1-alpha.2 玩家发布包。 ## 非官方关系与商标 @@ -20,7 +20,7 @@ Counter-Strike、Counter-Strike 2、CS2、Steam 及其相关名称和标识的 ## 雷达地图、图标与 Logo -Windows 发布包中的雷达地图通过 Awpy 工具链准备,再由本项目的 `server/setup_maps.py` 转换为运行时所需的地图目录和坐标数据。项目维护者已确认:v2.1.1-alpha.1 公开发布包内所含雷达图、现有 SVG 图标和 Logo 可由本项目公开使用。 +Windows 发布包中的雷达地图通过 Awpy 工具链准备,再由本项目的 `server/setup_maps.py` 转换为运行时所需的地图目录和坐标数据。项目维护者已确认:v2.1.1-alpha.2 公开发布包内所含雷达图、现有 SVG 图标和 Logo 可由本项目公开使用。 这项确认只说明本项目发布这些素材的依据,不表示 Valve、5E、Awpy 或其他第三方对 CS-Scout 作出授权、赞助或背书,也不自动授予发布包接收者将素材单独提取、再许可或用于其他项目的权利。如需在 CS-Scout 之外复用这些素材,请另行确认适用权限。 @@ -30,9 +30,9 @@ Awpy 自身以 MIT License 发布;该软件许可证适用于 Awpy 软件本 ## 直接 Python 依赖 -下表列出 v2.1.1-alpha.1 的直接依赖及其上游声明的许可证。Windows 安装脚本通过 `pip` 下载运行依赖;Linux 部署另外使用 Gunicorn;Awpy 只用于准备地图。请以链接中的上游许可证原文为准。 +下表列出 v2.1.1-alpha.2 的直接依赖及其上游声明的许可证。Windows 安装脚本通过 `pip` 下载运行依赖;Linux 部署另外使用 Gunicorn;Awpy 只用于准备地图。请以链接中的上游许可证原文为准。 -| 组件 | v2.1.1-alpha.1 固定版本 | 用途 | 上游许可证 | 官方链接 | +| 组件 | v2.1.1-alpha.2 固定版本 | 用途 | 上游许可证 | 官方链接 | | --- | ---: | --- | --- | --- | | Flask | 3.1.3 | Web 服务与 API | BSD-3-Clause | | | Requests | 2.34.2 | HTTPS 请求与 Demo 下载 | Apache-2.0 | | diff --git a/deploy/README.md b/deploy/README.md index 28cafc9..fb26c08 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -194,7 +194,7 @@ time, so changing source files during a scan can mix two code versions. 5. Atomically switch `current`, restart, and perform the HTTPS smoke check. ```bash -NEW_RELEASE='/opt/cs-scout/releases/v2.1.1-alpha.1' +NEW_RELEASE='/opt/cs-scout/releases/v2.1.1-alpha.2' sudo ln -s "${NEW_RELEASE}" /opt/cs-scout/current.next sudo mv -Tf /opt/cs-scout/current.next /opt/cs-scout/current sudo systemctl restart cs-scout.service diff --git a/server/fivee_monitor.py b/server/fivee_monitor.py index c0f0b75..3bfe60b 100644 --- a/server/fivee_monitor.py +++ b/server/fivee_monitor.py @@ -16,6 +16,7 @@ import os from pathlib import Path import re +import socket import subprocess import sys import threading @@ -33,11 +34,13 @@ DEFAULT_CDP_PORT = 9222 CDP_RETRY_SECONDS = 2.0 +CLIENT_LAUNCH_TIMEOUT = 30.0 MATCH_RESOLVE_ATTEMPTS = 8 MAX_FRAME_TEXT = 2 * 1024 * 1024 MAX_ENCODED_FRAME = (MAX_FRAME_TEXT * 4 // 3) + 32 MAX_SEEN_MATCHES = 100 MATCH_CANDIDATE_TTL = 10 * 60 +FIVEE_CONFIG_FILENAME = "fivee-install.json" PLATFORM_USER_INFO_URL = "https://platform-api.5eplay.com/api/user/info" _UUID_RE = re.compile( @@ -674,33 +677,212 @@ def active_steam_id() -> str: return "" -def find_5e_executable() -> str: - """Return a validated local 5E executable path, if installed.""" - candidates: list[Path] = [] +def _fivee_config_path() -> Path: + explicit = os.getenv("CS_SCOUT_5E_CONFIG", "").strip() + if explicit: + return Path(explicit).expanduser().resolve() + local_app_data = os.getenv("LOCALAPPDATA", "").strip() + if local_app_data: + return Path(local_app_data) / "CS-Scout" / FIVEE_CONFIG_FILENAME + return Path.home() / "AppData" / "Local" / "CS-Scout" / FIVEE_CONFIG_FILENAME + + +def _load_saved_5e_executable() -> Path | None: + try: + payload = json.loads(_fivee_config_path().read_text(encoding="utf-8")) + except (OSError, ValueError, TypeError): + return None + value = payload.get("executable") if isinstance(payload, dict) else None + if not isinstance(value, str) or not value.strip(): + return None + try: + return Path(value).expanduser().resolve() + except OSError: + return None + + +def _save_5e_executable(path: Path) -> None: + config_path = _fivee_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + temporary = config_path.with_name( + f".{config_path.name}-{os.getpid()}-{threading.get_ident()}.tmp" + ) + try: + temporary.write_text( + json.dumps({"executable": str(path)}, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + os.replace(temporary, config_path) + finally: + try: + temporary.unlink(missing_ok=True) + except OSError: + pass + + +def _running_5e_executable_paths() -> list[Path]: + """Read accessible executable paths for an already running 5E client.""" + if sys.platform != "win32": + return [] + script = ( + "$ErrorActionPreference='SilentlyContinue';" + "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);" + "$v=@(Get-Process -Name 5EClient|ForEach-Object{$_.Path}|Where-Object{$_});" + "ConvertTo-Json -Compress -InputObject $v" + ) + try: + completed = subprocess.run( + [ + "powershell.exe", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-Command", script, + ], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=8, + check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + values = json.loads(completed.stdout.strip() or "[]") + except (OSError, subprocess.SubprocessError, json.JSONDecodeError): + return [] + if isinstance(values, str): + values = [values] + return [Path(value) for value in values if isinstance(value, str) and value.strip()] + + +def _display_icon_path(value: object) -> Path | None: + text = str(value or "").strip() + if not text: + return None + if text.startswith('"') and '"' in text[1:]: + text = text[1:text.find('"', 1)] + else: + text = text.rsplit(",", 1)[0].strip() + return Path(os.path.expandvars(text)) if text.casefold().endswith(".exe") else None + + +def _registry_5e_executable_paths() -> list[Path]: + """Read App Paths and uninstall records without scanning whole drives.""" + if sys.platform != "win32": + return [] + try: + import winreg + except ImportError: + return [] + + result: list[Path] = [] + views = [0] + for name in ("KEY_WOW64_64KEY", "KEY_WOW64_32KEY"): + value = int(getattr(winreg, name, 0)) + if value and value not in views: + views.append(value) + roots = [winreg.HKEY_CURRENT_USER, winreg.HKEY_LOCAL_MACHINE] + + for root in roots: + for view in views: + try: + with winreg.OpenKey( + root, + r"Software\Microsoft\Windows\CurrentVersion\App Paths\5EClient.exe", + 0, + winreg.KEY_READ | view, + ) as key: + result.append(Path(str(winreg.QueryValueEx(key, None)[0]))) + except OSError: + pass + + uninstall = r"Software\Microsoft\Windows\CurrentVersion\Uninstall" + try: + parent = winreg.OpenKey(root, uninstall, 0, winreg.KEY_READ | view) + except OSError: + continue + with parent: + index = 0 + while index < 2048: + try: + child_name = winreg.EnumKey(parent, index) + except OSError: + break + index += 1 + try: + with winreg.OpenKey(parent, child_name) as child: + display_name = str(winreg.QueryValueEx(child, "DisplayName")[0]) + if "5e" not in display_name.casefold(): + continue + try: + location = str(winreg.QueryValueEx(child, "InstallLocation")[0]).strip() + except OSError: + location = "" + if location: + result.append(Path(location) / "5EClient.exe") + try: + icon = _display_icon_path(winreg.QueryValueEx(child, "DisplayIcon")[0]) + except OSError: + icon = None + if icon is not None: + result.append(icon) + except OSError: + continue + return result + + +def _fivee_executable_candidates() -> Iterable[tuple[Path, str]]: explicit = os.getenv("CS_SCOUT_5E_EXE", "").strip() if explicit: - candidates.append(Path(os.path.expandvars(os.path.expanduser(explicit)))) + yield Path(os.path.expandvars(os.path.expanduser(explicit))), "environment" + saved = _load_saved_5e_executable() + if saved is not None: + yield saved, "saved" + for path in _running_5e_executable_paths(): + yield path, "running_process" + for path in _registry_5e_executable_paths(): + yield path, "registry" for env_name in ("PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"): root = os.getenv(env_name, "").strip() - if root: - candidates.extend(( - Path(root) / "5EClient" / "5EClient.exe", - Path(root) / "5EPlay" / "5EClient.exe", - )) - candidates.append(Path(r"C:\Program Files\5EClient\5EClient.exe")) + if not root: + continue + for relative in ( + ("5EClient", "5EClient.exe"), + ("5EPlay", "5EClient.exe"), + ("5E对战平台", "5EClient.exe"), + ("5EClient.exe",), + ): + yield Path(root).joinpath(*relative), "default" + yield Path(r"C:\Program Files\5EClient\5EClient.exe"), "default" + + +def locate_5e_executable() -> dict[str, object]: seen: set[str] = set() - for candidate in candidates: + for candidate, source in _fivee_executable_candidates(): try: - resolved = str(candidate.resolve(strict=True)) + resolved = candidate.expanduser().resolve(strict=True) except (OSError, RuntimeError): continue - key = resolved.casefold() + key = str(resolved).casefold() if key in seen: continue seen.add(key) - if Path(resolved).is_file() and Path(resolved).name.casefold() == "5eclient.exe": - return resolved - return "" + if resolved.is_file() and resolved.name.casefold() == "5eclient.exe": + return { + "found": True, + "path": str(resolved), + "source": source, + "message": "已找到 5E 客户端", + } + return { + "found": False, + "path": "", + "source": "", + "message": "未找到 5E 客户端,请选择 5EClient.exe", + } + + +def find_5e_executable() -> str: + """Return a local 5E executable from portable discovery sources.""" + result = locate_5e_executable() + return str(result["path"]) if result["found"] else "" def _fivee_running_via_powershell() -> bool | None: @@ -874,6 +1056,103 @@ def _official_5e_signature(executable: str) -> bool: ) +def _show_5e_executable_picker() -> Path | None: + if sys.platform != "win32": + return None + script = ( + "$OutputEncoding=[Text.UTF8Encoding]::new($false);" + "[Console]::OutputEncoding=[Text.UTF8Encoding]::new($false);" + "Add-Type -AssemblyName System.Windows.Forms;" + "$owner=New-Object System.Windows.Forms.Form;" + "$owner.TopMost=$true;$owner.ShowInTaskbar=$false;$owner.Opacity=0;" + "$dialog=New-Object System.Windows.Forms.OpenFileDialog;" + "$dialog.Title='选择 5EClient.exe';" + "$dialog.Filter='5E 客户端 (5EClient.exe)|5EClient.exe';" + "$dialog.CheckFileExists=$true;$dialog.Multiselect=$false;" + "$result=$dialog.ShowDialog($owner);$owner.Dispose();" + "if($result -eq [System.Windows.Forms.DialogResult]::OK){" + "[Console]::Out.Write($dialog.FileName)}" + ) + try: + completed = subprocess.run( + ["powershell.exe", "-NoProfile", "-STA", "-Command", script], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + timeout=300, + check=False, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), + ) + except (OSError, subprocess.SubprocessError): + return None + value = completed.stdout.strip() + return Path(value).resolve() if completed.returncode == 0 and value else None + + +def configure_5e_executable(selected: str | os.PathLike[str]) -> dict[str, object]: + try: + path = Path(selected).expanduser().resolve(strict=True) + except (OSError, RuntimeError): + return { + "ready": False, + "code": "invalid_path", + "message": "选择的 5E 程序不存在", + "path": "", + } + if not path.is_file() or path.name.casefold() != "5eclient.exe": + return { + "ready": False, + "code": "wrong_file", + "message": "请选择官方 5EClient.exe", + "path": str(path), + } + if not _official_5e_signature(str(path)): + return { + "ready": False, + "code": "invalid_signature", + "message": "所选程序未通过 5E 官方签名验证", + "path": str(path), + } + _save_5e_executable(path) + return { + "ready": True, + "code": "ready", + "message": "已记住 5E 客户端位置", + "path": str(path), + } + + +def choose_5e_executable() -> dict[str, object]: + selected = _show_5e_executable_picker() + if selected is None: + located = locate_5e_executable() + return {"selected": False, "cancelled": True, "executable": located} + result = configure_5e_executable(selected) + return { + "selected": bool(result["ready"]), + "cancelled": False, + "executable": result, + } + + +def _loopback_port_in_use(port: int) -> bool: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: + probe.settimeout(0.25) + return probe.connect_ex(("127.0.0.1", int(port))) == 0 + except (OSError, TypeError, ValueError): + return False + + +def _free_loopback_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as listener: + listener.bind(("127.0.0.1", 0)) + return int(listener.getsockname()[1]) + + def launch_5e_with_cdp(executable: str, port: int) -> None: """Start (never restart) the official client with loopback-only CDP.""" path = Path(executable).resolve(strict=True) @@ -1037,8 +1316,11 @@ def __init__( self._next_match_retry = 0.0 self._targets_first_seen = 0.0 self._launched_by_scout = False + self._launch_started_at = 0.0 self._last_launch_failed = False self._next_launch_attempt = 0.0 + self._client_was_running = False + self._last_cdp_owned = False self._candidate_teams: dict[str, list[dict]] = {} self._state = { "platform": "fivee", @@ -1048,6 +1330,12 @@ def __init__( "manual_fallback": False, "client_running": False, "launched_by_scout": False, + "connection_code": "initializing", + "last_error": "", + "needs_executable": False, + "executable_found": False, + "executable_path": "", + "cdp_listening": False, "cdp_port": self.cdp_port, "max_demos": self.max_demos, "mode": self.mode, @@ -1067,7 +1355,11 @@ def __init__( def snapshot(self) -> dict[str, object]: with self._lock: - return deepcopy(self._state) + snapshot = deepcopy(self._state) + snapshot["monitor_running"] = bool( + self._thread and self._thread.is_alive() + ) + return snapshot def _update(self, **changes) -> None: with self._lock: @@ -1108,8 +1400,46 @@ def configure(self, *, max_demos: int, mode: str) -> dict[str, object]: self._state["max_demos"] = max_demos self._state["mode"] = mode self._state["updated_at"] = time.time() + if self._state.get("phase") in {"manual", "connecting"}: + self._last_launch_failed = False + self._next_launch_attempt = 0.0 + if not self._state.get("client_running"): + self._launched_by_scout = False + self._launch_started_at = 0.0 return {"max_demos": self.max_demos, "mode": self.mode, "busy": busy} + def choose_executable(self) -> dict[str, object]: + selected = choose_5e_executable() + executable = selected.get("executable", {}) + if selected.get("selected") and executable.get("ready"): + with self._lock: + self._launched_by_scout = False + self._launch_started_at = 0.0 + self._last_launch_failed = False + self._next_launch_attempt = 0.0 + self._state.update({ + "phase": "connecting", + "message": "已选择 5E,正在准备自动启动…", + "connection_code": "executable_selected", + "last_error": "", + "needs_executable": False, + "executable_found": True, + "executable_path": str(executable.get("path") or ""), + "manual_fallback": False, + "updated_at": time.time(), + }) + elif not selected.get("cancelled"): + self._connection_state( + phase="manual", + message=str(executable.get("message") or "所选 5E 程序不可用"), + connection_code=str(executable.get("code") or "invalid_executable"), + last_error=str(executable.get("message") or "invalid executable"), + needs_executable=True, + executable_found=False, + manual_fallback=True, + ) + return selected + def select_own_team(self, team_id: str) -> dict[str, object]: with self._lock: accepted = ( @@ -1204,6 +1534,8 @@ def _websocket(self): return self._websocket_module def _cdp_targets(self) -> list[dict]: + with self._lock: + self._last_cdp_owned = False with self._http_lock: response = self._http.get( f"http://127.0.0.1:{self.cdp_port}/json/list", @@ -1212,6 +1544,8 @@ def _cdp_targets(self) -> list[dict]: response.raise_for_status() if not cdp_listener_is_5e(self.cdp_port): raise RuntimeError("CDP listener is not owned by 5EClient.exe") + with self._lock: + self._last_cdp_owned = True targets = response.json() if not isinstance(targets, list): raise RuntimeError("CDP target list is not an array") @@ -1232,6 +1566,11 @@ def _cdp_targets(self) -> list[dict]: def _connection_state(self, *, phase: str, message: str, **changes) -> None: with self._lock: current_phase = self._state.get("phase") + previous = ( + str(self._state.get("phase") or ""), + str(self._state.get("connection_code") or ""), + str(self._state.get("message") or ""), + ) protected = current_phase in { "detected", "awaiting_team_selection", "awaiting_confirmation", "queued", "analyzing", "ready", @@ -1249,129 +1588,274 @@ def _connection_state(self, *, phase: str, message: str, **changes) -> None: "team_options": [], }) self._state["updated_at"] = time.time() + current = ( + str(self._state.get("phase") or ""), + str(self._state.get("connection_code") or ""), + str(self._state.get("message") or ""), + ) + if current != previous: + log.info("5E state [%s]: %s", current[1] or current[0], current[2]) def _supervisor_loop(self) -> None: while not self._stop.is_set(): try: - self._websocket() - except ImportError: + self._supervisor_iteration() + self._prune_target_workers() + self._expire_stale_candidate() + self._retry_pending_match() + except Exception as exc: + log.exception("5E automatic scout supervisor failed") self._connection_state( phase="manual", - message="5E 自动监听组件未安装,可暂时手动输入用户名", + message="5E 自动侦察发生异常,请选择程序或切换手动模式", + connection_code="monitor_error", + last_error=type(exc).__name__, auto_available=False, manual_fallback=True, ) - self._stop.wait(10) - continue + self._stop.wait(CDP_RETRY_SECONDS) - try: - targets = self._cdp_targets() - except Exception: - targets = [] + def _supervisor_iteration(self) -> None: + try: + self._websocket() + except ImportError: + self._connection_state( + phase="manual", + message="5E 自动监听组件未安装,请切换手动模式", + connection_code="websocket_missing", + last_error="websocket-client is not installed", + auto_available=False, + manual_fallback=True, + ) + return - if targets: - now = time.monotonic() - if not self._targets_first_seen: - self._targets_first_seen = now - self._start_target_workers(targets) - with self._lock: - connected = bool(self._connected_target_ids) - if not connected: - elapsed = now - self._targets_first_seen - self._connection_state( - phase="manual" if elapsed >= 10 else "connecting", - message=( - "无法建立 5E 自动侦察连接,可暂时手动输入用户名" - if elapsed >= 10 else "正在建立 5E 自动侦察连接…" - ), - auto_available=False, - manual_fallback=elapsed >= 10, - client_running=True, - launched_by_scout=self._launched_by_scout, - ) + try: + targets = self._cdp_targets() + except Exception: + with self._lock: + self._last_cdp_owned = False + targets = [] + + if targets: + now = time.monotonic() + self._client_was_running = True + if not self._targets_first_seen: + self._targets_first_seen = now + self._start_target_workers(targets) + with self._lock: + connected = bool(self._connected_target_ids) + if not connected: + elapsed = now - self._targets_first_seen + self._connection_state( + phase="manual" if elapsed >= 10 else "connecting", + message=( + "5E 页面连接失败,请重新打开 5E 或切换手动模式" + if elapsed >= 10 else "已找到 5E 页面,正在建立监听…" + ), + connection_code=( + "websocket_failed" if elapsed >= 10 else "websocket_connecting" + ), + last_error="CDP WebSocket handshake failed" if elapsed >= 10 else "", + auto_available=False, + manual_fallback=elapsed >= 10, + client_running=True, + cdp_listening=True, + launched_by_scout=self._launched_by_scout, + ) + return + + self._targets_first_seen = 0.0 + with self._lock: + listener_owned = self._last_cdp_owned + if listener_owned: + self._client_was_running = True + self._connection_state( + phase="connecting", + message="已连接 5E,等待平台页面…", + connection_code="listener_waiting_page", + last_error="", + auto_available=False, + manual_fallback=False, + client_running=True, + cdp_listening=True, + needs_executable=False, + launched_by_scout=self._launched_by_scout, + ) + return + + running = is_5e_running() + now = time.monotonic() + if running is True: + self._client_was_running = True + elapsed = now - self._launch_started_at if self._launch_started_at else 0.0 + if self._launched_by_scout and elapsed < CLIENT_LAUNCH_TIMEOUT: + self._connection_state( + phase="connecting", + message="5E 已启动,正在等待自动侦察端口…", + connection_code="waiting_for_cdp", + last_error="", + auto_available=False, + manual_fallback=False, + client_running=True, + cdp_listening=False, + launched_by_scout=True, + ) else: - self._targets_first_seen = 0.0 - running = is_5e_running() - now = time.monotonic() - if running is False and self.auto_launch: - executable = find_5e_executable() - if executable and now >= self._next_launch_attempt: - self._next_launch_attempt = now + 120 - try: - launch_5e_with_cdp(executable, self.cdp_port) - self._launched_by_scout = True - self._last_launch_failed = False - self._connection_state( - phase="connecting", - message="正在启动 5E 并连接自动侦察;如出现 Windows 确认,请允许", - auto_available=False, - manual_fallback=False, - client_running=True, - launched_by_scout=True, - ) - except Exception: - self._last_launch_failed = True - log.exception("Could not launch 5E with loopback CDP") - self._connection_state( - phase="manual", - message="无法自动启动 5E,可暂时手动输入用户名", - auto_available=False, - manual_fallback=True, - client_running=False, - ) - elif not executable: - self._connection_state( - phase="manual", - message="未找到 5E 客户端,可暂时手动输入用户名", - auto_available=False, - manual_fallback=True, - client_running=False, - ) - elif self._last_launch_failed: - self._connection_state( - phase="manual", - message="Windows 未能启动 5E 自动侦察,可暂时手动输入用户名", - auto_available=False, - manual_fallback=True, - client_running=False, - ) - else: - self._connection_state( - phase="connecting", - message="正在等待 5E 自动侦察端口…", - auto_available=False, - manual_fallback=False, - client_running=False, - ) - elif running is True: - self._connection_state( - phase="manual", - message="5E 已运行但未开放自动侦察;退出 5E 后,CS-Scout 会自动重新启动它。当前可手动输入用户名", - auto_available=False, - manual_fallback=True, - client_running=True, - ) - elif running is False: - self._connection_state( - phase="manual", - message="当前系统不支持自动启动 5E,可手动输入用户名", - auto_available=False, - manual_fallback=True, - client_running=False, - ) - else: - self._connection_state( - phase="manual", - message="无法确认 5E 是否正在运行,可暂时手动输入用户名", - auto_available=False, - manual_fallback=True, - client_running=False, - ) + self._last_launch_failed = bool(self._launched_by_scout) + self._connection_state( + phase="manual", + message=( + "5E 已启动但未开放自动侦察,请完全退出 5E;CS-Scout 随后会自动启动它" + ), + connection_code="client_running_without_cdp", + last_error="5E is running without a compatible CDP listener", + auto_available=False, + manual_fallback=True, + client_running=True, + cdp_listening=False, + launched_by_scout=self._launched_by_scout, + ) + return - self._prune_target_workers() - self._expire_stale_candidate() - self._retry_pending_match() - self._stop.wait(CDP_RETRY_SECONDS) + if running is None: + self._connection_state( + phase="manual", + message="无法确认 5E 是否正在运行,请选择程序或切换手动模式", + connection_code="process_check_failed", + last_error="Could not inspect 5E process state", + auto_available=False, + manual_fallback=True, + client_running=False, + cdp_listening=False, + ) + return + + if self._client_was_running: + previously_launched = self._launched_by_scout + self._client_was_running = False + self._launch_started_at = 0.0 + self._launched_by_scout = False + self._next_launch_attempt = 0.0 + if previously_launched: + self._last_launch_failed = True + + if not self.auto_launch: + self._connection_state( + phase="manual", + message="当前设置未启用 5E 自动启动,请切换手动模式", + connection_code="auto_launch_disabled", + last_error="", + auto_available=False, + manual_fallback=True, + client_running=False, + cdp_listening=False, + ) + return + + if self._last_launch_failed: + self._connection_state( + phase="manual", + message="上次未能启动 5E,请重新选择程序或再次进入自动模式", + connection_code="previous_launch_failed", + last_error="Previous 5E launch failed", + auto_available=False, + manual_fallback=True, + client_running=False, + cdp_listening=False, + needs_executable=True, + ) + return + + if self._launched_by_scout and self._launch_started_at: + elapsed = now - self._launch_started_at + if elapsed < CLIENT_LAUNCH_TIMEOUT: + self._connection_state( + phase="connecting", + message="正在启动 5E,请确认 Windows 权限提示…", + connection_code="launching", + last_error="", + auto_available=False, + manual_fallback=False, + client_running=False, + cdp_listening=False, + launched_by_scout=True, + ) + else: + self._last_launch_failed = True + self._connection_state( + phase="manual", + message="5E 在 30 秒内未能启动,请重新选择程序或切换手动模式", + connection_code="launch_timeout", + last_error="5E did not start within 30 seconds", + auto_available=False, + manual_fallback=True, + client_running=False, + cdp_listening=False, + ) + return + + located = locate_5e_executable() + if not located["found"]: + self._connection_state( + phase="manual", + message=str(located["message"]), + connection_code="executable_not_found", + last_error="5EClient.exe was not found", + auto_available=False, + manual_fallback=True, + client_running=False, + cdp_listening=False, + needs_executable=True, + executable_found=False, + executable_path="", + ) + return + + executable = str(located["path"]) + if _loopback_port_in_use(self.cdp_port) and not cdp_listener_is_5e(self.cdp_port): + previous_port = self.cdp_port + self.cdp_port = _free_loopback_port() + log.info("5E CDP port %s was occupied; selected %s", previous_port, self.cdp_port) + + try: + launch_5e_with_cdp(executable, self.cdp_port) + except Exception as exc: + self._last_launch_failed = True + log.exception("Could not launch 5E with loopback CDP") + self._connection_state( + phase="manual", + message="无法启动 5E,请重新选择程序或切换手动模式", + connection_code="launch_failed", + last_error=type(exc).__name__, + auto_available=False, + manual_fallback=True, + client_running=False, + cdp_listening=False, + needs_executable=True, + executable_found=True, + executable_path=executable, + ) + return + + self._launched_by_scout = True + self._launch_started_at = now + self._last_launch_failed = False + self._next_launch_attempt = now + CLIENT_LAUNCH_TIMEOUT + self._connection_state( + phase="connecting", + message="正在启动 5E,请确认 Windows 权限提示…", + connection_code="launching", + last_error="", + auto_available=False, + manual_fallback=False, + client_running=False, + cdp_listening=False, + launched_by_scout=True, + needs_executable=False, + executable_found=True, + executable_path=executable, + cdp_port=self.cdp_port, + ) def _start_target_workers(self, targets: list[dict]) -> None: with self._lock: @@ -1445,9 +1929,13 @@ def handle_message(message: object) -> None: self._connection_state( phase="waiting", message="已连接 5E,等待匹配到对局…", + connection_code="connected", + last_error="", auto_available=True, manual_fallback=False, client_running=True, + cdp_listening=True, + needs_executable=False, launched_by_scout=self._launched_by_scout, ) while not self._stop.is_set(): diff --git a/server/static/app.js b/server/static/app.js index b4c939e..83e3a97 100644 --- a/server/static/app.js +++ b/server/static/app.js @@ -29,6 +29,8 @@ let pwaSignerMessage = "正在检测组件…"; let fiveECanAnalyze = false; let fiveEManualFallback = false; let fiveENeedsMap = false; +let fiveENeedsExecutable = false; +let fiveEExecutableMessage = "未找到 5E 客户端"; let fiveEUseAnalysisStatus = false; let manualUsernames = { "5e": ["", "", "", "", ""], @@ -146,6 +148,8 @@ function setAnalysisBusy(busy, options = {}) { } const pwaDirectoryButton = $("#pwa-select-directory"); if (pwaDirectoryButton) pwaDirectoryButton.disabled = disabled; + const fiveEExecutableButton = $("#fivee-select-executable"); + if (fiveEExecutableButton) fiveEExecutableButton.disabled = disabled; const depth = $("#depth"); if (depth) depth.disabled = disabled; } @@ -177,6 +181,16 @@ function updatePlatformControls() { pwaDirectoryButton.hidden = pwaSignerReady; pwaDirectoryButton.disabled = analysisBusy; } + const fiveEComponent = $("#fivee-component"); + const fiveEComponentStatus = $("#fivee-component-status"); + const fiveEExecutableButton = $("#fivee-select-executable"); + if (fiveEComponent) { + fiveEComponent.hidden = !automaticFiveE || !fiveENeedsExecutable; + } + if (fiveEComponentStatus) { + fiveEComponentStatus.textContent = fiveEExecutableMessage; + } + if (fiveEExecutableButton) fiveEExecutableButton.disabled = analysisBusy; const mapSelect = $("#map"); if (mapSelect) { mapSelect.disabled = availableMapNames.length === 0 || @@ -204,6 +218,11 @@ function updatePlatformControls() { function shortPlatformStatus(status) { const phase = String(status && status.phase || ""); + const message = String(status && status.message || ""); + if (status && status.platform === "fivee" && + ["connecting", "manual"].includes(phase) && message) { + return message; + } const labels = { connecting: "正在连接…", waiting: "等待对局", @@ -216,7 +235,7 @@ function shortPlatformStatus(status) { ready: "分析完成", manual: "输入对手用户名", }; - return labels[phase] || String(status && status.message || ""); + return labels[phase] || message; } function showAutomaticTargets(targets) { @@ -289,6 +308,37 @@ async function selectPerfectWorldDirectory() { } } +async function selectFiveEExecutable() { + if (analysisBusy) return; + const button = $("#fivee-select-executable"); + if (button) button.disabled = true; + setStatus("请选择 5EClient.exe…"); + try { + const result = await requestJSON("/api/5e/exe/select", { + method: "POST", + headers: { "X-CS-Scout-Request": "1" } + }); + const executable = result && result.executable && + typeof result.executable === "object" ? result.executable : null; + if (executable) { + fiveENeedsExecutable = !Boolean(executable.ready || executable.found); + fiveEExecutableMessage = String( + executable.message || "未找到 5E 客户端" + ); + } + updatePlatformControls(); + if (!result.cancelled) setStatus(fiveEExecutableMessage); + } catch (error) { + fiveENeedsExecutable = true; + fiveEExecutableMessage = `选择失败:${error.message}`; + updatePlatformControls(); + setStatus(fiveEExecutableMessage); + } finally { + if (button) button.disabled = analysisBusy; + await poll(pollEpoch); + } +} + function renderFiveETeamOptions(options) { const container = $("#fivee-team-choice"); if (!container) return; @@ -570,6 +620,7 @@ function wireControls() { const platformButtons = document.querySelectorAll("[data-platform]"); const scoutModeButtons = document.querySelectorAll("[data-scout-mode]"); const pwaDirectoryButton = $("#pwa-select-directory"); + const fiveEExecutableButton = $("#fivee-select-executable"); if (playPause) { playPause.addEventListener("click", () => { clock.playing = !clock.playing; @@ -607,6 +658,11 @@ function wireControls() { void selectPerfectWorldDirectory(); }); } + if (fiveEExecutableButton) { + fiveEExecutableButton.addEventListener("click", () => { + void selectFiveEExecutable(); + }); + } setPlaybackSpeed(clock.speed); setAnalysisMode(analysisMode); updatePlatformControls(); @@ -1216,6 +1272,8 @@ async function poll(epoch = pollEpoch) { } } else if (localFiveE) { fiveEManualFallback = automaticMode && Boolean(status.manual_fallback); + fiveENeedsExecutable = automaticMode && Boolean(status.needs_executable); + fiveEExecutableMessage = String(status.message || "未找到 5E 客户端"); fiveENeedsMap = automaticMode && Boolean(status.needs_map); fiveECanAnalyze = automaticMode && status.phase === "awaiting_confirmation"; @@ -1341,7 +1399,7 @@ if (typeof module !== "undefined") { runAnalysis, cancelAnalysis, connectWithEnteredKey, setPlatform, setScoutMode, updatePlatformControls, showPerfectWorldTargets, showAutomaticTargets, runPerfectWorldAnalysis, - selectPerfectWorldDirectory, + selectPerfectWorldDirectory, selectFiveEExecutable, runFiveEAutomaticAnalysis, renderFiveETeamOptions, progressForStep, progressFromMessage, renderAnalysisProgress, }; diff --git a/server/templates/index.html b/server/templates/index.html index ceb3a29..551c62d 100644 --- a/server/templates/index.html +++ b/server/templates/index.html @@ -420,14 +420,13 @@ } .progress-panel[data-state="error"] #progress-fill { background: var(--danger); } #status { - min-height: 17px; + min-height: 32px; margin-top: 8px; - overflow: hidden; color: var(--text-color-secondary); font-size: 11px; line-height: 1.45; - text-overflow: ellipsis; - white-space: nowrap; + overflow-wrap: anywhere; + white-space: normal; } #failure-details { margin-top: 7px; @@ -714,6 +713,10 @@ 正在检测组件… + {% endif %}
diff --git a/server/tests/test_fivee_monitor.py b/server/tests/test_fivee_monitor.py index 7670bea..f6f5600 100644 --- a/server/tests/test_fivee_monitor.py +++ b/server/tests/test_fivee_monitor.py @@ -308,6 +308,106 @@ def test_unknown_process_state_does_not_claim_fivee_is_stopped(monkeypatch): assert fivee_monitor.is_5e_running() is None +def test_custom_executable_is_validated_saved_and_discovered(monkeypatch, tmp_path): + executable = tmp_path / "Custom 5E" / "5EClient.exe" + executable.parent.mkdir() + executable.write_bytes(b"signed test executable") + config_path = tmp_path / "data" / "fivee-install.json" + monkeypatch.setenv("CS_SCOUT_5E_CONFIG", str(config_path)) + monkeypatch.setattr(fivee_monitor, "_official_5e_signature", lambda _path: True) + + configured = fivee_monitor.configure_5e_executable(executable) + located = fivee_monitor.locate_5e_executable() + + assert configured["ready"] is True + assert json.loads(config_path.read_text(encoding="utf-8"))["executable"] == str( + executable.resolve() + ) + assert located["found"] is True + assert located["source"] == "saved" + assert located["path"] == str(executable.resolve()) + + +def test_custom_executable_rejects_wrong_file_or_signature(monkeypatch, tmp_path): + wrong_name = tmp_path / "client.exe" + wrong_name.write_bytes(b"not 5e") + assert fivee_monitor.configure_5e_executable(wrong_name)["code"] == "wrong_file" + + executable = tmp_path / "5EClient.exe" + executable.write_bytes(b"not signed") + monkeypatch.setattr(fivee_monitor, "_official_5e_signature", lambda _path: False) + assert fivee_monitor.configure_5e_executable(executable)["code"] == "invalid_signature" + + +def test_supervisor_launches_discovered_non_default_client(monkeypatch): + launched = [] + service = fivee_monitor.FiveEAutoScoutService( + auto_launch=True, cdp_port=9222, websocket_module=object() + ) + monkeypatch.setattr(service, "_cdp_targets", lambda: []) + monkeypatch.setattr(fivee_monitor, "is_5e_running", lambda: False) + monkeypatch.setattr(fivee_monitor, "locate_5e_executable", lambda: { + "found": True, + "path": r"D:\\Games\\5E\\5EClient.exe", + "source": "saved", + "message": "ready", + }) + monkeypatch.setattr(fivee_monitor, "_loopback_port_in_use", lambda _port: False) + monkeypatch.setattr( + fivee_monitor, "launch_5e_with_cdp", + lambda executable, port: launched.append((executable, port)), + ) + + service._supervisor_iteration() + + assert launched == [(r"D:\\Games\\5E\\5EClient.exe", 9222)] + snapshot = service.snapshot() + assert snapshot["phase"] == "connecting" + assert snapshot["connection_code"] == "launching" + assert snapshot["executable_found"] is True + + +def test_supervisor_chooses_free_port_when_default_is_occupied(monkeypatch): + launched = [] + service = fivee_monitor.FiveEAutoScoutService( + auto_launch=True, cdp_port=9222, websocket_module=object() + ) + monkeypatch.setattr(service, "_cdp_targets", lambda: []) + monkeypatch.setattr(fivee_monitor, "is_5e_running", lambda: False) + monkeypatch.setattr(fivee_monitor, "locate_5e_executable", lambda: { + "found": True, "path": r"E:\\5E\\5EClient.exe", "source": "saved", + "message": "ready", + }) + monkeypatch.setattr(fivee_monitor, "_loopback_port_in_use", lambda _port: True) + monkeypatch.setattr(fivee_monitor, "cdp_listener_is_5e", lambda _port: False) + monkeypatch.setattr(fivee_monitor, "_free_loopback_port", lambda: 19333) + monkeypatch.setattr( + fivee_monitor, "launch_5e_with_cdp", + lambda executable, port: launched.append((executable, port)), + ) + + service._supervisor_iteration() + + assert launched == [(r"E:\\5E\\5EClient.exe", 19333)] + assert service.snapshot()["cdp_port"] == 19333 + + +def test_running_client_without_cdp_stops_connecting_forever(monkeypatch): + service = fivee_monitor.FiveEAutoScoutService( + auto_launch=True, websocket_module=object() + ) + monkeypatch.setattr(service, "_cdp_targets", lambda: []) + monkeypatch.setattr(fivee_monitor, "is_5e_running", lambda: True) + + service._supervisor_iteration() + + snapshot = service.snapshot() + assert snapshot["phase"] == "manual" + assert snapshot["connection_code"] == "client_running_without_cdp" + assert snapshot["manual_fallback"] is True + assert "CDP" in snapshot["last_error"] + + @pytest.mark.parametrize("success", [False, True]) def test_completed_analysis_keeps_confirmed_roster_for_retry(success): service = fivee_monitor.FiveEAutoScoutService( diff --git a/server/tests/test_frontend_js.py b/server/tests/test_frontend_js.py index 1850eb5..9ec4e9c 100644 --- a/server/tests/test_frontend_js.py +++ b/server/tests/test_frontend_js.py @@ -9,9 +9,21 @@ NODE = shutil.which("node") REPLAY_JS = os.path.join(os.path.dirname(__file__), "..", "static", "replay.js") APP_JS = os.path.join(os.path.dirname(__file__), "..", "static", "app.js") +INDEX_HTML = os.path.join(os.path.dirname(__file__), "..", "templates", "index.html") pytestmark = pytest.mark.skipif(NODE is None, reason="Node.js is not installed") +def test_fivee_non_default_install_has_visible_graphical_picker(): + with open(APP_JS, encoding="utf-8") as source: + app_source = source.read() + with open(INDEX_HTML, encoding="utf-8") as source: + html_source = source.read() + + assert 'id="fivee-select-executable"' in html_source + assert "/api/5e/exe/select" in app_source + assert 'headers: { "X-CS-Scout-Request": "1" }' in app_source + + def test_replay_player_runtime_contract(): script = f""" global.Image = class FakeImage {{ diff --git a/server/tests/test_web.py b/server/tests/test_web.py index 84b619d..bbf23ff 100644 --- a/server/tests/test_web.py +++ b/server/tests/test_web.py @@ -720,6 +720,7 @@ def __init__(self): self.started = 0 self.phase = "awaiting_confirmation" self.analysis_started = 0 + self.executable_selections = 0 def start(self): self.started += 1 @@ -727,6 +728,17 @@ def start(self): def configure(self, *, max_demos, mode): return {"max_demos": max_demos, "mode": mode, "busy": False} + def choose_executable(self): + self.executable_selections += 1 + return { + "selected": True, + "cancelled": False, + "executable": { + "ready": True, + "path": r"D:\Games\5E\5EClient.exe", + }, + } + def snapshot(self): return { "platform": "fivee", @@ -778,6 +790,15 @@ def mark_analysis_start_failed(self): assert status.get_json()["platform"] == "fivee" assert status.headers["Cache-Control"] == "no-store" + missing_marker = client.post("/api/5e/exe/select") + assert missing_marker.status_code == 403 + executable = client.post( + "/api/5e/exe/select", headers={"X-CS-Scout-Request": "1"} + ) + assert executable.status_code == 200 + assert executable.get_json()["selected"] is True + assert service.executable_selections == 1 + selected = client.post("/api/5e/team", json={"team": "t1"}) assert selected.status_code == 200 diff --git a/server/web_server.py b/server/web_server.py index e441b55..54d5348 100644 --- a/server/web_server.py +++ b/server/web_server.py @@ -54,7 +54,8 @@ "/api/pwa/status", "/api/pwa/config", "/api/pwa/analyze", "/api/pwa/manual/analyze", "/api/pwa/dll/select", - "/api/5e/status", "/api/5e/config", "/api/5e/team", "/api/5e/analyze", + "/api/5e/status", "/api/5e/config", "/api/5e/exe/select", + "/api/5e/team", "/api/5e/analyze", }) CACHE_CONTROL_PREFIXES = ("/api/player/", "/api/pwa/player/", "/output/") LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost"}) @@ -498,6 +499,24 @@ def api_fivee_status(): return jsonify(snapshot) +@app.route("/api/5e/exe/select", methods=["POST"]) +def api_fivee_select_executable(): + if not _pwa_request_is_local(): + abort(404) + if request.headers.get("X-CS-Scout-Request") != "1": + return jsonify({"error": "Missing local request marker"}), 403 + with state_lock: + busy, _owner_platform = _analysis_activity_locked() + if busy: + return jsonify({"error": "Analysis already running"}), 409 + service = _get_fivee_service(start=False) + selected = service.choose_executable() + service.start() + if not selected.get("cancelled") and not selected.get("selected"): + return jsonify(selected), 409 + return jsonify(selected) + + @app.route("/api/5e/team", methods=["POST"]) def api_fivee_team(): if not _pwa_request_is_local(): diff --git a/windows/Build-Windows-Release.ps1 b/windows/Build-Windows-Release.ps1 index ca5b799..90efbc2 100644 --- a/windows/Build-Windows-Release.ps1 +++ b/windows/Build-Windows-Release.ps1 @@ -2,7 +2,7 @@ [CmdletBinding()] param( - [string]$Version = "2.1.1-alpha.1", + [string]$Version = "2.1.1-alpha.2", [string]$ProjectRoot = "", [string]$OutputDirectory = "" ) @@ -10,7 +10,7 @@ param( Set-StrictMode -Version Latest $ErrorActionPreference = "Stop" -$ExpectedReleaseVersion = "2.1.1-alpha.1" +$ExpectedReleaseVersion = "2.1.1-alpha.2" $ExpectedMaps = @( "de_ancient", "de_anubis", "de_dust2", "de_inferno", "de_mirage", "de_nuke", "de_overpass", "de_train" @@ -623,7 +623,7 @@ try { $requiredFiles = @( "LICENSE", "README.md", - "RELEASE_NOTES_v2.1.1-alpha.1.md", + "RELEASE_NOTES_v2.1.1-alpha.2.md", "SECURITY.md", "THIRD_PARTY_NOTICES.md", "windows\Install-CS-Scout.cmd", diff --git a/windows/Install-CS-Scout.ps1 b/windows/Install-CS-Scout.ps1 index ac1aa66..3cc8f11 100644 --- a/windows/Install-CS-Scout.ps1 +++ b/windows/Install-CS-Scout.ps1 @@ -389,7 +389,7 @@ try { Write-Step "Checking the installation" & $venvPython -m pip check Assert-LastExitCode "Checking installed dependencies" - & $venvPython -c "import sys; sys.path.insert(0, sys.argv[1]); import cryptography, flask, requests, pandas, numpy, demoparser2, websocket, fivee_monitor, perfectworld_experiment.web_server; print('Runtime imports: OK')" (Join-Path $projectRoot "server") + & $venvPython -c "import sys; sys.path.insert(0, sys.argv[1]); sys.path.insert(0, sys.argv[2]); import cryptography, flask, requests, pandas, numpy, demoparser2, websocket, fivee_monitor, perfectworld_experiment.web_server; print('Runtime imports: OK')" $projectRoot (Join-Path $projectRoot "server") Assert-LastExitCode "Importing runtime packages" Write-Host "`nInstallation is ready." -ForegroundColor Green diff --git a/windows/README-PLAYER-ZH.md b/windows/README-PLAYER-ZH.md index 42a86ba..388105b 100644 --- a/windows/README-PLAYER-ZH.md +++ b/windows/README-PLAYER-ZH.md @@ -18,9 +18,9 @@ ## 二、下载和安装 1. 在 GitHub Releases 页面下载 - `CS-Scout-Windows-x64-v2.1.1-alpha.1.zip` Windows 发布包。 + `CS-Scout-Windows-x64-v2.1.1-alpha.2.zip` Windows 发布包。 2. 同时下载 `SHA256SUMS.txt`,在 ZIP 所在目录运行 - `Get-FileHash -Algorithm SHA256 .\CS-Scout-Windows-x64-v2.1.1-alpha.1.zip`,确认结果与 + `Get-FileHash -Algorithm SHA256 .\CS-Scout-Windows-x64-v2.1.1-alpha.2.zip`,确认结果与 校验文件中的 64 位散列完全相同;不同就不要运行。 3. 不要下载 GitHub 自动生成的 `Source code (zip)` 或 `Source code (tar.gz)`;它们可能不含雷达地图。 4. 右键 ZIP,选择“全部解压”。不要直接在压缩包预览窗口里运行程序。 @@ -42,7 +42,8 @@ 所以不同电脑或不同启动次数显示的数字可能不同,这是正常现象。 3. 先选择“5E / 完美平台”,再选择“自动 / 手动”。两个平台默认都是自动模式。 4. 选择 5E 自动模式后,CS-Scout 会连接本机 5E 客户端并等待匹配消息。5E 尚未运行时, - CS-Scout 会尝试启动它;如果 Windows 显示 5E 的标准 UAC 确认,请确认后继续。 + CS-Scout 会尝试启动它;如果没有自动找到安装位置,直接在页面点击“选择 5E”,选中 + `5EClient.exe` 即可。程序会记住这次选择;如果 Windows 显示 5E 的标准 UAC 确认,请确认后继续。 5. 匹配成功后,页面会自动显示地图和 5 名对手,用户名不可修改。确认名单后点击 “开始分析”。如果无法唯一识别你所在的队伍,页面会让你点击一次“我的队伍”。 6. 手动模式下可直接选择地图并输入最多 5 个用户名;5E 与完美平台都会使用各自平台的历史 Demo。 @@ -65,7 +66,8 @@ ├─ demos\ 已下载的 Demo 缓存 ├─ output\ 最近的 5E 分析 JSON ├─ perfectworld\ 完美平台 Demo 缓存与分析 JSON -└─ pwa-install.json 上次选择的完美平台安装目录 +├─ pwa-install.json 上次选择的完美平台安装目录 +└─ fivee-install.json 上次选择的 5E 客户端位置 ``` 需要释放磁盘时,先停止 CS-Scout,再删除 `demos` 目录中的缓存文件。不要在分析过程中删除文件。 @@ -103,12 +105,8 @@ python.org 提供的版本并保留 Python Launcher。 保持 CS-Scout 运行并允许它重新启动 5E,或重新启动 CS-Scout。若仍不可用,直接切换到 “手动”并输入用户名。 -如果 5E 安装在非默认目录,可先在 PowerShell 中指定程序路径,再从同一个窗口启动: - -```powershell -$env:CS_SCOUT_5E_EXE = 'D:\Games\5EClient\5EClient.exe' -.\windows\Start-CS-Scout.cmd -``` +如果 5E 安装在非默认目录,页面会显示“选择 5E”。点击后选中官方 `5EClient.exe`,CS-Scout +会验证数字签名、保存位置并自动重试;不需要打开 PowerShell,也不需要配置环境变量。 CS-Scout 只连接确认由官方 5E 进程占用的 `127.0.0.1` 调试端口。本人识别只在 5E 页面内部 用当前十人名单比对账号 UUID;页面存储、Cookie 和登录 Token 不会返回给 CS-Scout,也不会 diff --git a/windows/Verify-Windows-Package.ps1 b/windows/Verify-Windows-Package.ps1 index 3310430..66c868c 100644 --- a/windows/Verify-Windows-Package.ps1 +++ b/windows/Verify-Windows-Package.ps1 @@ -201,6 +201,8 @@ Assert-True ($runtimeRequirements -match '(?m)^websocket-client==[^;]+;\s*platfo Assert-True ($installScript -match 'import[^\r\n]*websocket[^\r\n]*fivee_monitor') ` "Installer must import-check websocket and the 5E monitor." Assert-True ($installScript -match 'sys\.path\.insert\(0, sys\.argv\[1\]\)') ` + "Installer must import-check packages from the packaged project root." +Assert-True ($installScript -match 'sys\.path\.insert\(0, sys\.argv\[2\]\)') ` "Installer must import-check the 5E monitor from the packaged server directory." Assert-True ($startScript -match 'CS_SCOUT_5E_CDP_PORT') ` "Starter must pass the validated 5E CDP port to the local service." @@ -214,6 +216,17 @@ Assert-True ($webServerScript -match '/api/pwa/dll/select') ` "Local server must expose the Perfect World directory picker endpoint." Assert-True ($appScript -match '/api/pwa/dll/select') ` "Desktop UI must call the Perfect World directory picker endpoint." +Assert-True ($webServerScript -match '/api/5e/exe/select') ` + "Local server must expose the 5E executable picker endpoint." +Assert-True ($appScript -match '/api/5e/exe/select') ` + "Desktop UI must call the 5E executable picker endpoint." +$fiveEMonitorScript = Get-Content -LiteralPath (Join-Path $root "server\fivee_monitor.py") -Raw +Assert-True ($fiveEMonitorScript -match 'fivee-install\.json') ` + "5E monitor must persist a selected non-default installation path." +Assert-True ($fiveEMonitorScript -match 'OpenFileDialog') ` + "5E monitor must provide a graphical executable picker." +Assert-True ($fiveEMonitorScript -match 'CurrentVersion\\Uninstall') ` + "5E monitor must inspect Windows installation records." Assert-True ($nativeSignerScript -match 'Get-AuthenticodeSignature') ` "Perfect World DLL validation must verify the Authenticode signature." Assert-True ($nativeSignerScript -match 'X86_PE_MACHINE') `