diff --git a/app/models/config.py b/app/models/config.py index 13b18f47d..93fa3f0ca 100644 --- a/app/models/config.py +++ b/app/models/config.py @@ -3750,6 +3750,8 @@ def __init__(self) -> None: self.Game_Type = ConfigItem( "Game", "Type", "Client", OptionsValidator(["Client", "URL"]) ) + # 异环直启 HTGame.exe 会卡界面,此路径为启动器 exe(NTELauncher/NTEGame.exe), + # 旧值为 HTGame.exe 时运行时自动反推同安装根下的启动器 self.Game_Path = ConfigItem("Game", "Path", "", FileValidator()) self.Game_URL = ConfigItem("Game", "URL", "") self.Game_ProcessName = ConfigItem("Game", "ProcessName", "") @@ -3761,6 +3763,10 @@ def __init__(self) -> None: self.Game_CloseOnFinish = ConfigItem( "Game", "CloseOnFinish", True, BoolValidator() ) + ## 运行前强制切换账号(依赖游戏配置启用;用户未填手机号时不切换) + self.Game_AccountSwitch = ConfigItem( + "Game", "AccountSwitch", False, BoolValidator() + ) ## Run ------------------------------------------------------------- self.Run_ProxyTimesLimit = ConfigItem( diff --git a/app/models/schema.py b/app/models/schema.py index b212c07eb..9cbbfa1a7 100644 --- a/app/models/schema.py +++ b/app/models/schema.py @@ -1122,7 +1122,9 @@ class OkNteConfig_Game(BaseModel): Type: Optional[Literal["Client", "URL"]] = Field( default=None, description="类型: PC端, URL协议" ) - Path: Optional[str] = Field(default=None, description="游戏程序路径") + Path: Optional[str] = Field( + default=None, description="游戏启动器路径(NTELauncher/NTEGame.exe,直启 HTGame.exe 会卡界面)" + ) URL: Optional[str] = Field(default=None, description="自定义协议URL") ProcessName: Optional[str] = Field(default=None, description="游戏进程名称") Arguments: Optional[str] = Field(default=None, description="游戏启动参数") @@ -1136,6 +1138,9 @@ class OkNteConfig_Game(BaseModel): CloseOnFinish: Optional[bool] = Field( default=None, description="任务结束后是否关闭游戏" ) + AccountSwitch: Optional[bool] = Field( + default=None, description="运行前强制切换账号(需启用游戏配置;用户未填手机号时不切换)" + ) class OkNteConfig_Run(GeneralConfig_Run): diff --git a/app/task/OkNte/AutoProxy.py b/app/task/OkNte/AutoProxy.py index 92fa24410..593805ca4 100644 --- a/app/task/OkNte/AutoProxy.py +++ b/app/task/OkNte/AutoProxy.py @@ -28,6 +28,8 @@ from datetime import datetime from pathlib import Path +import psutil + from app.core import Config from app.core.ws import Publisher, protocol from app.log_box import LogType, log_box @@ -35,7 +37,7 @@ from app.models.ConfigBase import MultipleConfig from app.models.schema import WSTaskNoticeData from app.models.task import LogRecord, ScriptItem, TaskExecuteBase, UserItem -from app.services import System +from app.services import Notify, System from app.task.general.tools import execute_script_task from app.utils import ( ProcessInfo, @@ -59,12 +61,18 @@ ) from .push_log import OKNTE_PUSH_RULES, oknte_resolve from .tools import push_notification +from .tools.account_switch import async_switch_account +from .tools.launcher_start import async_start_game_via_launcher logger = get_logger("OK-NTE 自动代理") # 异环 PC 客户端进程名固定,MAS 接管启动前据此避免重复拉起 _NTE_CLIENT_PROCESS = "HTGame.exe" -_NTE_LAUNCHER_RELATIVE_PATH = Path("Neverness To Everness/NTELauncher/NTEGame.exe") +# 异环必须经启动器拉起(直启 HTGame.exe 会卡界面):启动器相对目录与候选 exe +# (国服/国际/台服,对齐 ok-nte 上游) +_NTE_LAUNCHER_DIR = Path("Neverness To Everness/NTELauncher") +_NTE_LAUNCHER_EXES = ("NTEGame.exe", "NTEGlobalGame.exe", "NTETWGame.exe") +_NTE_LAUNCHER_EXES_CASEFOLD = {exe.casefold() for exe in _NTE_LAUNCHER_EXES} # 多用户切换时等待旧游戏完全退出的上限(秒): # 异环客户端「自退」不是瞬时的(ok-nte `-e` 退出约 70 秒),若不等待完全退出, @@ -81,13 +89,16 @@ def _load_nte_launcher_path(config_path: Path) -> Path | None: return None launcher_path = Path(str(config.get("Launcher Path") or "").strip()) - expected_parts = tuple( - part.casefold() for part in _NTE_LAUNCHER_RELATIVE_PATH.parts - ) - actual_parts = tuple( - part.casefold() for part in launcher_path.parts[-len(expected_parts) :] + if not launcher_path.is_absolute(): + return None + expected_dirs = tuple(part.casefold() for part in _NTE_LAUNCHER_DIR.parts) + actual_dirs = tuple( + part.casefold() for part in launcher_path.parts[-len(expected_dirs) - 1 : -1] ) - if not launcher_path.is_absolute() or actual_parts != expected_parts: + if ( + actual_dirs != expected_dirs + or launcher_path.name.casefold() not in _NTE_LAUNCHER_EXES_CASEFOLD + ): return None return launcher_path @@ -270,7 +281,7 @@ async def check(self) -> str: and self.script_config.get("Game", "Type") == "Client" and not Path(self.script_config.get("Game", "Path")).is_file() ): - return "请设置异环游戏路径" + return "请设置异环启动器路径" if ( self.script_config.get("Game", "Enabled") and self.script_config.get("Game", "Type") == "URL" @@ -382,8 +393,7 @@ async def prepare(self): self.oknte_args.append("-e") self.oknte_args.extend(extra_args) - # 游戏配置(对齐通用脚本逻辑) - self.game_path = Path(self.script_config.get("Game", "Path")) + # 游戏配置(对齐通用脚本逻辑);启动器路径在启动时经 _resolve_launcher_path 解析 self.game_url = self.script_config.get("Game", "URL") self.game_process_name = self.script_config.get("Game", "ProcessName") self.script_config_path = Path(self.script_config.get("Script", "ConfigPath")) @@ -511,6 +521,26 @@ async def _log_game_config_summary(self) -> None: self.script_info.log = "\n".join(self._game_config_summary_lines()) await asyncio.sleep(0) + def _resolve_launcher_path(self) -> Path | None: + """解析异环启动器路径(异环直启 HTGame.exe 会卡界面,必须经启动器)。 + + Game.Path 优先:新语义直接选启动器 exe;旧值是 HTGame.exe 时按 + <安装根>\\Client\\... 反推 <安装根>\\NTELauncher\\<启动器>。都没有时 + 回退 ok-nte 自己的 LauncherTask.json(其注册表回退由 ok-nte 维护)。 + """ + game_path = Path(self.script_config.get("Game", "Path")) + if game_path.is_file(): + if game_path.name.casefold() in _NTE_LAUNCHER_EXES_CASEFOLD: + return game_path + for ancestor in game_path.parents: + if ancestor.name.casefold() == "client": + for exe in _NTE_LAUNCHER_EXES: + candidate = ancestor.parent / "NTELauncher" / exe + if candidate.is_file(): + return candidate + break + return _load_nte_launcher_path(self.script_config_path) + async def _mas_launch_game_before_task(self) -> None: """MAS 接管启动游戏,并将各步骤写入调度台日志。""" @@ -526,13 +556,29 @@ async def _mas_launch_game_before_task(self) -> None: await self._push_dispatch_log("检测到客户端已在运行,跳过启动") return - await self._push_dispatch_log("未检测到运行中的客户端,正在拉起游戏...") - await self.game_manager.open_process( - self.game_path, - *_split_args(self.script_config.get("Game", "Arguments")), + await self._push_dispatch_log("未检测到运行中的客户端,正在拉起启动器...") + launcher_path = self._resolve_launcher_path() + if launcher_path is None: + raise RuntimeError( + "未找到异环启动器路径,请重新选择游戏目录以定位 NTELauncher 启动器" + ) + await self.game_manager.open_process(launcher_path) + await self._push_dispatch_log("启动器已拉起,正在等待点击「开始游戏」...") + # 启动器交互在后台线程内同步执行,on_log 契约是同步回调; + # _push_dispatch_log 是 async 方法,须经 run_coroutine_threadsafe + # 调度回事件循环(与账号切换的 _push_switch_log 同理)。 + launch_loop = asyncio.get_running_loop() + + def _push_launch_log(line: str) -> None: + asyncio.run_coroutine_threadsafe( + self._push_dispatch_log(line), launch_loop + ) + + await async_start_game_via_launcher( + launcher_path, on_log=_push_launch_log ) wait_time = int(self.script_config.get("Game", "WaitTime")) - await self._push_dispatch_log(f"正在等待游戏完成启动({wait_time}s)...") + await self._push_dispatch_log(f"游戏窗口已出现,正在等待游戏完成启动({wait_time}s)...") await asyncio.sleep(wait_time) await self._push_dispatch_log("游戏启动完成") return @@ -559,6 +605,46 @@ async def _mas_launch_game_before_task(self) -> None: await self._push_dispatch_log("游戏启动指令已发送") return + async def handle_pre_oknte_error( + self, error_message: str, e: Exception | None = None + ) -> None: + """游戏启动 / 账号切换等前置步骤失败的统一处理(对齐 OK-WW)。""" + + if e is None: + logger.warning(f"用户: {self.cur_user_uid} - {error_message}") + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData(level="error", message=error_message), + ) + else: + logger.opt(exception=True).warning( + f"用户: {self.cur_user_uid} - {error_message}: {e}" + ) + await Publisher.send( + id=self.task_info.task_id, + type=protocol.TASK_NOTICE, + data=WSTaskNoticeData( + level="error", message=f"{error_message}: {e}" + ), + ) + self.cur_user_log.content = [f"{error_message}, 无日志记录"] + self.cur_user_log.status = error_message + + await self.kill_managed_process( + kill_game=self._mas_should_close_game_on_retry() + ) + + try: + await Notify.push_plyer( + "OK-NTE 自动代理出现异常!", + f"用户 {self.cur_user_item.name} 自动代理时{error_message}", + f"{self.cur_user_item.name}的自动代理出现异常", + 3, + ) + except Exception: + pass + async def main_task(self): await self.prepare() await self._reset_daily_proxy_count() @@ -625,6 +711,52 @@ async def main_task(self): self.cur_user_item.status = "异常" continue + # 游戏启动成功后、下发脚本配置前,按「运行前强制切换账号」开关切号。 + # 开关在游戏配置区,依赖 Game.Enabled 且需开启「任务前启动游戏」(否则游戏非 + # MAS 拉起、无窗口可切);用户未填写手机号时跳过不切换。 + if ( + self.script_config.get("Game", "AccountSwitch") + and self.script_config.get("Game", "Enabled") + and self.script_config.get("Game", "LaunchBeforeTask") + and self.game_manager is not None + ): + account_id = (self.cur_user_config.get("Info", "Id") or "").strip() + if not account_id: + await self._push_dispatch_log( + "未配置账号,跳过账号切换" + ) + else: + try: + await self._push_dispatch_log( + "正在强制切换异环登录账号..." + ) + # 账号切换在后台线程内同步执行,on_log 契约是同步回调; + # _push_dispatch_log 是 async 方法,须经 run_coroutine_threadsafe + # 调度回事件循环,否则进度不会推送到调度台且产生未等待协程告警。 + switch_loop = asyncio.get_running_loop() + + def _push_switch_log(line: str) -> None: + asyncio.run_coroutine_threadsafe( + self._push_dispatch_log(line), switch_loop + ) + + await async_switch_account( + account_id, on_log=_push_switch_log + ) + await self._push_dispatch_log( + f"异环账号切换成功:****{account_id[-4:]}" + ) + except Exception as e: + await self.handle_pre_oknte_error("异环账号切换失败", e) + if i + 1 < run_limit: + await self._push_dispatch_log( + f"异环账号切换失败,将在稍后重试 ({i + 1}/{run_limit})" + ) + await asyncio.sleep(10) + else: + self.cur_user_item.status = "异常" + continue + await self.set_oknte() await self._push_dispatch_log( f"启动 OK-NTE: -t {self.task_index}" @@ -966,9 +1098,20 @@ async def _kill_game_process(self) -> None: if isinstance(self.game_manager, ProcessManager): await self.game_manager.kill() if game_type == "Client": - gp = self.game_path - if gp.is_file(): - await System.kill_process(gp) + # Game.Path 是启动器,游戏本体按进程名结束;进程管理器只跟踪 + # 启动器,HTGame.exe 由启动器拉起、可能不在其进程树内 + for process in psutil.process_iter(["name"]): + try: + if process.info["name"] != _NTE_CLIENT_PROCESS: + continue + except psutil.Error: + continue + try: + await System.kill_process_by_pid(process.pid) + except Exception as e: + logger.opt(exception=True).warning( + f"结束异环游戏进程失败 PID: {process.pid}, {e}" + ) except Exception as e: logger.opt(exception=True).warning(f"关闭游戏进程失败: {e}") diff --git a/app/task/OkNte/tools/account_switch.py b/app/task/OkNte/tools/account_switch.py new file mode 100644 index 000000000..206cae07c --- /dev/null +++ b/app/task/OkNte/tools/account_switch.py @@ -0,0 +1,721 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""OK-NTE(异环)强制账号切换。 + +参照 OK-WW 强制切号骨架重写(前台 pyautogui + DPI 适配 + 1080p 帧坐标空间, +OCR 复用通用工具集 `app.tools.ocr`)。异环部分界面元素没有文本可识别, +无文本元素按 1080p 帧相对坐标点击。 + +流程:: + + 标题界面点右上角退出图标位置(相对坐标,双入口)→ 已登录:确认弹窗 + 点「确认」;未登录:直接打开登录面板 → HOTTA STUDIO 登录面板 + → 点账号卡片展开列表 → 按手机号后 4 位选择目标账号(超出可见范围时 + 滚轮翻页)→ 点「登 录」→ 等待登录面板消失(登录成功) + +退出图标位置的点击是双入口:已登录时弹出「是否退出当前账号」确认弹窗, +未登录时该点击直接打开登录面板,按点击后弹出的界面分流。 +""" + +import asyncio +import ctypes +import inspect +import re +import time +from collections.abc import Callable +from contextlib import contextmanager +from datetime import datetime +from functools import lru_cache +from pathlib import Path + +import cv2 +import numpy as np +import psutil +from PIL import Image + +from app.tools.ocr import Box, OCRItem, ocr_image +from app.utils import get_logger +from app.utils.platform import IS_WINDOWS + +if IS_WINDOWS: + # pyautogui 与 pywin32 仅 Windows 可用(无图形会话导入即失败),随入口的 + # IS_WINDOWS 检查一并惰性导入,避免非 Windows 环境在未启用切号时导入崩溃 + import pyautogui + import win32api + import win32con + import win32gui + import win32process + +logger = get_logger("OK-NTE 账号切换") + +# 诊断文件(debug/oknte-account-switch/switch-detail-*.log):记录切换过程各步骤 +# OCR 文本,供用户反馈登录失败时对照定位识别漂移;切换串行独占前台,单例写入。 +_DIAGNOSTIC_PATH: Path | None = None + + +def _write_diagnostic(text: str) -> None: + """向诊断文件追加一行(旁路,失败时静默忽略)。""" + if _DIAGNOSTIC_PATH is not None: + try: + with _DIAGNOSTIC_PATH.open("a", encoding="utf-8") as handle: + handle.write(text) + except OSError: + pass + +# ── 异环客户端窗口识别(与 OkNte/AutoProxy 的 _NTE_CLIENT_PROCESS 一致)── +_NTE_CLIENT_PROCESS = "HTGame.exe" + +# 游戏窗口就绪宽限期:进程拉起到窗口可见通常存在启动延迟,且设备性能越差窗口 +# 创建/亮相越慢。账号切换紧随定长 WaitTime 之后立即执行,须在宽限期内轮询等待 +# 窗口就绪,否则慢设备会误报「未找到异环游戏窗口」。 +_GAME_WINDOW_WAIT_SECONDS = 60.0 +# 窗口轮询间隔。 +_WINDOW_POLL_INTERVAL = 1.0 +# 启动界面稳定等待:窗口已出现但尚未进入可执行态(仍停在 splash/加载/游戏内更新 +# 过渡帧)时,在硬上限内轮询等待「标题界面」或「登录面板」出现。异环更新频繁,点 +# 「开始游戏」后游戏内更新可能耗时很长,故界面仍在变化(有进展)时持续顺延等待; +# 只有持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展(界面静止且仍非标题/登录)才判失败。 +# 异环更新频繁且包体大,慢网下 30 分钟不够,硬上限放宽到 2 小时;真挂起由无进展 +# 检测在 60s 内提前失败,不会傻等满上限。 +_IN_GAME_UPDATE_TIMEOUT = 7200.0 +# 长时间无进展判定:界面静止达到该时长仍非标题/登录面板则视为卡死,提前失败。 +_IN_GAME_STALL_SECONDS = 60.0 +# 游戏内更新/加载等待的轮询间隔(比窗口等待更宽松,降低长等待期 OCR 负载)。 +_IN_GAME_POLL_INTERVAL = 3.0 +# 长等待期诊断 OCR 的落盘节流:更新可能耗时数十分钟,若每个轮询都全量写诊断文件, +# 单次切换会累积数千行;改为每 N 次轮询(≈ N*3s)写一次,既能保留过渡帧采样又不膨胀。 +_DIAGNOSTIC_DUMP_EVERY_POLLS = 10 + +# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口 +_FRAME_WIDTH = 1920 +_FRAME_HEIGHT = 1080 + +# 标题界面右上角退出图标(无文本可 OCR,按 2048x1152 参考截图换算 1080p 坐标; +# 图标中心 x≈1962/2048→1839,y≈363/1152→340,原 1865 偏右落在图标右缘外导致点击不生效) +_LOGOUT_ICON_POINT = (1839, 340) +# 账号列表滚动中心(HOTTA STUDIO 面板中部),目标账号超出可见范围时滚轮翻页 +_LIST_SCROLL_POINT = (960, 600) + +# 仅标题界面出现的文本(底部居中按钮),用于判定「处于标题界面」 +_TITLE_TEXTS = ("进入游戏",) +# 仅登录面板出现的文本,用于判定「登录面板已打开 / 登录成功后面板消失」 +_PANEL_TEXTS = ("使用其他方式登录",) +# 加载完成后常驻左上角的适龄提示徽标(16+/CADPA),标题界面与登录面板均有: +# 作为就绪判定的附加信号,防止「进入游戏」按钮被特效遮挡或 OCR 瞬时失败漏判 +_LOADED_BADGE_TEXTS = ("适龄提示",) + +# 掩码账号形如 130*****6220 +_MASKED_ACCOUNT = re.compile(r"\d{3}\*+\d{4}") +_MASKED_SUFFIX = re.compile(r"\d+\*+(\d{4})") + + +@lru_cache(maxsize=1) +def _user32_dpi_api(): + user32 = ctypes.windll.user32 + user32.SetThreadDpiAwarenessContext.argtypes = [ctypes.c_void_p] + user32.SetThreadDpiAwarenessContext.restype = ctypes.c_void_p + return user32 + + +@contextmanager +def _per_monitor_dpi(): + """切换到 per-monitor DPI 感知,保证窗口坐标换算在跨 DPI 显示器下正确。""" + user32 = _user32_dpi_api() + previous = user32.SetThreadDpiAwarenessContext(ctypes.c_void_p(-4)) + try: + yield + finally: + if previous: + user32.SetThreadDpiAwarenessContext(previous) + + +# ── 窗口定位 ──────────────────────────────────────────────────────────── + + +def _process_name(pid: int) -> str | None: + """按 pid 读取进程名;提权进程可能被拒,返回 None 而非抛错。""" + try: + return psutil.Process(pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return None + + +def _window_area(hwnd: int) -> int: + try: + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + return (right - left) * (bottom - top) + except Exception: + return 0 + + +def _find_game_hwnd(*, wait: bool = True) -> int: + """按所属进程名定位异环主窗口。 + + 异环窗口类未实测稳定,故只用 ``EnumWindows + psutil.Process(pid).name()`` + 过滤 HTGame.exe 的可见窗口(规避提权进程 name 读取被拒导致的漏判), + 同进程存在多个窗口时取面积最大者。 + + Args: + wait: 为 True 时在宽限期 ``_GAME_WINDOW_WAIT_SECONDS`` 内轮询等待窗口就绪, + 吸收进程拉起后窗口延迟亮相的启动阶段;为 False 时单次枚举立即返回。 + + Raises: + RuntimeError: 宽限期结束仍未定位到异环游戏窗口。 + """ + deadline = time.monotonic() + _GAME_WINDOW_WAIT_SECONDS + while True: + candidates: list[int] = [] + + def _enum(hwnd: int, _lparam: int) -> bool: + try: + if not win32gui.IsWindowVisible(hwnd): + return True + except Exception: + return True + _, pid = win32process.GetWindowThreadProcessId(hwnd) + if pid and _process_name(pid) == _NTE_CLIENT_PROCESS: + candidates.append(hwnd) + return True + + win32gui.EnumWindows(_enum, 0) + if candidates: + return max(candidates, key=_window_area) + if not wait or time.monotonic() >= deadline: + break + logger.info( + "异环游戏进程已启动但窗口暂未就绪," + f"{_WINDOW_POLL_INTERVAL:g} 秒后重试..." + ) + time.sleep(_WINDOW_POLL_INTERVAL) + raise RuntimeError( + f"未找到异环游戏窗口(进程 {_NTE_CLIENT_PROCESS})" + ) + + +# ── 截图 / 交互(前台 pyautogui + DPI 适配)───────────────────────────── + + +def _activate_window(hwnd: int) -> None: + if not win32gui.IsWindow(hwnd): + raise RuntimeError("异环游戏窗口已失效") + show_command = ( + win32con.SW_RESTORE + if win32gui.IsIconic(hwnd) + else win32con.SW_SHOW if not win32gui.IsWindowVisible(hwnd) else None + ) + if show_command is not None: + win32gui.ShowWindow(hwnd, show_command) + time.sleep(0.15) + try: + if win32gui.GetForegroundWindow() != hwnd: + # Windows 前台锁:后台进程不能直接抢占前台。先附着当前前台窗口线程 + # 的输入队列,再置前,绕过系统限制(与 OK-WW 切号同理)。 + foreground = win32gui.GetForegroundWindow() + fg_thread = win32process.GetWindowThreadProcessId(foreground)[0] + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, True + ) + try: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + finally: + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, False + ) + else: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + except win32gui.error: + logger.debug("异环游戏窗口焦点请求被系统忽略,继续按前置窗口处理") + time.sleep(0.1) + + +def _client_size(hwnd: int) -> tuple[int, int]: + _, _, width, height = win32gui.GetClientRect(hwnd) + if width <= 0 or height <= 0: + raise RuntimeError("异环游戏窗口尺寸异常") + if abs(width / height - 16 / 9) > 0.02: + logger.warning( + f"异环窗口非 16:9({width}x{height}),账号切换坐标可能偏移" + ) + return width, height + + +def _capture_window_image(hwnd: int, *, activate: bool = True) -> Image.Image: + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + left, top = win32gui.ClientToScreen(hwnd, (0, 0)) + virtual_left = win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN) + virtual_top = win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN) + return pyautogui.screenshot(allScreens=True).crop( + ( + left - virtual_left, + top - virtual_top, + left - virtual_left + width, + top - virtual_top + height, + ) + ) + + +def _capture_window(hwnd: int, *, activate: bool = True) -> np.ndarray: + screenshot = _capture_window_image(hwnd, activate=activate) + screenshot = screenshot.resize( + (_FRAME_WIDTH, _FRAME_HEIGHT), Image.Resampling.LANCZOS + ) + return cv2.cvtColor(np.asarray(screenshot), cv2.COLOR_RGB2BGR) + + +def _dump_ocr_items(items: list[OCRItem]) -> None: + """诊断旁路:把一次 OCR 的全部识别文本写入诊断文件(标注调用函数)。""" + if _DIAGNOSTIC_PATH is None: + return + caller = "" + frame = inspect.currentframe() + if frame is not None and frame.f_back is not None: + caller = frame.f_back.f_code.co_name + _write_diagnostic(f"\n[{datetime.now():%H:%M:%S}] OCR[{caller}] {len(items)} 条:\n") + for text, box in items: + x, y, w, h = box + _write_diagnostic(f" ({x:4},{y:4} {w:3}x{h:3}) {text}\n") + + +def _read_texts(hwnd: int, roi: Box | None = None) -> list[OCRItem]: + frame = _capture_window(hwnd, activate=False) + items = ocr_image(frame, roi) + _dump_ocr_items(items) + return items + + +def _click_box( + hwnd: int, box: Box, *, activate: bool = False, after_sleep: float = 0.3 +) -> None: + """点击 1080p 坐标空间中的一个文字框中心。""" + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + x, y, box_width, box_height = box + client_x = round((x + box_width / 2) * width / _FRAME_WIDTH) + client_y = round((y + box_height / 2) * height / _FRAME_HEIGHT) + screen_x, screen_y = win32gui.ClientToScreen(hwnd, (client_x, client_y)) + + original_position = pyautogui.position() + try: + pyautogui.moveTo(screen_x, screen_y) + time.sleep(0.3) + pyautogui.click() + time.sleep(after_sleep) + finally: + pyautogui.moveTo(*original_position) + + +def _click_point( + hwnd: int, px: int, py: int, *, after_sleep: float = 0.3 +) -> None: + _click_box(hwnd, (px, py, 1, 1), after_sleep=after_sleep) + + +# ── OCR 文本判定辅助 ───────────────────────────────────────────────────── + + +def _find_text(items: list[OCRItem], keywords: tuple[str, ...]) -> Box | None: + for text, box in items: + if any(keyword in text for keyword in keywords): + return box + return None + + +def _wait_ocr_text( + hwnd: int, + keywords: tuple[str, ...], + *, + roi: Box | None = None, + timeout: int, +) -> Box | None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + box = _find_text(_read_texts(hwnd, roi), keywords) + if box is not None: + return box + time.sleep(1) + return None + + +def _on_login_panel(hwnd: int) -> bool: + """登录面板是否已打开(面板内独有文本「使用其他方式登录」)。""" + return _find_text(_read_texts(hwnd), _PANEL_TEXTS) is not None + + +def _frame_signature(frame: np.ndarray) -> int: + """对下采样的帧做哈希,用于判断界面是否仍在变化(有更新/加载进展)。""" + small = frame[::40, ::40] + return hash(small.tobytes()) + + +def _reacquire_game_hwnd(on_log: Callable[[str], None]) -> int: + """窗口句柄在等待期间失效(如游戏内更新触发客户端重启)时重新定位异环窗口。 + + 重定位成功返回新句柄;等待宽限期内仍找不到则抛出,交由调用方失败处理。 + """ + on_log("游戏窗口句柄已失效,重新定位异环游戏窗口...") + return _find_game_hwnd(wait=True) + + +def _wait_for_actionable_state( + hwnd: int, on_log: Callable[[str], None] +) -> int: + """等待进入可执行的切号态(标题界面或登录面板),返回当前有效的游戏窗口句柄。 + + 游戏窗口刚出现时可能仍停在启动过渡帧(splash/加载),而异环更新频繁,点「开始 + 游戏」后游戏内可能出现体积大、耗时长更新的界面(此时标题界面与登录面板都不命中)。 + 若立即按标题界面退出图标分流,会落在不匹配的画面上。故采用「进展续延」语义等待: + + - 命中标题界面/登录面板/左上角适龄提示徽标 → 返回有效句柄,由调用方按对应态执行; + - 界面仍在变化(有更新/加载进展)→ 持续顺延等待,硬上限 ``_IN_GAME_UPDATE_TIMEOUT``; + - 界面持续 ``_IN_GAME_STALL_SECONDS`` 无任何进展且仍非标题/登录 → 判卡死提前失败。 + - 等待期间窗口句柄失效(客户端更新重启)→ 重新定位新窗口后继续等待。 + + 进度日志按约 5 条/轮询节流,避免向调度台频繁刷屏。 + """ + deadline = time.monotonic() + _IN_GAME_UPDATE_TIMEOUT + last_progress = time.monotonic() + last_sig: int | None = None + iter_count = 0 + while time.monotonic() < deadline: + try: + frame = _capture_window(hwnd, activate=False) + items = ocr_image(frame) + except RuntimeError: + # 窗口句柄失效:可能是游戏内更新触发客户端重启,重找新窗口继续而非中止。 + hwnd = _reacquire_game_hwnd(on_log) + last_progress = time.monotonic() + last_sig = None + continue + if iter_count % _DIAGNOSTIC_DUMP_EVERY_POLLS == 0: + _dump_ocr_items(items) + if ( + _find_text(items, _PANEL_TEXTS) is not None + or _find_text(items, _TITLE_TEXTS) is not None + or _find_text(items, _LOADED_BADGE_TEXTS) is not None + ): + return hwnd + sig = _frame_signature(frame) + if sig != last_sig: + last_sig = sig + last_progress = time.monotonic() + if time.monotonic() - last_progress >= _IN_GAME_STALL_SECONDS: + break + if iter_count % 5 == 0: + on_log("异环仍在游戏内更新/加载过渡帧中,等待进入标题界面或登录面板...") + iter_count += 1 + time.sleep(_IN_GAME_POLL_INTERVAL) + raise RuntimeError( + "等待异环标题界面/登录面板超时:长时间无进展或超过硬上限" + f"({_IN_GAME_UPDATE_TIMEOUT:g}s),请人工确认游戏已停在标题界面" + ) + + +def _find_confirm_box(items: list[OCRItem]) -> Box | None: + """在 OCR 条目中定位退出确认弹窗的「确认」按钮。 + + 弹窗说明文本「是否确认退出当前账号?」含「确认」子串,必须排除; + 按钮文本恰为「确认」,与左侧「取消」区分。 + """ + candidates = [(text, box) for text, box in items if "确认" in text] + if not candidates: + return None + # 精确等于按钮文本 + for text, box in candidates: + if text == "确认": + logger.info("OK-NTE 确认按钮精确命中「确认」") + return box + # 排除说明文本(含问号 / 「退出」)后取含「确认」的候选,多个命中取最右 + # (确认按钮在弹窗右侧) + filtered = [ + (t, b) + for t, b in candidates + if "?" not in t and "?" not in t and "退出" not in t and "取消" not in t + ] + if filtered: + text, box = max(filtered, key=lambda item: item[1][0] + item[1][2]) + logger.info(f"OK-NTE 确认按钮命中候选文本: {text}") + return box + logger.info(f"确认候选均为说明文本: {[t for t, _ in candidates]}") + return None + + +def _find_login_button(items: list[OCRItem]) -> Box | None: + """在 OCR 条目中定位登录面板的「登 录」按钮。 + + OCR 文本已去空白,按钮文本归一化后恰为「登录」;面板下方还有 + 「使用其他方式登录」,须排除。 + """ + candidates = [(text, box) for text, box in items if "登录" in text] + if not candidates: + return None + for text, box in candidates: + if text == "登录": + logger.info("OK-NTE 登录按钮精确命中「登录」") + return box + filtered = [(t, b) for t, b in candidates if "其他方式" not in t] + if filtered: + text, box = max(filtered, key=lambda item: item[1][0] + item[1][2]) + logger.info(f"OK-NTE 登录按钮命中候选文本: {text}") + return box + logger.info(f"登录候选均为干扰文本: {[t for t, _ in candidates]}") + return None + + +def _open_account_panel(hwnd: int, on_log: Callable[[str], None]) -> None: + """标题界面 → 点右上角退出图标位置 → 按弹出的界面分流。 + + 该位置点击是双入口:已登录时此点击弹出「是否退出当前账号」确认弹窗, + 点「确认」后登录面板打开;未登录时此点击直接打开登录面板。 + """ + on_log("正在点击标题界面右上角退出图标位置") + _click_point(hwnd, *_LOGOUT_ICON_POINT, after_sleep=1.5) + + # 已登录:等待退出确认弹窗;未登录:登录面板直接打开 + confirm: Box | None = None + deadline = time.monotonic() + 15 + while time.monotonic() < deadline: + items = _read_texts(hwnd) + confirm = _find_confirm_box(items) + if confirm is not None: + break + if _find_text(items, _PANEL_TEXTS) is not None: + on_log("登录面板已打开(当前未登录)") + return + time.sleep(1) + + if confirm is None: + raise RuntimeError( + "点击退出图标位置后未出现退出确认或登录面板,请人工确认当前处于标题界面" + ) + on_log("检测到已登录,点击「确认」退出当前账号") + _click_box(hwnd, confirm, after_sleep=2) + + if _wait_ocr_text(hwnd, _PANEL_TEXTS, timeout=30) is None: + raise RuntimeError("退出账号后登录面板未打开(30s 未识别到登录面板)") + on_log("登录面板已打开") + + +def _detect_current_account(hwnd: int) -> str | None: + """从登录面板 OCR 掩码账号(如 130*****6220)识别当前账号后 4 位。""" + for text, _ in _read_texts(hwnd): + match = _MASKED_SUFFIX.search(text) + if match: + return match.group(1) + return None + + +def _expand_account_list(hwnd: int) -> None: + """点击当前账号卡片直至账号列表展开(出现多个掩码账号)。 + + 若始终识别不到任何掩码账号,说明无法确认账号列表已打开(OCR 失败或 + 面板布局变化);继续登录可能落在错误账号上,按失败抛出而非静默返回。 + """ + found_masked = False + for _ in range(3): + items = _read_texts(hwnd) + masked = [box for text, box in items if _MASKED_ACCOUNT.search(text)] + if len(masked) >= 2: + return + if not masked: + # 可能为 OCR 瞬时失败,等待后重试 + time.sleep(1) + continue + found_masked = True + _click_box(hwnd, masked[0], after_sleep=1) + if not found_masked: + raise RuntimeError( + "未识别到任何掩码账号,无法确认账号列表已打开,请人工检查登录面板" + ) + + +def _scroll_list(hwnd: int) -> None: + """在账号列表区域滚轮下翻一页(目标账号不在可见范围时使用)。""" + with _per_monitor_dpi(): + width, height = _client_size(hwnd) + px, py = _LIST_SCROLL_POINT + client_x = round(px * width / _FRAME_WIDTH) + client_y = round(py * height / _FRAME_HEIGHT) + screen_x, screen_y = win32gui.ClientToScreen(hwnd, (client_x, client_y)) + pyautogui.moveTo(screen_x, screen_y) + time.sleep(0.2) + pyautogui.scroll(-3) + time.sleep(0.5) + + +def _click_masked_account( + hwnd: int, pattern: re.Pattern[str], on_log: Callable[[str], None] +) -> bool: + """在账号列表中点击目标掩码账号;不在可见范围时滚轮翻页(上限 5 页)。""" + for page in range(6): + items = _read_texts(hwnd) + for text, box in items: + if pattern.search(text): + _click_box(hwnd, box, after_sleep=1) + return True + if page < 5: + on_log(f"目标账号不在当前可见列表,滚轮翻页({page + 1}/5)") + _scroll_list(hwnd) + return False + + +def _wait_login_success(hwnd: int, *, timeout: int = 120) -> None: + """点击登录后等待登录面板消失(登录成功进入加载/游戏)。""" + deadline = time.monotonic() + timeout + absent_count = 0 + while time.monotonic() < deadline: + if not _on_login_panel(hwnd): + absent_count += 1 + if absent_count >= 2: + return + else: + absent_count = 0 + time.sleep(1) + raise RuntimeError("等待登录完成超时(登录面板未消失)") + + +def _select_and_login( + hwnd: int, suffix: str, on_log: Callable[[str], None] +) -> None: + pattern = re.compile(rf"\d+\*+{re.escape(suffix)}") + max_retries = 3 + for attempt in range(1, max_retries + 1): + _activate_window(hwnd) + time.sleep(1) + _expand_account_list(hwnd) + time.sleep(0.5) + if _click_masked_account(hwnd, pattern, on_log): + time.sleep(1) + detected = _detect_current_account(hwnd) + on_log( + f"已选择账号 ****{suffix},当前显示 " + f"****{detected if detected else '未知'}" + ) + if detected == suffix: + break + if attempt < max_retries: + on_log(f"账号显示不匹配,重试({attempt}/{max_retries})") + time.sleep(1) + else: + on_log(f"账号选择失败,已重试 {max_retries} 次") + raise RuntimeError(f"账号选择失败,已重试 {max_retries} 次") + time.sleep(2) + login_box = _find_login_button(_read_texts(hwnd)) + if login_box is not None: + _click_box(hwnd, login_box, after_sleep=3) + else: + # OCR 失败兜底:登录按钮在面板中线、账号卡片下方约一卡处 + on_log("未识别到「登录」按钮文本,按面板相对位置兜底点击") + _click_point(hwnd, 960, 660, after_sleep=3) + _wait_login_success(hwnd) + on_log(f"登录成功:****{suffix}") + + +def _save_error_screenshot(hwnd: int) -> None: + """保存切换失败时的原始窗口截图,便于排查 OCR 文本漂移。""" + try: + screenshot_dir = Path.cwd() / "debug" / "oknte-account-switch" + screenshot_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshot_dir / ( + f"switch-error-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.png" + ) + _capture_window_image(hwnd, activate=False).save( + screenshot_path, format="PNG" + ) + logger.warning(f"账号切换错误截图已保存: {screenshot_path}") + except Exception as error: + # 截图是诊断旁路,失败时不能覆盖原始切换异常 + logger.warning(f"账号切换错误截图保存失败: {error}") + + +# ── 对外入口 ───────────────────────────────────────────────────────────── + + +def account_switch( + account_id: str, *, on_log: Callable[[str], None] | None = None +) -> bool: + """强制切换异环登录账号到 ``account_id``(按手机号后 4 位匹配)。 + + Args: + account_id: 目标账号(手机号),取后 4 位匹配登录面板掩码账号。 + on_log: 流程进度回调(供 MAS 推送调度台日志),默认仅写日志。 + + Returns: + 切换成功返回 True;失败抛出带原因描述的 RuntimeError。 + + Raises: + RuntimeError: 未找到游戏窗口 / 画面不在标题界面或登录面板 / 流程失败 / 超时。 + """ + on_log = on_log or (lambda msg: logger.info(msg)) + if not IS_WINDOWS: + raise RuntimeError("OK-NTE 账号切换仅支持 Windows 平台") + account_id = str(account_id or "").strip() + if len(account_id) < 4: + raise RuntimeError("账号不足四位,无法按手机号后 4 位匹配登录账号") + suffix = account_id[-4:] + + # 开启诊断记录:进度日志与各步骤 OCR 文本写入 debug/oknte-account-switch/, + # 供登录失败时用户反馈定位(文件随时间戳命名,一次切换一个文件) + global _DIAGNOSTIC_PATH + diagnostic_dir = Path.cwd() / "debug" / "oknte-account-switch" + diagnostic_dir.mkdir(parents=True, exist_ok=True) + _DIAGNOSTIC_PATH = diagnostic_dir / ( + f"switch-detail-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.log" + ) + + def _on_log(msg: str) -> None: + _write_diagnostic(f"[{datetime.now():%H:%M:%S}] {msg}\n") + on_log(msg) + + _on_log(f"开始切换异环账号:****{suffix}") + try: + hwnd = _find_game_hwnd() + _activate_window(hwnd) + # 启动稳定化:等界面进入「标题界面」或「登录面板」之一,再按各自流程分流, + # 避免在窗口已现但仍在加载过渡帧时立即误判失败;等待期间窗口重启会返回新句柄。 + hwnd = _wait_for_actionable_state(hwnd, _on_log) + if _on_login_panel(hwnd): + on_log("登录面板已打开,直接选择账号") + else: + _open_account_panel(hwnd, _on_log) + _select_and_login(hwnd, suffix, _on_log) + except Exception: + try: + # wait=False:主流程已等待过窗口,此处单次枚举即可,避免失败后再空等宽限期。 + _save_error_screenshot(_find_game_hwnd(wait=False)) + except Exception: + pass + raise + finally: + _DIAGNOSTIC_PATH = None + logger.success(f"异环账号切换成功:****{suffix}") + return True + + +async def async_switch_account( + account_id: str, *, on_log: Callable[[str], None] | None = None +) -> bool: + """async 版本:在后台线程执行完整切换流程,避免阻塞事件循环。""" + return await asyncio.to_thread(account_switch, account_id, on_log=on_log) diff --git a/app/task/OkNte/tools/launcher_start.py b/app/task/OkNte/tools/launcher_start.py new file mode 100644 index 000000000..75346a68e --- /dev/null +++ b/app/task/OkNte/tools/launcher_start.py @@ -0,0 +1,457 @@ +# AUTO-MAS: A Multi-Script, Multi-Config Management and Automation Software +# Copyright © 2025-2026 AUTO-MAS Team +# +# This file is part of AUTO-MAS. +# +# AUTO-MAS is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of +# the License, or (at your option) any later version. +# +# AUTO-MAS is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty +# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See +# the GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with AUTO-MAS. If not, see . + +"""OK-NTE(异环)通过启动器拉起游戏。 + +异环客户端直接运行 HTGame.exe 会卡界面,必须经启动器(NTELauncher 下的 +NTEGame.exe / NTEGlobalGame.exe / NTETWGame.exe)启动。本模块对齐 ok-nte +上游 LauncherTask 的启动流程,交互与截图采用与账号切换一致的前台 +pyautogui + DPI 适配模式,OCR 复用通用工具集 `app.tools.ocr`。 + +流程:: + + 拉起启动器 → 等启动器窗口 → OCR 找「开始游戏」/「更新」按钮并点击 + (点「更新」后只点一次,等更新完成按钮变回「开始游戏」再点) + → 等 HTGame.exe 进程 + 可见窗口出现(游戏就绪,停在标题界面) +""" + +import asyncio +import ctypes +import time +from collections.abc import Callable +from contextlib import contextmanager +from datetime import datetime +from functools import lru_cache +from pathlib import Path + +import cv2 +import numpy as np +import psutil +from PIL import Image + +from app.tools.ocr import Box, OCRItem, ocr_image +from app.utils import get_logger +from app.utils.platform import IS_WINDOWS + +if IS_WINDOWS: + # pyautogui 与 pywin32 仅 Windows 可用(无图形会话导入即失败),随入口的 + # IS_WINDOWS 检查一并惰性导入 + import pyautogui + import win32api + import win32con + import win32gui + import win32process + +logger = get_logger("OK-NTE 启动器启动") + +# 游戏客户端与启动器进程名(对齐 ok-nte 上游 src/__init__.py) +_GAME_PROCESS = "HTGame.exe" +LAUNCHER_EXES = ("NTEGame.exe", "NTEGlobalGame.exe", "NTETWGame.exe") + +# 截图基准分辨率(16:9),OCR 与点击均在此坐标空间计算后再映射回真实窗口 +_FRAME_WIDTH = 1920 +_FRAME_HEIGHT = 1080 + +# 首次找到按钮的等待;点击「开始游戏」后等游戏起窗;点「更新」后等更新完成 +_FIND_BUTTON_TIMEOUT = 120 +_START_GAME_TIMEOUT = 300 +# 更新类等待上限:异环更新频繁且包体大,慢网下 30 分钟不够;对齐 ok-ww「MAS 下载 +# 不设总时限」的思路放宽到 2 小时。下载进行中时由下方下载状态检测持续动态续延, +# 该值仅为点击「更新」后尚未出现下载 UI 的兜底等待。 +_UPDATE_TIMEOUT = 7200 +# 启动器拉起后等其窗口创建的时限(对齐 ok-nte 上游 _wait_for_process 默认值) +_LAUNCHER_WINDOW_TIMEOUT = 120 +# 启动器点「确定」自重启后,等旧进程退出再找新窗口的缓冲 +_LAUNCHER_RESTART_QUIET_SECONDS = 3.0 + +# ── 启动器下载状态动态识别(基于 OCR,样本为真实下载界面)──────────────── +# 下载进行中的判定文本:底部状态行「... 下载中 0% (x/x) 当前速度 xx MB/s」 +# 与右下角「暂停下载」按钮(该按钮仅在下载进行中存在) +_DOWNLOAD_STATE_TEXTS = ("下载中", "暂停下载") +# 下载进度相关文本特征:用于构造「下载进度签名」,只看下载相关行, +# 避免首页横幅轮播等无关界面变化干扰卡死判定 +_DOWNLOAD_PROGRESS_TOKENS = ("%", "MB/s", "剩余时间") +# 检测到下载态时每次顺延的等待宽限(下载 UI 持续存在就持续等,等效不设总时限) +_UPDATE_ACTIVE_GRACE_SECONDS = 600.0 +# 下载进度签名持续无变化的时长上限:百分比/速度/剩余时间长时间不动视为下载卡死 +_DOWNLOAD_STALL_SECONDS = 300.0 + + +@lru_cache(maxsize=1) +def _user32_dpi_api(): + user32 = ctypes.windll.user32 + user32.SetThreadDpiAwarenessContext.argtypes = [ctypes.c_void_p] + user32.SetThreadDpiAwarenessContext.restype = ctypes.c_void_p + return user32 + + +@contextmanager +def _per_monitor_dpi(): + """切换到 per-monitor DPI 感知,保证窗口坐标换算在跨 DPI 显示器下正确。""" + user32 = _user32_dpi_api() + previous = user32.SetThreadDpiAwarenessContext(ctypes.c_void_p(-4)) + try: + yield + finally: + if previous: + user32.SetThreadDpiAwarenessContext(previous) + + +# ── 窗口 / 进程定位 ───────────────────────────────────────────────────── + + +def _process_name(pid: int) -> str | None: + """按 pid 读取进程名;提权进程可能被拒,返回 None 而非抛错。""" + try: + return psutil.Process(pid).name() + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + return None + + +def _window_area(hwnd: int) -> int: + try: + left, top, right, bottom = win32gui.GetWindowRect(hwnd) + return (right - left) * (bottom - top) + except Exception: + return 0 + + +def _find_process_hwnd(process_name: str) -> int | None: + """按所属进程名找最大可见窗口;不存在返回 None(区别于切号的必存抛错)。""" + candidates: list[int] = [] + + def _enum(hwnd: int, _lparam: int) -> bool: + try: + if not win32gui.IsWindowVisible(hwnd): + return True + except Exception: + return True + _, pid = win32process.GetWindowThreadProcessId(hwnd) + if pid and _process_name(pid) == process_name: + candidates.append(hwnd) + return True + + win32gui.EnumWindows(_enum, 0) + return max(candidates, key=_window_area) if candidates else None + + +def _find_game_hwnd() -> int | None: + return _find_process_hwnd(_GAME_PROCESS) + + +def _wait_launcher_hwnd( + launcher_path: Path, *, timeout: int | None = None +) -> int | None: + """轮询等待启动器窗口创建。 + + 启动器进程被拉起后窗口创建需要时间(单次查找会瞬时误判失败);窗口 + 也可能由其它区服启动器进程名承载,按候选名单兜底。 + """ + deadline = time.monotonic() + (timeout or _LAUNCHER_WINDOW_TIMEOUT) + while True: + hwnd = _find_process_hwnd(launcher_path.name) + if hwnd is None: + for exe in LAUNCHER_EXES: + hwnd = _find_process_hwnd(exe) + if hwnd is not None: + break + if hwnd is not None or time.monotonic() >= deadline: + return hwnd + time.sleep(2) + + +# ── 截图 / 交互(前台 pyautogui + DPI 适配)───────────────────────────── + + +def _activate_window(hwnd: int) -> None: + if not win32gui.IsWindow(hwnd): + raise RuntimeError("异环启动器窗口已失效") + show_command = ( + win32con.SW_RESTORE + if win32gui.IsIconic(hwnd) + else win32con.SW_SHOW if not win32gui.IsWindowVisible(hwnd) else None + ) + if show_command is not None: + win32gui.ShowWindow(hwnd, show_command) + time.sleep(0.15) + try: + if win32gui.GetForegroundWindow() != hwnd: + # Windows 前台锁:后台进程不能直接抢占前台。先附着当前前台窗口线程 + # 的输入队列,再置前,绕过系统限制(与 OK-NTE 切号同理)。 + foreground = win32gui.GetForegroundWindow() + fg_thread = win32process.GetWindowThreadProcessId(foreground)[0] + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, True + ) + try: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + finally: + win32process.AttachThreadInput( + win32api.GetCurrentThreadId(), fg_thread, False + ) + else: + win32gui.BringWindowToTop(hwnd) + win32gui.SetForegroundWindow(hwnd) + except win32gui.error: + logger.debug("异环启动器窗口焦点请求被系统忽略,继续按前置窗口处理") + time.sleep(0.1) + + +def _client_size(hwnd: int) -> tuple[int, int]: + _, _, width, height = win32gui.GetClientRect(hwnd) + if width <= 0 or height <= 0: + raise RuntimeError("异环启动器窗口尺寸异常") + return width, height + + +def _capture_window_image(hwnd: int, *, activate: bool = True) -> Image.Image: + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + left, top = win32gui.ClientToScreen(hwnd, (0, 0)) + virtual_left = win32api.GetSystemMetrics(win32con.SM_XVIRTUALSCREEN) + virtual_top = win32api.GetSystemMetrics(win32con.SM_YVIRTUALSCREEN) + return pyautogui.screenshot(allScreens=True).crop( + ( + left - virtual_left, + top - virtual_top, + left - virtual_left + width, + top - virtual_top + height, + ) + ) + + +def _capture_window(hwnd: int, *, activate: bool = True) -> np.ndarray: + screenshot = _capture_window_image(hwnd, activate=activate) + screenshot = screenshot.resize( + (_FRAME_WIDTH, _FRAME_HEIGHT), Image.Resampling.LANCZOS + ) + return cv2.cvtColor(np.asarray(screenshot), cv2.COLOR_RGB2BGR) + + +def _read_texts(hwnd: int) -> list[OCRItem]: + return ocr_image(_capture_window(hwnd, activate=False)) + + +def _click_box( + hwnd: int, box: Box, *, activate: bool = False, after_sleep: float = 0.3 +) -> None: + """点击 1080p 坐标空间中的一个文字框中心。""" + with _per_monitor_dpi(): + if activate: + _activate_window(hwnd) + width, height = _client_size(hwnd) + x, y, box_width, box_height = box + client_x = round((x + box_width / 2) * width / _FRAME_WIDTH) + client_y = round((y + box_height / 2) * height / _FRAME_HEIGHT) + screen_x, screen_y = win32gui.ClientToScreen(hwnd, (client_x, client_y)) + + original_position = pyautogui.position() + try: + pyautogui.moveTo(screen_x, screen_y) + time.sleep(0.3) + pyautogui.click() + time.sleep(after_sleep) + finally: + pyautogui.moveTo(*original_position) + + +# ── OCR 文本判定辅助 ───────────────────────────────────────────────────── + + +def _find_text(items: list[OCRItem], keywords: tuple[str, ...]) -> Box | None: + for text, box in items: + if any(keyword in text for keyword in keywords): + return box + return None + + +def _save_error_screenshot(launcher_hwnd: int | None) -> None: + """保存启动失败时的原始窗口截图,便于排查 OCR 文本漂移。""" + try: + screenshot_dir = Path.cwd() / "debug" / "oknte-launcher-start" + screenshot_dir.mkdir(parents=True, exist_ok=True) + screenshot_path = screenshot_dir / ( + f"launcher-error-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.png" + ) + target = launcher_hwnd if launcher_hwnd is not None else _find_game_hwnd() + if target is None: + return + _capture_window_image(target, activate=False).save( + screenshot_path, format="PNG" + ) + logger.warning(f"启动器启动错误截图已保存: {screenshot_path}") + except Exception as error: + # 截图是诊断旁路,失败时不能覆盖原始启动异常 + logger.warning(f"启动器启动错误截图保存失败: {error}") + + +# ── 对外入口 ───────────────────────────────────────────────────────────── + + +def start_game_via_launcher( + launcher_path: Path, *, on_log: Callable[[str], None] | None = None +) -> bool: + """通过启动器拉起异环游戏,直到 HTGame.exe 窗口出现(停在标题界面)。 + + 启动器按钮有概率是「更新」而非「开始游戏」(游戏有新版本时):点「更新」 + 后不再重复点击,等更新完成、按钮变回「开始游戏」后再点。 + + 启动器自身更新按弹窗分两级处理:「全新启动器现已推出」弹窗点「立即体验」 + 升级;更新完成后「更新已完成,请重新启动游戏」弹窗点「确定」重启启动器, + 并重新等待启动器窗口后继续走「开始游戏」流程。 + + 游戏下载进行中(「下载中」状态行 /「暂停下载」按钮)基于下载状态动态续延等待, + 下载多久等多久;同时以百分比/速度/剩余时间构造下载进度签名,长时间无变化 + 判定下载卡死提前失败。 + + Args: + launcher_path: 启动器 exe 路径(NTELauncher 下的启动器程序)。 + on_log: 流程进度回调(供 MAS 推送调度台日志),默认仅写日志。 + + Returns: + 游戏窗口就绪返回 True;失败抛出带原因描述的 RuntimeError。 + + Raises: + RuntimeError: 未找到启动器窗口 / 按钮点击失败 / 等待游戏窗口超时。 + """ + on_log = on_log or (lambda msg: logger.info(msg)) + if not IS_WINDOWS: + raise RuntimeError("OK-NTE 启动器启动仅支持 Windows 平台") + + try: + hwnd = _wait_launcher_hwnd(launcher_path) + if hwnd is None: + raise RuntimeError( + f"等待启动器窗口超时({_LAUNCHER_WINDOW_TIMEOUT}s 未找到进程 " + f"{launcher_path.name} 的窗口,若启动器弹出 UAC 请先确认)" + ) + _activate_window(hwnd) + + start_clicks = 0 + update_clicked = False + launcher_upgrade_clicked = False + last_download_sig: int | None = None + last_download_progress = time.monotonic() + deadline = time.monotonic() + _FIND_BUTTON_TIMEOUT + while time.monotonic() < deadline: + if _find_game_hwnd() is not None: + on_log("已检测到异环游戏窗口") + return True + + try: + items = _read_texts(hwnd) + except RuntimeError: + # 启动器点击「开始游戏」后最小化或退出,窗口失效:只等游戏起窗 + if start_clicks > 0: + time.sleep(2) + continue + raise + + now = time.monotonic() + + # 弹窗一:全新启动器推送 → 点「立即体验」升级启动器 + upgrade_box = _find_text(items, ("立即体验",)) + if upgrade_box is not None and not launcher_upgrade_clicked: + on_log("检测到「全新启动器现已推出」弹窗,点击「立即体验」升级启动器...") + _click_box(hwnd, upgrade_box, after_sleep=3) + launcher_upgrade_clicked = True + deadline = max(deadline, now + _UPDATE_TIMEOUT) + time.sleep(2) + continue + + # 弹窗二:启动器更新完成 → 点「确定」重启启动器,重新等窗口 + if _find_text(items, ("更新已完成", "重新启动游戏")) is not None: + confirm_box = _find_text(items, ("确定",)) + if confirm_box is not None: + on_log("启动器更新完成,点击「确定」重启启动器...") + _click_box(hwnd, confirm_box, after_sleep=3) + time.sleep(_LAUNCHER_RESTART_QUIET_SECONDS) + new_hwnd = _wait_launcher_hwnd(launcher_path) + if new_hwnd is None: + raise RuntimeError( + "启动器重启后未找到启动器窗口,请人工确认启动器状态" + ) + hwnd = new_hwnd + _activate_window(hwnd) + start_clicks = 0 + update_clicked = False + launcher_upgrade_clicked = False + time.sleep(2) + continue + + # 游戏下载进行中:基于下载状态动态续延等待,并以下载进度签名检测卡死 + if _find_text(items, _DOWNLOAD_STATE_TEXTS) is not None: + deadline = max(deadline, now + _UPDATE_ACTIVE_GRACE_SECONDS) + progress_sig = hash( + tuple( + text + for text, _ in items + if any(token in text for token in _DOWNLOAD_PROGRESS_TOKENS) + ) + ) + if progress_sig != last_download_sig: + last_download_sig = progress_sig + last_download_progress = now + elif now - last_download_progress >= _DOWNLOAD_STALL_SECONDS: + raise RuntimeError( + f"启动器下载长时间无进展({_DOWNLOAD_STALL_SECONDS:g}s 内" + "百分比/速度无变化,疑似卡住),请人工确认下载状态" + ) + time.sleep(2) + continue + + start_box = _find_text(items, ("开始游戏",)) + update_box = None if start_box else _find_text(items, ("更新",)) + if start_box is not None and start_clicks < 3: + on_log("点击启动器「开始游戏」") + _click_box(hwnd, start_box, after_sleep=3) + start_clicks += 1 + deadline = max(deadline, now + _START_GAME_TIMEOUT) + elif update_box is not None and not update_clicked: + on_log("检测到启动器「更新」按钮,正在更新游戏,等待时间将延长...") + _click_box(hwnd, update_box, after_sleep=3) + update_clicked = True + deadline = max(deadline, now + _UPDATE_TIMEOUT) + time.sleep(2) + + if _find_game_hwnd() is not None: + on_log("已检测到异环游戏窗口") + return True + raise RuntimeError( + "等待异环游戏窗口超时" + + ("(游戏更新可能未完成,请人工确认启动器状态)" if update_clicked else "") + ) + except Exception: + # 失败留启动器/游戏原图,供排查 OCR 文本漂移 + try: + _save_error_screenshot(_wait_launcher_hwnd(launcher_path, timeout=5)) + except Exception: + pass + raise + + +async def async_start_game_via_launcher( + launcher_path: Path, *, on_log: Callable[[str], None] | None = None +) -> bool: + """async 版本:在后台线程执行启动器交互,避免阻塞事件循环。""" + return await asyncio.to_thread( + start_game_via_launcher, launcher_path, on_log=on_log + ) diff --git a/frontend/electron/main.ts b/frontend/electron/main.ts index f6d234202..c4b8e1b11 100644 --- a/frontend/electron/main.ts +++ b/frontend/electron/main.ts @@ -37,6 +37,7 @@ import { decideRendererRecovery } from './rendererCrashRecovery' import { getLogger, initializeLogger } from './services/logger' import { createMaaEndIssueReport } from './services/maaEndIssueReportService' import { createOkwwIssueReport } from './services/okwwIssueReportService' +import { createOkNteIssueReport } from './services/okNteIssueReportService' import { captureMainRendererCrash, configureMainSentry, @@ -1220,6 +1221,12 @@ registerIssueReportExporter( 'OK-WW-logs', createOkwwIssueReport ) +registerIssueReportExporter( + 'oknte:exportIssueReport', + '导出 OK-NTE 问题包', + 'OK-NTE-logs', + createOkNteIssueReport +) ipcMain.handle('data:backup', async () => { let partialPath: string | undefined diff --git a/frontend/electron/preload.ts b/frontend/electron/preload.ts index be5177744..6fd486b65 100644 --- a/frontend/electron/preload.ts +++ b/frontend/electron/preload.ts @@ -127,6 +127,7 @@ contextBridge.exposeInMainWorld('electronAPI', { exportLogs: () => ipcRenderer.invoke('log:export'), exportMaaEndIssueReport: () => ipcRenderer.invoke('maaend:exportIssueReport'), exportOkwwIssueReport: () => ipcRenderer.invoke('okww:exportIssueReport'), + exportOkNteIssueReport: () => ipcRenderer.invoke('oknte:exportIssueReport'), exportDataBackup: () => ipcRenderer.invoke('data:backup'), getLogs: (lines?: number, fileName?: string) => ipcRenderer.invoke('log:getContent', lines, fileName), diff --git a/frontend/electron/services/okNteIssueReportService.ts b/frontend/electron/services/okNteIssueReportService.ts new file mode 100644 index 000000000..57a5e60b8 --- /dev/null +++ b/frontend/electron/services/okNteIssueReportService.ts @@ -0,0 +1,98 @@ +import * as fs from 'fs' +import * as path from 'path' +import AdmZip = require('adm-zip') + +import { getLogger } from './logger' +import { + CollectorState, + Installation, + addDiagnosticFile, + addDirectory, + addLatestMasHistoryLog, + discoverInstallations, + resolveDataRoots, +} from './issueReportCore' + +const logger = getLogger('OK-NTE问题包') + +// 与 app/task/OkNte/AutoProxy.py 的 script_log_path 默认值保持同步 +const OKNTE_REL_LOG_FILE = 'data/apps/ok-nte/working/logs/ok-script.log' + +function addLatestOkNteScriptLog( + state: CollectorState, + installations: Installation[] +): void { + let latest: { sourcePath: string; archivePath: string; mtimeMs: number } | undefined + + for (const installation of installations) { + const logPath = path.join(installation.rootPath, ...OKNTE_REL_LOG_FILE.split('/')) + try { + const mtimeMs = fs.statSync(logPath).mtimeMs + if (!latest || mtimeMs > latest.mtimeMs) { + latest = { + sourcePath: logPath, + archivePath: `oknte/${installation.label}/ok-script.log`, + mtimeMs, + } + } + } catch (error) { + logger.debug(`读取 ok-script.log 失败: ${logPath}, ${String(error)}`) + } + } + + if (latest) { + addDiagnosticFile(state, latest.sourcePath, latest.archivePath) + } +} + +export interface OkNteIssueReportResult { + success: boolean + message?: string + zipPath?: string + error?: string +} + +export function createOkNteIssueReport(appRoot: string, zipPath: string): OkNteIssueReportResult { + const zip = new AdmZip() + const state: CollectorState = { zip, entries: [], archiveBytes: 0 } + const dataRoots = resolveDataRoots(appRoot) + const installations = discoverInstallations(dataRoots, { + configType: 'OkNteConfig', + pathField: 'RootPath', + labelPrefix: 'oknte', + }) + addLatestMasHistoryLog(state, dataRoots) + + dataRoots.forEach((dataRoot, index) => { + addDirectory( + state, + path.join(dataRoot, 'debug'), + index === 0 ? 'logs/auto-mas' : 'logs/auto-mas/backend' + ) + }) + + const runtimeDebugDir = path.join(path.dirname(process.execPath), 'debug') + const knownDebugDirs = new Set(dataRoots.map(dataRoot => path.resolve(dataRoot, 'debug'))) + if (!knownDebugDirs.has(path.resolve(runtimeDebugDir))) { + addDirectory(state, runtimeDebugDir, 'logs/frontend-runtime') + } + + addLatestOkNteScriptLog(state, installations) + + try { + fs.mkdirSync(path.dirname(zipPath), { recursive: true }) + zip.writeZip(zipPath) + logger.info(`OK-NTE 问题包已导出: ${zipPath}`) + return { + success: true, + message: `OK-NTE 问题包导出成功,已收集 ${state.entries.filter(entry => entry.status !== 'skipped').length} 个文件`, + zipPath, + } + } catch (error) { + logger.error(`OK-NTE 问题包导出失败: ${String(error)}`) + return { + success: false, + error: error instanceof Error ? error.message : String(error), + } + } +} diff --git a/frontend/src/api/models/OkNteConfig_Game.ts b/frontend/src/api/models/OkNteConfig_Game.ts index 0e154e47a..bb1847dd8 100644 --- a/frontend/src/api/models/OkNteConfig_Game.ts +++ b/frontend/src/api/models/OkNteConfig_Game.ts @@ -15,7 +15,7 @@ export type OkNteConfig_Game = { */ Type?: ('Client' | 'URL' | null); /** - * 游戏程序路径 + * 游戏启动器路径(NTELauncher/NTEGame.exe,直启 HTGame.exe 会卡界面) */ Path?: (string | null); /** @@ -46,5 +46,9 @@ export type OkNteConfig_Game = { * 任务结束后是否关闭游戏 */ CloseOnFinish?: (boolean | null); + /** + * 运行前强制切换账号(需启用游戏配置;用户未填手机号时不切换) + */ + AccountSwitch?: (boolean | null); }; diff --git a/frontend/src/composables/useOkNteIssueReport.ts b/frontend/src/composables/useOkNteIssueReport.ts new file mode 100644 index 000000000..9c26c0de4 --- /dev/null +++ b/frontend/src/composables/useOkNteIssueReport.ts @@ -0,0 +1,11 @@ +import { useIssueReport } from './useIssueReport' +import type { ReportLogger } from './useIssueReport' + +export function useOkNteIssueReport(logger: ReportLogger) { + const { exporting, exportIssueReport } = useIssueReport(logger, { + label: 'OK-NTE', + fallbackName: 'OK-NTE-logs-*.zip', + exportFn: () => window.electronAPI?.exportOkNteIssueReport?.(), + }) + return { exporting, exportOkNteIssueReport: exportIssueReport } +} diff --git a/frontend/src/i18n/locales/en-US.ts b/frontend/src/i18n/locales/en-US.ts index 2c609bb81..982848e88 100644 --- a/frontend/src/i18n/locales/en-US.ts +++ b/frontend/src/i18n/locales/en-US.ts @@ -960,7 +960,7 @@ export default { nativeTaskConfigurationHas: 'The native task configuration has not been read yet', importedFromCurrentSra: 'Imported from the current SRA / March7th Assistant configuration', scriptLevelMaaendConfiguration: 'Script-level MaaEnd configuration started', - gamePathMatchedHtgame: 'Game path matched to HTGame.exe automatically', + gamePathMatchedHtgame: 'Game path matched to NTEGame.exe launcher automatically', applyPreset2: 'Apply the preset', turnThisOffWhen: 'Turn this off when the script controls the game directly and you use cloud gaming', @@ -1251,6 +1251,8 @@ export default { pickStarRailInstall: 'Pick the Star Rail install directory (contains StarRail.exe)', pickGameExecutable: 'Pick the game executable', pickGameRootDirectory: 'Pick the game root directory (HTGame.exe is matched automatically)', + pickGameLauncherDirectory: + 'Pick the game root directory (NTEGame.exe is matched automatically)', pickGameExecutable2: 'Pick the game executable', pickScriptSMain: "Pick the script's main program file", pickScriptRootDirectory2: 'Pick the script root directory', @@ -2660,8 +2662,10 @@ export default { 'Export this backup before MAS runs into something unrecoverable. Once saved you can reinstall safely — important data will not be lost.', logSection: 'MAS log export', exportLog: 'Export a log archive', - maaEndSection: 'MaaEnd log bundle', exportMaaEnd: 'Export a MaaEnd issue bundle', + issueSection: 'Specialized issue bundles', + exportOkww: 'Export an OK-WW issue bundle', + exportOkNte: 'Export an OK-NTE issue bundle', devSection: 'Developer options', openDevTools: 'Open DevTools', }, diff --git a/frontend/src/i18n/locales/ja-JP.ts b/frontend/src/i18n/locales/ja-JP.ts index 075e9279f..c550f95a9 100644 --- a/frontend/src/i18n/locales/ja-JP.ts +++ b/frontend/src/i18n/locales/ja-JP.ts @@ -960,7 +960,7 @@ export default { nativeTaskConfigurationHas: 'ネイティブのタスク設定はまだ読み込まれていません', importedFromCurrentSra: '現在の SRA / 三月なのかアシスタントの設定からインポートしました', scriptLevelMaaendConfiguration: 'スクリプト単位の MaaEnd 設定を開始しました', - gamePathMatchedHtgame: 'ゲームのパスを HTGame.exe に自動で合わせました', + gamePathMatchedHtgame: 'ゲームのパスを NTEGame.exe ランチャーに自動で合わせました', applyPreset2: 'プリセットを適用', turnThisOffWhen: 'スクリプト直接制御でクラウドゲームを使う場合は、このスイッチをオフにすることをおすすめします', @@ -1255,6 +1255,8 @@ export default { pickGameExecutable: 'ゲームの実行ファイルを選択してください', pickGameRootDirectory: 'ゲームのルートフォルダを選択してください(HTGame.exe は自動で照合されます)', + pickGameLauncherDirectory: + 'ゲームのルートフォルダを選択してください(NTEGame.exe は自動で照合されます)', pickGameExecutable2: 'ゲームの実行ファイルを選択してください', pickScriptSMain: 'スクリプトのメインプログラムを選択してください', pickScriptRootDirectory2: 'スクリプトのルートフォルダを選択してください', @@ -2716,8 +2718,10 @@ export default { 'MAS が復旧できない問題に遭遇する前に、このバックアップを書き出しておいてください。保存しておけば安心して再インストールでき、重要なデータが失われることはありません。', logSection: 'MAS 本体のログ書き出し', exportLog: 'ログのアーカイブを書き出す', - maaEndSection: 'MaaEnd のログパッケージ', exportMaaEnd: 'MaaEnd の問題報告パッケージを書き出す', + issueSection: '専用の問題報告パッケージ', + exportOkww: 'OK-WW の問題報告パッケージを書き出す', + exportOkNte: 'OK-NTE の問題報告パッケージを書き出す', devSection: '開発者向けオプション', openDevTools: '開発者ツールを開く', }, diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index c07fa5842..38c8eddff 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -915,7 +915,7 @@ export default { nativeTaskConfigurationHas: '尚未读取到原生任务配置', importedFromCurrentSra: '已从当前 SRA / 三月七助手源配置导入', scriptLevelMaaendConfiguration: '已启动脚本级 MaaEnd 配置', - gamePathMatchedHtgame: '已自动匹配游戏路径至 HTGame.exe', + gamePathMatchedHtgame: '已自动匹配游戏路径至 NTEGame.exe 启动器', applyPreset2: '应用预设', turnThisOffWhen: '建议在脚本直控且使用云游戏的情况下关闭此开关', on: '开启', @@ -1180,6 +1180,7 @@ export default { pickStarRailInstall: '请选择星穹铁道安装目录(含 StarRail.exe)', pickGameExecutable: '请选择游戏可执行文件', pickGameRootDirectory: '请选择游戏根目录(自动匹配到 HTGame.exe)', + pickGameLauncherDirectory: '请选择游戏根目录(自动匹配到 NTEGame.exe)', pickGameExecutable2: '请选择游戏的可执行文件', pickScriptSMain: '请选择脚本主程序文件', pickScriptRootDirectory2: '请选择脚本根目录', @@ -2567,8 +2568,10 @@ export default { '当 MAS 遇到无法恢复的问题时,可先导出此备份。保存后即可放心重装软件,重要数据不会因重装而丢失。', logSection: 'MAS 本体日志导出', exportLog: '导出日志压缩包', - maaEndSection: 'MaaEnd 日志包导出', exportMaaEnd: '导出 MaaEnd 问题包', + issueSection: '专项问题包导出', + exportOkww: '导出 OK-WW 问题包', + exportOkNte: '导出 OK-NTE 问题包', devSection: '开发者选项', openDevTools: '打开开发者工具', }, diff --git a/frontend/src/types/electron.d.ts b/frontend/src/types/electron.d.ts index d6c051c04..aec041497 100644 --- a/frontend/src/types/electron.d.ts +++ b/frontend/src/types/electron.d.ts @@ -160,6 +160,12 @@ export interface ElectronAPI { zipPath?: string error?: string }> + exportOkNteIssueReport: () => Promise<{ + success: boolean + message?: string + zipPath?: string + error?: string + }> exportDataBackup: () => Promise<{ success: boolean message?: string diff --git a/frontend/src/views/EditView/Script/OkNteScriptEdit.vue b/frontend/src/views/EditView/Script/OkNteScriptEdit.vue index 66300a40f..5ac7f7265 100644 --- a/frontend/src/views/EditView/Script/OkNteScriptEdit.vue +++ b/frontend/src/views/EditView/Script/OkNteScriptEdit.vue @@ -90,7 +90,7 @@

{{ t('edit.gameConfiguration') }}

- +