commit de064272ec89668e9153f97c2e352e576a6bac09 Author: RRRosmontis Date: Wed Sep 16 11:11:35 2026 +0800 feat: publish sanitized Linux cloud-game MAA adapter diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..af93633 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# Credentials and login state +token +token* +.pending-login +backend/token +backend/token* +backend/.pending-login +.env + +# Generated user/runtime state +runtime/ +config/profiles/cloud.toml +config/tasks/daily.toml +user_config/daily.toml +user_config/backups/ +tests/fake-actions.jsonl +artifacts/ + +# Python +__pycache__/ +*.py[cod] +.venv/ +venv/ + +# Logs and editor files +*.log +*.tmp +*.bak +.DS_Store +.idea/ +.vscode/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..48dc4d8 --- /dev/null +++ b/README.md @@ -0,0 +1,180 @@ +# maa-online + +在 Linux 无图形服务器上,通过网易云游戏运行《明日方舟》,并让官方 MAA/maa-cli 执行日常任务的实验性适配层。 + +> 本项目调用网易云游戏未公开的 HTTP、WebSocket 与 WebRTC 协议,可能随平台更新失效,也可能不符合平台服务条款。请仅用于个人研究,自行承担账号及服务风险;禁止多账号、商业代练或规避平台限制。 + +## 上游项目 + +本仓库是在以下开源项目及实验原型基础上整理的适配代码,不包含完整第三方源码、官方 MAA 二进制或任何用户凭据: + +- **MAA 官方仓库(AGPL-3.0)** + https://github.com/MaaAssistantArknights/MaaAssistantArknights +- **maa-cli 官方仓库** + https://github.com/MaaAssistantArknights/maa-cli +- **网易云游戏后端原型(Apache-2.0)** + https://github.com/Tokisaki-Galaxy/netease_cloud_game_server +- **后端使用的网易云游戏 SDK fork(Apache-2.0)** + https://github.com/Tokisaki-Galaxy/netease_cloud_game_sdk +- **MAA 云游戏实验分支** + https://github.com/Tokisaki-Galaxy/MaaAssistantArknights/tree/support-cloudgame +- **最初的网易云游戏 SDK 原型(Apache-2.0)** + https://github.com/wupco/netease_cloud_game_sdk +- **MAA 社区实验讨论** + https://github.com/orgs/MaaAssistantArknights/discussions/14404 + +`backend/` 与 `backend/sdk/` 分别保留了相应上游的 Apache-2.0 LICENSE。MAA 和 maa-cli 由安装脚本从官方渠道下载,其许可证仍以各自上游为准。 + +## 工作原理 + +```text +官方 maa-cli / MaaCore + │ 标准 ADB 命令 + ▼ + bin/adb-cloud + │ loopback HTTP + ▼ +backend/server.py + │ 网易 HTTP + WebSocket + WebRTC + ▼ + 网易云游戏中的明日方舟 +``` + +没有修改官方 MaaCore。`adb-cloud` 将 MaaCore 的截图、点击、滑动、文字输入和启动/停止命令转换成本地 HTTP 请求,因此官方 `maa self update`、`maa update` 与 `maa hot-update` 仍可使用。 + +## 环境要求 + +- Linux x86_64(其他架构需自行验证) +- Python 3.10+ +- `python3-venv`、Git、curl +- 可访问网易云游戏和 GitHub +- 已完成实名认证的网易云游戏账号 +- 已有或可登录的《明日方舟》官服账号 + +## 安装 + +```bash +git clone https://git.rrrosmontis.icu/RoyirinRosmontis/maa-online.git +cd maa-online +./install.sh +``` + +安装脚本会在仓库内部创建被 Git 忽略的 `runtime/`,安装 Python 依赖、官方 maa-cli、官方 MaaCore 和资源;不会下载或保存本仓库维护者的账号数据。 + +## 登录网易云游戏 + +```bash +runtime/venv/bin/python backend/login.py request <手机号> +runtime/venv/bin/python backend/login.py verify <短信验证码> +``` + +生成的 token 位于 `backend/token`,权限为 `0600`,且已被 `.gitignore` 排除。不要将 token、手机号、验证码、身份证信息、游戏截图或日志提交到 Git。 + +实名认证请在网易云游戏网页或 App 中自行完成,不要通过 Issue、聊天或配置文件提供身份证信息。 + +## 启动与运行 + +终端一: + +```bash +bin/maa-online-server +``` + +终端二: + +```bash +curl -X POST http://127.0.0.1:22888/start +bin/maa-online startup Official --batch -v +``` + +编辑日常配置: + +```bash +bin/maa-online-config +``` + +验证配置: + +```bash +bin/maa-online run daily --dry-run --batch +``` + +执行日常: + +```bash +bin/maa-online run daily --batch -v +``` + +退出云游戏: + +```bash +curl -X POST http://127.0.0.1:22888/exit +``` + +服务默认仅监听 `127.0.0.1:22888`,不要直接暴露到公网。 + +## 默认日常模板 + +`user_config/daily.example.toml` 是脱敏示例。首次安装时复制为被 Git 忽略的 `user_config/daily.toml`,包括: + +- 基建一键轮换; +- 自动公招; +- 刷 `1-7` 清理理智; +- 信用商店与好友访问; +- 日常、周常及邮件奖励; +- 完成后退出云游戏。 + +示例默认不吃理智药、不碎石、不使用加急许可。运行前务必检查 `stage`、公招次数、信用商店列表和邮件领取选项。全部参数说明见 [`user_config/README.md`](user_config/README.md)。 + +## 更新 + +```bash +bin/maa-online-update +``` + +该脚本更新官方 maa-cli、MaaCore、基础资源和热更新资源。适配代码位于官方管理目录之外,不会被 `maa update` 清理。 + +## 离线测试 + +启动假后端: + +```bash +runtime/venv/bin/python tests/fake_backend.py +``` + +另一个终端验证官方 MaaCore → ADB 适配层: + +```bash +LD_LIBRARY_PATH="$PWD/runtime/home/.local/share/maa/lib" \ + runtime/venv/bin/python tests/smoke_maa_connect.py +``` + +预期: + +```text +MaaCore -> adb-cloud -> fake backend: PASS +``` + +## 已知限制 + +- 网易私有协议可能随时变化; +- 云视频压缩与网络抖动会降低 MAA 图像识别稳定性; +- 高频截图在低性能服务器上较慢,基建换班可能耗时较长; +- Home/ESC 等键值尚未完整验证; +- 断线自动恢复仍有限; +- 后端可能出现少量 H.264 解码丢包警告; +- 游戏自动化会真实消耗招聘许可、理智、信用等资源,请先 dry-run 并审阅配置。 + +## 安全与脱敏 + +公开仓库只应保留代码和示例配置。以下内容已明确排除: + +- 网易 token、短信验证码、手机号及实名信息; +- 明日方舟账号、角色 ID、截图及游戏日志; +- Python 虚拟环境、MAA 二进制、MaaCore 和资源文件; +- 本地运行产物、备份、缓存和真实 `daily.toml`; +- 上游项目的完整 Git 工作树。 + +## 许可证 + +本仓库自有代码按 Apache License 2.0 发布,详见 [`LICENSE`](LICENSE)。派生文件及第三方组件同时受对应上游许可证约束。 diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000..a8f0a0c --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,41 @@ +# Third-party notices + +This repository contains modified files derived from third-party projects and installs additional components at runtime. + +## Modified and vendored source + +### NetEase Cloud Game API Server for MAA + +- Upstream: https://github.com/Tokisaki-Galaxy/netease_cloud_game_server +- Base revision used during development: `5fabe39055e7d918830773fa38c53af27494b449` +- License: Apache License 2.0 +- Local copy: `backend/` +- Changes include Linux/headless login bootstrap, connection readiness handling, PNG screenshots, validation, timeout/error handling, real frame dimensions and lifecycle fixes. +- Original license is preserved in `backend/LICENSE`. + +### NetEase Cloud Game SDK + +- Upstream: https://github.com/Tokisaki-Galaxy/netease_cloud_game_sdk +- SDK revision used during development: `3829b4933fafded6ec8c275ae352f4e9774a699b` +- Original project: https://github.com/wupco/netease_cloud_game_sdk +- License: Apache License 2.0 +- Local copy: `backend/sdk/wsconnect.py` +- Changes include non-blocking HTTP calls, request timeouts, safe token handling, resolution/codec propagation, corrected exit URL and structured protocol errors. +- Original license is preserved in `backend/sdk/LICENSE`. + +## Runtime dependencies (not vendored) + +The installation process downloads these components from their official upstreams. Their own licenses apply: + +- MAA: https://github.com/MaaAssistantArknights/MaaAssistantArknights — AGPL-3.0-only and project terms +- maa-cli: https://github.com/MaaAssistantArknights/maa-cli — see upstream license +- MaaResource: https://github.com/MaaAssistantArknights/MaaResource — see upstream license and game-asset notices +- aiohttp: https://github.com/aio-libs/aiohttp +- aiortc: https://github.com/aiortc/aiortc +- PyAV: https://github.com/PyAV-Org/PyAV +- NumPy: https://github.com/numpy/numpy +- Pillow: https://github.com/python-pillow/Pillow +- Requests: https://github.com/psf/requests +- websockets: https://github.com/python-websockets/websockets + +This project is not affiliated with or endorsed by NetEase, Hypergryph, Studio Montagne, MAA Team, or the maintainers of the upstream prototypes. Product names and trademarks belong to their respective owners. diff --git a/backend/LICENSE b/backend/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/backend/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/backend/login.py b/backend/login.py new file mode 100755 index 0000000..d8ef2a8 --- /dev/null +++ b/backend/login.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Bootstrap a NetEase Cloud Game token without blocking the API server.""" + +from __future__ import annotations + +import argparse +import json +import os +from pathlib import Path + +import requests + +BASE_DIR = Path(__file__).resolve().parent +TOKEN_FILE = Path(os.getenv("MAA_ONLINE_TOKEN_FILE", str(BASE_DIR / "token"))) +PENDING_FILE = Path(os.getenv("MAA_ONLINE_PENDING_LOGIN_FILE", str(BASE_DIR / ".pending-login"))) +API_BASE = "https://n.cg.163.com/api/v1" + + +def write_private(path: Path, value: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + flags = os.O_WRONLY | os.O_CREAT | os.O_TRUNC + fd = os.open(path, flags, 0o600) + try: + os.write(fd, value.encode("utf-8")) + finally: + os.close(fd) + os.chmod(path, 0o600) + + +def normalize_phone(phone: str, country_code: str) -> tuple[str, str]: + phone = phone.strip().replace(" ", "") + country_code = country_code.strip().lstrip("+") + if not phone.isdigit() or not country_code.isdigit(): + raise ValueError("phone and country code must contain digits only") + return country_code, phone + + +def request_code(phone: str, country_code: str) -> None: + country_code, phone = normalize_phone(phone, country_code) + response = requests.post( + f"{API_BASE}/phone-captchas/{country_code}-{phone}", + timeout=15, + ) + response.raise_for_status() + write_private(PENDING_FILE, json.dumps({"country_code": country_code, "phone": phone})) + print("SMS code requested. Run: python login.py verify ") + + +def verify_code(code: str) -> None: + if not PENDING_FILE.is_file(): + raise RuntimeError("no pending login; run the request command first") + pending = json.loads(PENDING_FILE.read_text(encoding="utf-8")) + code = code.strip() + if not code: + raise ValueError("verification code must not be empty") + + payload = { + "auth_method": "phone-captcha", + "ctcode": pending["country_code"], + "phone": pending["phone"], + "captcha": code, + "device_info": { + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140 Safari/537.36", + "appVersion": "5.0 (X11)", + "codecs": ["h264", "vp8", "vp9"], + }, + } + response = requests.post(f"{API_BASE}/tokens", json=payload, timeout=15) + response.raise_for_status() + token = response.json().get("token") + if not token: + raise RuntimeError("login response did not contain a token") + + write_private(TOKEN_FILE, token) + PENDING_FILE.unlink(missing_ok=True) + print(f"Login token saved securely to {TOKEN_FILE}") + + +def status() -> None: + if TOKEN_FILE.is_file() and TOKEN_FILE.stat().st_size: + print(f"token: present ({oct(TOKEN_FILE.stat().st_mode & 0o777)})") + else: + print("token: missing") + print(f"pending login: {'yes' if PENDING_FILE.is_file() else 'no'}") + + +def main() -> None: + parser = argparse.ArgumentParser() + sub = parser.add_subparsers(dest="command", required=True) + request_parser = sub.add_parser("request", help="request an SMS verification code") + request_parser.add_argument("phone") + request_parser.add_argument("--country-code", default="86") + verify_parser = sub.add_parser("verify", help="exchange an SMS code for a token") + verify_parser.add_argument("code") + sub.add_parser("status", help="show credential state without revealing secrets") + args = parser.parse_args() + + if args.command == "request": + request_code(args.phone, args.country_code) + elif args.command == "verify": + verify_code(args.code) + else: + status() + + +if __name__ == "__main__": + main() diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..2da31fa --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,7 @@ +aiohttp>=3.10,<4 +requests>=2.31,<3 +aiortc>=1.9,<2 +av>=12.3,<17 +numpy>=1.26,<3 +pillow>=10.3,<13 +websockets>=12,<16 diff --git a/backend/sdk/LICENSE b/backend/sdk/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/backend/sdk/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/backend/sdk/requirements.txt b/backend/sdk/requirements.txt new file mode 100644 index 0000000..4fa1d65 --- /dev/null +++ b/backend/sdk/requirements.txt @@ -0,0 +1,6 @@ +aiortc==1.9.0 +aioice==0.9.0 +av==12.3.0 +numpy>=1.26.0 +pillow>=10.3.0 +websockets>=12.0 diff --git a/backend/sdk/wsconnect.py b/backend/sdk/wsconnect.py new file mode 100644 index 0000000..15f040c --- /dev/null +++ b/backend/sdk/wsconnect.py @@ -0,0 +1,234 @@ +import asyncio +import websockets +import base64 +import time +import json +import requests +from aiortc.sdp import candidate_from_sdp, candidate_to_sdp +from aiortc.contrib.signaling import object_from_string, object_to_string +from aiortc import RTCIceCandidate, RTCPeerConnection, RTCSessionDescription +from aiortc.contrib.signaling import BYE, add_signaling_arguments, create_signaling +from aiortc.contrib.media import MediaBlackhole, MediaPlayer, MediaRecorder, MediaRelay + +relay = MediaRelay() +sub_key = None + +def login(phonenumber, code=None, token_file="token"): + """Request an SMS code (when code is None) or exchange it for a token.""" + if "-" not in phonenumber: + raise ValueError("phone number must use the '-' form") + + if code is None: + response = requests.post( + "https://n.cg.163.com/api/v1/phone-captchas/" + phonenumber, + timeout=15, + ) + response.raise_for_status() + print("input the code received by your phone") + code = input().strip() + + country_code, phone = phonenumber.split("-", 1) + headers = {"Content-Type": "application/json;charset=utf-8"} + data = { + "auth_method": "phone-captcha", + "ctcode": country_code, + "phone": phone, + "captcha": str(code).strip(), + "device_info": { + "userAgent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 Chrome/140 Safari/537.36", + "appVersion": "5.0 (X11)", + "codecs": ["h264", "vp8", "vp9"], + }, + } + response = requests.post( + "https://n.cg.163.com/api/v1/tokens", + headers=headers, + json=data, + timeout=15, + ) + response.raise_for_status() + user_obj = response.json() + token = user_obj.get("token") + if not token: + raise RuntimeError("login response did not contain a token") + + with open(token_file, "w", encoding="utf-8") as token_handle: + token_handle.write(token) + try: + import os + os.chmod(token_file, 0o600) + except OSError: + pass + print("login ok!") + return token + + +def encode_mess(message): + global sub_key + mess = str.encode(message) + m = b"" + for i in mess: + m += ((i+sub_key)%256).to_bytes(1, byteorder='big') + res = base64.b64encode(m) + return res + +def decode_mess(message): + try: + mess = base64.b64decode(message) + except: + return message + global sub_key + if sub_key == None: + fbyte = mess[0] + sbyte = mess[1] + for j in range(0, 256): + if(chr((fbyte-j)%256)=="[" and chr((sbyte-j)%256) == "{" and chr((mess[2]-j)%256) == "\""): + sub_key = j + break + if chr((fbyte-j)%256) == "{" and chr((sbyte-j)%256) == "\"": + sub_key = j + break + m = "" + for i in mess: + m += chr((i-sub_key)%256) + return m + +def get_basic_info(token): + headers = {"Authorization": "Bearer " + token} + response = requests.get( + "https://n.cg.163.com/api/v2/users/@me", + headers=headers, + timeout=15, + ) + response.raise_for_status() + info = json.loads(decode_mess(response.text)) + return info["yunxin_account"]["accid"] + +def request_ticket(token, game_code, regions=None, codecs=None, width=1280, height=720): + if regions is None: + regions = ["hdcz", "hbsjz"] + if codecs is None: + codecs = ["h264", "vp8", "vp9"] + req_obj = { + "regions": regions, + "game_code": game_code, + "codecs": codecs, + "width": width, + "height": height + } + global sub_key + + req_str = encode_mess(json.dumps(req_obj).replace("'","\"")) + headers = { + "Authorization": "Bearer " +token, + "Content-Type": "application/octet-stream" + } + response = requests.post( + "https://n.cg.163.com/api/v2/tickets", + headers=headers, + data=req_str, + timeout=15, + ) + if not response.ok: + try: + detail = decode_mess(response.text) + except Exception: + detail = "" + raise RuntimeError(f"ticket request failed with HTTP {response.status_code}: {detail[:500]}") + info_obj = json.loads(decode_mess(response.text)) + return info_obj["gateway_url"] + +def find_region(token, game_code): + headers = {"Authorization": "Bearer " + token} + response = requests.get( + "https://n.cg.163.com/api/v2/media-servers", + params={"game_code": game_code}, + headers=headers, + timeout=15, + ) + response.raise_for_status() + regions = json.loads(decode_mess(response.text)) + return [item["region"] for item in regions] + +def exit_game(token, game_code): + headers = {"Authorization": "Bearer " + token} + response = requests.delete( + f"https://n.cg.163.com/api/v2/users/@me/games-playing/{game_code}", + headers=headers, + timeout=15, + ) + response.raise_for_status() + return response.text + +async def connect(token, game_code, w=1280, h=720, quality="high", codecs=None, platform=0, fps="30"): + if codecs is None: + codecs = ["h264", "vp8", "vp9"] + user_id = await asyncio.to_thread(get_basic_info, token) + regions = await asyncio.to_thread(find_region, token, game_code) + uri = await asyncio.to_thread( + request_ticket, + token, + game_code, + regions, + codecs, + w, + h, + ) + websocket = await websockets.connect(uri) + auth_obj = { + "id": str(int(round(time.time() * 1000))), + "op": "auth", + "data": { + "user_id": user_id, + "token": token, + "game_code": game_code, + "w": w, + "h": h, + "quality": quality, + "codecs": codecs, + "platform": platform, + "fps": fps + } + } + auth_str = json.dumps(auth_obj) + await websocket.send(auth_str) + auth_res = await websocket.recv() + auth_res = decode_mess(auth_res) + try: + de_res = json.loads(auth_res) + except (TypeError, json.JSONDecodeError) as exc: + await websocket.close() + raise RuntimeError("failed to decode cloud-game auth response") from exc + if de_res.get("op") != "offer": + error_message = de_res.get("data", {}).get("errmsg", "unexpected signaling response") + await websocket.close() + raise RuntimeError(error_message) + new_sdp = {} + new_sdp["type"] = de_res["op"] + new_sdp["sdp"] = de_res["data"]["sdp"] + new_sdp = json.dumps(new_sdp) + return new_sdp, websocket + + +def channel_log(channel, t, message): + print("channel(%s) %s %s" % (channel.label, t, message)) + +async def send_action(sock, action): + + action = encode_mess(json.dumps(action).replace("'","\"")) + await sock.send(action) + pong_waiter = await sock.ping() + await pong_waiter + +def pack_message(cmd, data): + if cmd == "mm": # move mouse, data: x, y + action = {"id":str(int(round(time.time() * 1000))),"op":"input","data":{"cmd":"1 %d %d 0" % (data["x"], data["y"])}} + elif cmd == "cm": # click mouse, data: x, y + action = {"id":str(int(round(time.time() * 1000))),"op":"input","data":{"cmd":"3 %d %d 0" % (data["x"], data["y"])}} + elif cmd == "ip": # keyboard input, data: single keyborad word + action = {"id":str(int(round(time.time() * 1000))),"op":"input","data":{"cmd":"5 %s" % data["word"]}} + else: + action = {} + return action + +#print(decode_mess("2YDHwoCYgI+VlI6SlpeTk4+Uj5eAioDNzoCYgMfMztPSgIqAwr/Sv4CY2YDBy8KAmICRfpaOln6TkZB+joDb2w==")) \ No newline at end of file diff --git a/backend/server.py b/backend/server.py new file mode 100644 index 0000000..a70b7a2 --- /dev/null +++ b/backend/server.py @@ -0,0 +1,573 @@ +import asyncio +import json +import time +import sys +import signal +import platform +import logging +import base64 +import os +from pathlib import Path +from typing import Optional, Coroutine +try: + from websockets.legacy.client import WebSocketClientProtocol +except ImportError: # websockets >=12 removed legacy package + from websockets.client import WebSocketClientProtocol # type: ignore[attr-defined] +from io import BytesIO + +import requests +from aiohttp import web + +from aiortc import RTCPeerConnection, RTCSessionDescription +from aiortc.contrib.media import MediaRelay +from sdk.wsconnect import ( + connect, object_from_string, encode_mess, + pack_message, send_action, login, exit_game +) + +# --- 配置 --- +BASE_DIR = Path(__file__).resolve().parent +GAME_CODE = os.getenv("MAA_ONLINE_GAME_CODE", "mrfz") +TOKEN_FILE = Path(os.getenv("MAA_ONLINE_TOKEN_FILE", str(BASE_DIR / "token"))) +HOST = os.getenv("MAA_ONLINE_HOST", "127.0.0.1") +PORT = int(os.getenv("MAA_ONLINE_PORT", "22888")) +WIDTH = int(os.getenv("MAA_ONLINE_WIDTH", "1280")) +HEIGHT = int(os.getenv("MAA_ONLINE_HEIGHT", "720")) +FPS = os.getenv("MAA_ONLINE_FPS", "30") +START_WAIT_SECONDS = float(os.getenv("MAA_ONLINE_START_WAIT_SECONDS", "30")) + +# --- 颜色定义 --- +class Colors: + RESET = '\033[0m' + RED = '\033[31m' + GREEN = '\033[32m' + YELLOW = '\033[33m' + BLUE = '\033[34m' + CYAN = '\033[36m' + BOLD = '\033[1m' + +if platform.system() == "Windows": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + + +# --- API 解码工具 --- +def decode_api_response(encoded_data: str) -> dict: + """Decode base64 encoded API response from cg.163.com""" + try: + raw_bytes = base64.b64decode(encoded_data) + except Exception: + return {} + + # Find the decryption key by detecting JSON start pattern + decode_key = None + if len(raw_bytes) >= 2: + first_byte = raw_bytes[0] + second_byte = raw_bytes[1] + for key_candidate in range(256): + char1 = chr((first_byte - key_candidate) % 256) + char2 = chr((second_byte - key_candidate) % 256) + if char1 == "{" and char2 == "\"": + decode_key = key_candidate + break + + if decode_key is None: + return {} + + decoded_str = "".join(chr((b - decode_key) % 256) for b in raw_bytes) + try: + return json.loads(decoded_str) + except json.JSONDecodeError: + return {} + + +def fetch_user_info(token: str) -> dict: + """Fetch user info including remaining game time from cg.163.com API""" + try: + headers = {"Authorization": f"Bearer {token}"} + response = requests.get( + "https://n.cg.163.com/api/v2/users/@me", + headers=headers, + timeout=10 + ) + if response.status_code != 200: + return {} + return decode_api_response(response.text) + except Exception as e: + logging.warning(f"Failed to fetch user info: {e}") + return {} + + +# --- 全局状态 --- +class AppState: + def __init__(self): + self.pc: Optional[RTCPeerConnection] = None + self.sock: Optional[WebSocketClientProtocol] = None + self.snapshotter: Optional['VideoSnapshotter'] = None + self.is_ready = False + self.width = WIDTH + self.height = HEIGHT + self.token: Optional[str] = None + self.cloud_game_task: Optional[asyncio.Task] = None + self.ready_event = asyncio.Event() + self.last_error: Optional[str] = None + + def reset(self): + self.pc = None + self.sock = None + self.snapshotter = None + self.is_ready = False + self.token = None + self.cloud_game_task = None + +app_state = AppState() + +# --- 快照工具 (从 ark-demo.py 移植并修改) --- +class VideoSnapshotter: + def __init__(self, video_track): + self._track = video_track + self._task: Optional[asyncio.Task] = None + self._last_frame = None + self._got_first = asyncio.Event() + self._running = False + self._last_recv_ts: float = 0.0 + + def start(self): + if self._task: + return + self._running = True + self._task = asyncio.create_task(self._pump()) + + async def _pump(self): + try: + while self._running: + frame = await self._track.recv() + self._last_frame = frame + self._last_recv_ts = time.monotonic() + if frame.width > 0 and frame.height > 0: + app_state.width = frame.width + app_state.height = frame.height + if not self._got_first.is_set(): + self._got_first.set() + except asyncio.CancelledError: + pass # 任务被取消是正常的 + except Exception: + pass + + async def wait_ready(self, timeout=20.0): + try: + await asyncio.wait_for(self._got_first.wait(), timeout=timeout) + return True + except asyncio.TimeoutError: + return False + + async def snapshot_bytes(self, fmt="png") -> Optional[bytes]: + if not self._got_first.is_set(): + return None + if self._last_recv_ts and time.monotonic() - self._last_recv_ts > 10: + self._last_frame = None + if app_state.is_ready: + app_state.is_ready = False + logging.warning("Video stream stale for over 10s; marking service as not ready") + return None + frame = self._last_frame + if frame is None: + return None + try: + from PIL import Image + img = frame.to_image() + with BytesIO() as bio: + img.save(bio, format=fmt.upper()) + return bio.getvalue() + except Exception as exc: + logging.exception("Failed to encode cloud-game frame: %s", exc) + return None + + async def stop(self): + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + +# --- API 处理函数 --- +async def handle_info(request: web.Request): + if not app_state.is_ready: + status = "connecting" if app_state.cloud_game_task and not app_state.cloud_game_task.done() else "disconnected" + if app_state.last_error: + status = "error" + return web.json_response({ + "status": status, + "message": app_state.last_error or "Cloud gaming service not ready or not connected.", + }) + + return web.json_response({ + "status": "ok", + "width": app_state.width, + "height": app_state.height, + }) + +async def handle_account(request: web.Request): + if not app_state.token: + return web.json_response({"status": "login_required"}, status=401) + loop = asyncio.get_running_loop() + user_info = await loop.run_in_executor(None, fetch_user_info, app_state.token) + if not user_info: + return web.json_response({"status": "error", "message": "Failed to query account."}, status=502) + return web.json_response({ + "status": "ok", + "remaining_time": user_info.get("free_time_left"), + }) + +async def handle_screencap(request: web.Request): + if not app_state.is_ready or not app_state.snapshotter: + return web.json_response({"status": "error", "message": "Service not ready"}, status=503) + + image_bytes = await app_state.snapshotter.snapshot_bytes(fmt="png") + if not image_bytes: + return web.json_response({"status": "error", "message": "Failed to capture screen"}, status=500) + + return web.Response(body=image_bytes, content_type="image/png") + +async def handle_click(request: web.Request): + if not app_state.is_ready or not app_state.sock: + return web.json_response({"status": "error", "message": "Service not ready"}, status=503) + + try: + data = await request.json() + x, y = int(data['x']), int(data['y']) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return web.json_response({"status": "error", "message": "Invalid request body"}, status=400) + if not (0 <= x < app_state.width and 0 <= y < app_state.height): + return web.json_response({"status": "error", "message": "Coordinates out of range"}, status=400) + + logging.warning(f"Action: Click at ({x}, {y})") + + # 1. 先移动鼠标到目标位置 + move_action = pack_message("mm", {"x": x, "y": y}) + await send_action(app_state.sock, move_action) + await asyncio.sleep(0.05) # 短暂等待,模拟真实操作 + + # 2. 再执行点击操作 + click_action = pack_message("cm", {"x": x, "y": y}) + await send_action(app_state.sock, click_action) + + return web.json_response({"status": "ok"}) + +async def handle_swipe(request: web.Request): + if not app_state.is_ready or not app_state.sock: + return web.json_response({"status": "error", "message": "Service not ready"}, status=503) + + try: + data = await request.json() + start_x, start_y = int(data['x1']), int(data['y1']) + end_x, end_y = int(data['x2']), int(data['y2']) + swipe_duration = int(data['duration']) + except (json.JSONDecodeError, KeyError, TypeError, ValueError): + return web.json_response({"status": "error", "message": "Invalid request body"}, status=400) + if not (50 <= swipe_duration <= 10000): + return web.json_response({"status": "error", "message": "Duration must be between 50 and 10000 ms"}, status=400) + + logging.warning(f"Action: Swipe from ({start_x}, {start_y}) to ({end_x}, {end_y}) in {swipe_duration}ms") + + # The web client protocol uses raw video-frame pixel coordinates, matching + # the existing mouse move/click commands. Keep swipe in the same space. + def clamp_coord(pixel_val: int, dimension: int) -> int: + return max(0, min(pixel_val, dimension - 1)) + + screen_width = app_state.width + screen_height = app_state.height + + num_points = max(5, swipe_duration // 30) + interval = swipe_duration / 1000.0 / num_points + touch_id = 0 + + def create_touch_cmd(evt_type: int, px: int, py: int, tid: int) -> dict: + """Create a touch command in raw video-frame pixel coordinates.""" + raw_x = clamp_coord(px, screen_width) + raw_y = clamp_coord(py, screen_height) + timestamp = str(int(time.time() * 1000)) + cmd_str = f"{evt_type} {raw_x} {raw_y} {tid}" + return {"id": timestamp, "op": "input", "data": {"cmd": cmd_str}} + + # Touch down at starting point + down_cmd = create_touch_cmd(1, start_x, start_y, touch_id) + await send_action(app_state.sock, down_cmd) + await asyncio.sleep(0.02) + + # Move through path points + delta_x = end_x - start_x + delta_y = end_y - start_y + for step in range(1, num_points + 1): + progress = step / num_points + current_x = int(start_x + delta_x * progress) + current_y = int(start_y + delta_y * progress) + + move_cmd = create_touch_cmd(2, current_x, current_y, touch_id) + await send_action(app_state.sock, move_cmd) + await asyncio.sleep(interval) + + # Touch up at ending point + up_cmd = create_touch_cmd(3, end_x, end_y, touch_id) + await send_action(app_state.sock, up_cmd) + + return web.json_response({"status": "ok"}) + +async def handle_input(request: web.Request): + if not app_state.is_ready or not app_state.sock: + return web.json_response({"status": "error", "message": "Service not ready"}, status=503) + + try: + data = await request.json() + text = str(data['text']) + except (json.JSONDecodeError, KeyError, TypeError): + return web.json_response({"status": "error", "message": "Invalid request body, 'text' field is required"}, status=400) + if not text or len(text) > 1024: + return web.json_response({"status": "error", "message": "Text length must be between 1 and 1024"}, status=400) + + for char in text: + action = pack_message("ip", {"word": char}) + await send_action(app_state.sock, action) + await asyncio.sleep(0.05) + + return web.json_response({"status": "ok"}) + +async def handle_start(request: web.Request): + if app_state.is_ready: + return web.json_response({"status": "ok", "message": "Cloud game connection already active."}) + + if not TOKEN_FILE.is_file(): + return web.json_response( + {"status": "login_required", "message": "Run login.py before starting the cloud game."}, + status=401, + ) + + if app_state.cloud_game_task and app_state.cloud_game_task.done(): + app_state.cloud_game_task = None + + if not app_state.cloud_game_task: + app_state.last_error = None + app_state.ready_event.clear() + app_state.cloud_game_task = asyncio.create_task(run_cloud_game()) + + try: + await asyncio.wait_for(app_state.ready_event.wait(), timeout=START_WAIT_SECONDS) + except asyncio.TimeoutError: + return web.json_response( + {"status": "connecting", "message": "Cloud game connection is still starting."}, + status=202, + ) + + if app_state.is_ready: + return web.json_response({"status": "ok", "message": "Cloud game connection is ready."}) + + return web.json_response( + {"status": "error", "message": app_state.last_error or "Cloud game connection failed."}, + status=502, + ) + +async def handle_exit(request: web.Request): + if not app_state.cloud_game_task or app_state.cloud_game_task.done(): + return web.json_response({"status": "ok", "message": "No active cloud game connection to exit."}) + + print(f"{Colors.CYAN}[*] Received exit request. Disconnecting from cloud game...{Colors.RESET}") + + app_state.cloud_game_task.cancel() + try: + await app_state.cloud_game_task + except asyncio.CancelledError: + pass # Expected + + return web.json_response({"status": "ok", "message": "Cloud game connection terminated."}) + +async def handle_root(request: web.Request): + raise web.HTTPMovedPermanently('/info') + +# --- 云游戏连接逻辑 --- +async def run_cloud_game(): + try: + try: + token = TOKEN_FILE.read_text(encoding="utf-8").strip() + except FileNotFoundError: + token = "" + if not token: + raise RuntimeError("login token is missing; run login.py first") + + app_state.token = token + print(f"{Colors.CYAN}[*] Connecting to cloud gaming service...{Colors.RESET}") + res, sock = await connect(token, GAME_CODE, w=app_state.width, h=app_state.height, fps=FPS) + app_state.sock = sock + remote = object_from_string(res) + if not isinstance(remote, RTCSessionDescription): + raise RuntimeError(f"Unexpected offer type from signaling: {type(remote)!r}") + + pc = RTCPeerConnection() + app_state.pc = pc + relay = MediaRelay() + track_event = asyncio.Event() + + @pc.on("track") + def on_track(track): + if track.kind == "video": + print(f"{Colors.BLUE}[*] Video track received.{Colors.RESET}") + app_state.snapshotter = VideoSnapshotter(relay.subscribe(track)) + app_state.snapshotter.start() + track_event.set() + + await pc.setRemoteDescription(remote) + answer = await pc.createAnswer() + if answer is None: + raise RuntimeError("Failed to create SDP answer") + patched = RTCSessionDescription( + sdp=answer.sdp.replace("a=setup:active", "a=setup:passive"), + type=answer.type, + ) + await pc.setLocalDescription(patched) + msg = {"id": str(int(time.time() * 1000)), "op": "answer", "data": {"sdp": patched.sdp}} + await sock.send(encode_mess(json.dumps(msg))) + + print(f"{Colors.YELLOW}[*] Waiting for video stream...{Colors.RESET}") + await asyncio.wait_for(track_event.wait(), timeout=20.0) + + if app_state.snapshotter and await app_state.snapshotter.wait_ready(): + app_state.is_ready = True + app_state.ready_event.set() + print(f"{Colors.GREEN}{Colors.BOLD}[✓] Cloud game ready. API is active.{Colors.RESET}") + else: + raise RuntimeError("failed to receive the first cloud-game video frame") + + # Use a cancellable wait instead of Event().wait() + # This allows the task to be properly cancelled by signals + while True: + await asyncio.sleep(1) + + except asyncio.CancelledError: + print(f"\n{Colors.CYAN}[*] Cloud game task cancelled.{Colors.RESET}") + except Exception as e: + app_state.last_error = str(e) + app_state.ready_event.set() + print(f"\n{Colors.RED}[!] An error occurred in cloud game task: {e}{Colors.RESET}", file=sys.stderr) + logging.exception("Cloud game task error") + finally: + print(f"{Colors.CYAN}[*] Cleaning up cloud game resources...{Colors.RESET}") + + token_to_use = app_state.token + pc_to_close = app_state.pc + sock_to_close = app_state.sock + snapshotter_to_stop = app_state.snapshotter + + # 重置状态 + app_state.reset() + + if token_to_use: + print(f"{Colors.CYAN}[*] Sending exit command to cloud game server...{Colors.RESET}") + try: + loop = asyncio.get_running_loop() + await loop.run_in_executor(None, exit_game, token_to_use, GAME_CODE) + except Exception as exc: + logging.warning("Failed to release remote cloud-game session: %s", exc) + + async def close_with_timeout(awaitable: Coroutine, timeout=5.0): + try: + await asyncio.wait_for(awaitable, timeout=timeout) + except (asyncio.TimeoutError, asyncio.CancelledError): + pass + except Exception: + pass + + if snapshotter_to_stop: + await close_with_timeout(snapshotter_to_stop.stop()) + if pc_to_close and pc_to_close.connectionState != "closed": + await close_with_timeout(pc_to_close.close()) + if sock_to_close and sock_to_close.close_code is None: + await close_with_timeout(sock_to_close.close()) + + print(f"{Colors.GREEN}[✓] Cloud game resources cleaned up.{Colors.RESET}") + + +async def cleanup_background_tasks(app: web.Application): + if app_state.cloud_game_task and not app_state.cloud_game_task.done(): + print(f"\n{Colors.CYAN}Server shutting down. Cleaning up active cloud game connection...{Colors.RESET}") + app_state.cloud_game_task.cancel() + try: + await app_state.cloud_game_task + except asyncio.CancelledError: + pass + +# --- 主函数 --- +async def run_server(): + """Run the aiohttp server with proper signal handling.""" + logging.basicConfig(level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s') + access_log = logging.getLogger("aiohttp.access") + + app = web.Application() + app.add_routes([ + web.get('/', handle_root), + web.get('/info', handle_info), + web.get('/account', handle_account), + web.get('/screencap', handle_screencap), + web.post('/click', handle_click), + web.post('/swipe', handle_swipe), + web.post('/input', handle_input), + web.post('/start', handle_start), + web.post('/exit', handle_exit), + ]) + + app.on_cleanup.append(cleanup_background_tasks) + + runner = web.AppRunner(app, access_log=access_log) + await runner.setup() + + site = web.TCPSite(runner, HOST, PORT) + await site.start() + + print(f"{Colors.GREEN}{Colors.BOLD}[✓] API server is running at http://{HOST}:{PORT}{Colors.RESET}") + print(f"{Colors.YELLOW}Send POST to /start to connect to the cloud game.{Colors.RESET}") + print(f"{Colors.YELLOW}(Press CTRL+C to quit){Colors.RESET}") + + # Create shutdown event + shutdown_event = asyncio.Event() + + # Setup signal handlers + loop = asyncio.get_running_loop() + + def signal_handler(): + print(f"\n{Colors.CYAN}[*] Shutdown signal received...{Colors.RESET}") + shutdown_event.set() + + # Register signal handlers + # Note: loop.add_signal_handler() is not supported on Windows event loops, + # so the KeyboardInterrupt fallback in main() is needed for Windows support + if platform.system() != "Windows": + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, signal_handler) + + try: + # Wait for shutdown signal + await shutdown_event.wait() + except asyncio.CancelledError: + pass + finally: + print(f"{Colors.CYAN}[*] Shutting down server...{Colors.RESET}") + # Cleanup cloud game task if running + if app_state.cloud_game_task and not app_state.cloud_game_task.done(): + app_state.cloud_game_task.cancel() + try: + await app_state.cloud_game_task + except asyncio.CancelledError: + pass + await runner.cleanup() + print(f"{Colors.GREEN}[✓] Server stopped.{Colors.RESET}") + +def main(): + try: + asyncio.run(run_server()) + except KeyboardInterrupt: + # This handles Ctrl+C on Windows + print(f"\n{Colors.GREEN}[✓] Server stopped by user.{Colors.RESET}") + +if __name__ == "__main__": + main() diff --git a/bin/adb-cloud b/bin/adb-cloud new file mode 100755 index 0000000..6f8227b --- /dev/null +++ b/bin/adb-cloud @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Small ADB-command compatibility shim backed by maa-online HTTP API. + +It lets an unmodified MaaCore/maa-cli use the cloud-game backend, preserving +maa-cli's normal `maa self update` and `maa update` mechanisms. +""" + +from __future__ import annotations + +import gzip +import json +import os +import shlex +import socket +import struct +import sys +import urllib.error +import urllib.request +from io import BytesIO +from pathlib import Path + +try: + from PIL import Image +except ModuleNotFoundError: + root = Path(__file__).resolve().parents[1] + venv_python = root / "runtime/venv/bin/python" + if venv_python.is_file() and Path(sys.executable) != venv_python: + os.execv(venv_python, [str(venv_python), str(Path(__file__).resolve()), *sys.argv[1:]]) + raise + +BASE_URL = os.getenv("MAA_ONLINE_API", "http://127.0.0.1:22888").rstrip("/") +SERIAL = os.getenv("MAA_ONLINE_ADB_SERIAL", "cloud-game:22888") +TIMEOUT = float(os.getenv("MAA_ONLINE_ADB_TIMEOUT", "40")) + + +def request(path: str, payload: dict | None = None) -> tuple[int, bytes, dict[str, str]]: + data = None + headers: dict[str, str] = {} + method = "GET" + if payload is not None: + data = json.dumps(payload).encode("utf-8") + headers["Content-Type"] = "application/json" + method = "POST" + req = urllib.request.Request(BASE_URL + path, data=data, headers=headers, method=method) + try: + with urllib.request.urlopen(req, timeout=TIMEOUT) as response: + return response.status, response.read(), dict(response.headers.items()) + except urllib.error.HTTPError as exc: + return exc.code, exc.read(), dict(exc.headers.items()) + + +def fetch_png() -> bytes: + status, body, headers = request("/screencap") + if status != 200: + raise RuntimeError(f"screencap failed: HTTP {status}: {body.decode(errors='replace')}") + content_type = headers.get("Content-Type", "") + if "image/png" not in content_type: + raise RuntimeError(f"unexpected screenshot content type: {content_type}") + return body + + +def fetch_raw_screencap() -> bytes: + image = Image.open(BytesIO(fetch_png())).convert("RGBA") + # Android screencap raw format: little-endian width, height, format=RGBA_8888. + return struct.pack(" None: + if isinstance(data, str): + data = data.encode("utf-8") + sys.stdout.buffer.write(data) + + +def fail(message: str, code: int = 1) -> int: + print(message, file=sys.stderr) + return code + + +def strip_global_options(argv: list[str]) -> list[str]: + result: list[str] = [] + index = 0 + options_with_value = {"-s", "-H", "-P", "-L", "--one-device"} + while index < len(argv): + if argv[index] in options_with_value and index + 1 < len(argv): + index += 2 + continue + if argv[index] in {"-d", "-e", "-a"}: + index += 1 + continue + result.extend(argv[index:]) + break + return result + + +def shell(command: str) -> int: + normalized = " ".join(command.strip().split()) + if "settings get secure android_id" in normalized: + emit("6d61616f6e6c696e\n") + elif "getprop ro.build.version.release" in normalized: + emit("9\n") + elif "getprop ro.product.cpu.abilist" in normalized: + emit("x86_64,arm64-v8a,armeabi-v7a\n") + elif normalized.startswith("wm size"): + status, body, _ = request("/info") + if status != 200: + return fail(f"cloud backend unavailable: HTTP {status}") + info = json.loads(body) + if info.get("status") != "ok": + return fail(f"cloud backend is {info.get('status', 'not ready')}") + emit(f"{int(info['width'])}\n{int(info['height'])}\n") + elif "SurfaceOrientation" in normalized: + emit("0\n") + elif "SurfaceFlinger --latency" in normalized: + emit("33333333\n") + elif normalized.startswith("input tap "): + fields = normalized.split() + status, body, _ = request("/click", {"x": int(fields[-2]), "y": int(fields[-1])}) + if status != 200: + return fail(f"click failed: HTTP {status}: {body.decode(errors='replace')}") + elif normalized.startswith("input swipe "): + fields = normalized.split() + status, body, _ = request( + "/swipe", + { + "x1": int(fields[-5]), "y1": int(fields[-4]), + "x2": int(fields[-3]), "y2": int(fields[-2]), + "duration": int(fields[-1]), + }, + ) + if status != 200: + return fail(f"swipe failed: HTTP {status}: {body.decode(errors='replace')}") + elif normalized.startswith("input text "): + text = normalized.removeprefix("input text ") + status, body, _ = request("/input", {"text": text}) + if status != 200: + return fail(f"text input failed: HTTP {status}: {body.decode(errors='replace')}") + elif normalized.startswith("input keyevent "): + return fail("cloud backend key events are not implemented") + elif normalized.startswith("am start "): + status, body, _ = request("/start", {}) + if status not in {200, 202}: + return fail(f"cloud start failed: HTTP {status}: {body.decode(errors='replace')}") + # MaaCore's legacy startup probe treats a sufficiently long `am start` + # response as confirmation that Android accepted the launch request. + emit( + "Starting: Intent { act=android.intent.action.MAIN cat=[android.intent.category.LAUNCHER] " + "flg=0x10200000 cmp=com.hypergryph.arknights/com.u8.sdk.U8UnityContext }\n" + "Status: ok\nActivity: com.hypergryph.arknights/com.u8.sdk.U8UnityContext\n" + ) + elif normalized.startswith("am force-stop "): + status, body, _ = request("/exit", {}) + if status != 200: + return fail(f"cloud stop failed: HTTP {status}: {body.decode(errors='replace')}") + elif normalized.startswith(("dumpsys input", "cat /proc/net/arp")): + emit("0\n") + else: + return fail(f"unsupported adb shell command: {normalized}") + return 0 + + +def main(argv: list[str]) -> int: + args = strip_global_options(argv) + if not args: + return fail("adb-cloud: missing command") + if args[0] == "devices": + emit(f"List of devices attached\n{SERIAL}\tdevice\n") + return 0 + if args[0] == "connect": + emit(f"already connected to {args[1] if len(args) > 1 else SERIAL}\n") + return 0 + if args[0] in {"start-server", "kill-server", "wait-for-device"}: + return 0 + if args[0] == "get-state": + emit("device\n") + return 0 + if args[0] == "exec-out": + command = " ".join(args[1:]) + if "screencap" not in command: + return fail(f"unsupported adb exec-out command: {command}") + if "nc " in command: + fields = shlex.split(command) + nc_index = fields.index("nc") + # MaaCore passes the host address as seen from an Android guest + # (often 10.0.2.2). The shim itself runs on the host, so loop back. + host, port = "127.0.0.1", int(fields[-1]) + if nc_index < 0: + return fail(f"invalid nc screencap command: {command}") + with socket.create_connection((host, port), timeout=TIMEOUT) as connection: + connection.sendall(fetch_raw_screencap()) + return 0 + if "gzip" in command: + emit(gzip.compress(fetch_raw_screencap(), compresslevel=1)) + return 0 + emit(fetch_png()) + return 0 + if args[0] == "shell": + return shell(" ".join(args[1:])) + return fail(f"unsupported adb command: {' '.join(args)}") + + +if __name__ == "__main__": + try: + raise SystemExit(main(sys.argv[1:])) + except (OSError, RuntimeError, ValueError, KeyError, json.JSONDecodeError) as exc: + raise SystemExit(f"adb-cloud: {exc}") diff --git a/bin/maa-online b/bin/maa-online new file mode 100755 index 0000000..d977525 --- /dev/null +++ b/bin/maa-online @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +export HOME="${MAA_ONLINE_HOME:-$ROOT/runtime/home}" +export MAA_CONFIG_DIR="${MAA_CONFIG_DIR:-$ROOT/config}" +export MAA_ONLINE_API="${MAA_ONLINE_API:-http://127.0.0.1:22888}" +MAA_BIN="${MAA_BIN:-$ROOT/runtime/bin/maa}" + +if [ "$#" -eq 0 ]; then + exec "$MAA_BIN" --help +fi + +command_name="$1" +shift +case "$command_name" in + run|startup|closedown|fight|copilot|ssscopilot|paradoxcopilot|roguelike|reclamation) + exec "$MAA_BIN" "$command_name" --profile cloud "$@" + ;; + *) + exec "$MAA_BIN" "$command_name" "$@" + ;; +esac diff --git a/bin/maa-online-config b/bin/maa-online-config new file mode 100755 index 0000000..609da02 --- /dev/null +++ b/bin/maa-online-config @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +exec python3 "$ROOT/user_config/edit_config.py" "$@" diff --git a/bin/maa-online-server b/bin/maa-online-server new file mode 100755 index 0000000..0742a98 --- /dev/null +++ b/bin/maa-online-server @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" + +# Cloud gaming should normally connect directly rather than inherit a host proxy. +if [ "${MAA_ONLINE_KEEP_PROXY:-0}" != "1" ]; then + unset HTTP_PROXY HTTPS_PROXY ALL_PROXY http_proxy https_proxy all_proxy +fi + +cd "$ROOT/backend" +exec "$ROOT/runtime/venv/bin/python" server.py diff --git a/bin/maa-online-update b/bin/maa-online-update new file mode 100755 index 0000000..265d66d --- /dev/null +++ b/bin/maa-online-update @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="${MAA_ONLINE_ROOT:-$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)}" +export HOME="${MAA_ONLINE_HOME:-$ROOT/runtime/home}" +export MAA_CONFIG_DIR="${MAA_CONFIG_DIR:-$ROOT/config}" +MAA_BIN="${MAA_BIN:-$ROOT/runtime/bin/maa}" + +retry() { + local attempt + for attempt in 1 2 3; do + if "$@"; then return 0; fi + echo "Attempt ${attempt}/3 failed: $*" >&2 + sleep $((attempt * 2)) + done + return 1 +} + +direct() { + if [ "${MAA_ONLINE_KEEP_PROXY:-0}" = "1" ]; then "$@"; else env -u HTTPS_PROXY -u HTTP_PROXY -u ALL_PROXY "$@"; fi +} + +echo '[1/3] Updating maa-cli' +retry direct timeout 90s "$MAA_BIN" self update + +echo '[2/3] Updating official MaaCore and base resources' +retry direct timeout 150s "$MAA_BIN" update stable --batch + +resource_repo="$HOME/.local/share/maa/MaaResource" +if [ -d "$resource_repo/.git" ]; then git -C "$resource_repo" config http.version HTTP/1.1; fi + +echo '[3/3] Updating hot resources' +retry direct timeout 90s "$MAA_BIN" hot-update --batch + +echo 'Update workflow completed.' diff --git a/config/profiles/cloud.toml.example b/config/profiles/cloud.toml.example new file mode 100644 index 0000000..4b0cbd3 --- /dev/null +++ b/config/profiles/cloud.toml.example @@ -0,0 +1,12 @@ +[connection] +adb_path = "__ROOT__/bin/adb-cloud" +address = "cloud-game:22888" +config = "General" + +[instance_options] +touch_mode = "ADB" +adb_lite_enabled = false +kill_adb_on_exit = false + +[behavior] +auto_reconnect = false diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..a1ab6c1 --- /dev/null +++ b/install.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +RUNTIME="$ROOT/runtime" +mkdir -p "$RUNTIME/bin" "$RUNTIME/home" "$ROOT/config/profiles" "$ROOT/config/tasks" + +python3 -m venv "$RUNTIME/venv" +"$RUNTIME/venv/bin/pip" install --upgrade pip +"$RUNTIME/venv/bin/pip" install -r "$ROOT/backend/requirements.txt" + +MAA_INSTALL_DIR="$RUNTIME/bin" \ + bash -c 'curl -fsSL https://raw.githubusercontent.com/MaaAssistantArknights/maa-cli/main/install.sh | bash' + +HOME="$RUNTIME/home" MAA_CONFIG_DIR="$ROOT/config" "$RUNTIME/bin/maa" install stable --batch +sed "s|__ROOT__|$ROOT|g" "$ROOT/config/profiles/cloud.toml.example" > "$ROOT/config/profiles/cloud.toml" +if [ ! -e "$ROOT/user_config/daily.toml" ]; then + cp "$ROOT/user_config/daily.example.toml" "$ROOT/user_config/daily.toml" +fi +ln -sfn ../../user_config/daily.toml "$ROOT/config/tasks/daily.toml" +chmod 755 "$ROOT"/bin/* "$ROOT/backend/login.py" "$ROOT/user_config/edit_config.py" + +cat < +2. Verify it: $RUNTIME/venv/bin/python $ROOT/backend/login.py verify +3. Start backend: $ROOT/bin/maa-online-server +4. Start cloud session: curl -X POST http://127.0.0.1:22888/start +5. Run daily: $ROOT/bin/maa-online run daily --batch -v +EOF diff --git a/tests/fake_backend.py b/tests/fake_backend.py new file mode 100755 index 0000000..96b45e5 --- /dev/null +++ b/tests/fake_backend.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Local deterministic backend used to smoke-test the ADB shim and MaaCore.""" + +from __future__ import annotations + +import json +import os +from io import BytesIO +from pathlib import Path + +from aiohttp import web +from PIL import Image, ImageDraw + +LOG_FILE = Path(os.getenv("MAA_ONLINE_FAKE_LOG", Path(__file__).with_name("fake-actions.jsonl"))) + + +def make_frame() -> bytes: + image = Image.new("RGB", (1280, 720), "#111827") + draw = ImageDraw.Draw(image) + draw.rectangle((100, 100, 1180, 620), outline="#60a5fa", width=4) + draw.text((480, 340), "MAA ONLINE SMOKE TEST", fill="white") + output = BytesIO() + image.save(output, "PNG") + return output.getvalue() + + +FRAME = make_frame() + + +async def record(request: web.Request) -> web.Response: + payload = await request.json() if request.can_read_body else {} + with LOG_FILE.open("a", encoding="utf-8") as handle: + handle.write(json.dumps({"path": request.path, "payload": payload}) + "\n") + return web.json_response({"status": "ok"}) + + +async def info(_: web.Request) -> web.Response: + return web.json_response({"status": "ok", "width": 1280, "height": 720}) + + +async def screenshot(_: web.Request) -> web.Response: + return web.Response(body=FRAME, content_type="image/png") + + +app = web.Application() +app.router.add_get("/info", info) +app.router.add_get("/screencap", screenshot) +for path in ("/start", "/exit", "/click", "/swipe", "/input"): + app.router.add_post(path, record) + +if __name__ == "__main__": + LOG_FILE.unlink(missing_ok=True) + web.run_app(app, host="127.0.0.1", port=22889) diff --git a/tests/smoke_maa_connect.py b/tests/smoke_maa_connect.py new file mode 100755 index 0000000..cce6312 --- /dev/null +++ b/tests/smoke_maa_connect.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +"""Load the official Linux MaaCore and connect through adb-cloud.""" + +from __future__ import annotations + +import ctypes +import os +from pathlib import Path + +ROOT = Path(os.getenv("MAA_ONLINE_ROOT", Path(__file__).resolve().parents[1])) +RUNTIME_HOME = Path(os.getenv("MAA_ONLINE_HOME", ROOT / "runtime/home")) +LIB_DIR = RUNTIME_HOME / ".local/share/maa/lib" +RESOURCE_DIR = RUNTIME_HOME / ".local/share/maa" +USER_DIR = RUNTIME_HOME / ".local/state/maa" +ADB = ROOT / "bin/adb-cloud" +USER_DIR.mkdir(parents=True, exist_ok=True) + +os.environ["MAA_ONLINE_API"] = "http://127.0.0.1:22889" +os.environ["LD_LIBRARY_PATH"] = str(LIB_DIR) + os.pathsep + os.environ.get("LD_LIBRARY_PATH", "") + +lib = ctypes.CDLL(str(LIB_DIR / "libMaaCore.so")) +lib.AsstSetUserDir.argtypes = [ctypes.c_char_p] +lib.AsstSetUserDir.restype = ctypes.c_uint8 +lib.AsstLoadResource.argtypes = [ctypes.c_char_p] +lib.AsstLoadResource.restype = ctypes.c_uint8 +lib.AsstCreate.argtypes = [] +lib.AsstCreate.restype = ctypes.c_void_p +lib.AsstSetInstanceOption.argtypes = [ctypes.c_void_p, ctypes.c_int32, ctypes.c_char_p] +lib.AsstSetInstanceOption.restype = ctypes.c_uint8 +lib.AsstConnect.argtypes = [ctypes.c_void_p, ctypes.c_char_p, ctypes.c_char_p, ctypes.c_char_p] +lib.AsstConnect.restype = ctypes.c_uint8 +lib.AsstConnected.argtypes = [ctypes.c_void_p] +lib.AsstConnected.restype = ctypes.c_uint8 +lib.AsstDestroy.argtypes = [ctypes.c_void_p] + +assert lib.AsstSetUserDir(os.fsencode(USER_DIR)), "AsstSetUserDir failed" +assert lib.AsstLoadResource(os.fsencode(RESOURCE_DIR)), "AsstLoadResource failed" +handle = lib.AsstCreate() +assert handle, "AsstCreate failed" +try: + # AsstInstanceOptionKey::TouchMode = 2, value 'adb'. + assert lib.AsstSetInstanceOption(handle, 2, b"adb"), "setting ADB touch mode failed" + connected = lib.AsstConnect( + handle, + os.fsencode(ADB), + b"cloud-game:22888", + b"General", + ) + assert connected, "MaaCore failed to connect through adb-cloud" + assert lib.AsstConnected(handle), "MaaCore did not remain connected" +finally: + lib.AsstDestroy(handle) + +print("MaaCore -> adb-cloud -> fake backend: PASS") diff --git a/user_config/README.md b/user_config/README.md new file mode 100644 index 0000000..65e9c3e --- /dev/null +++ b/user_config/README.md @@ -0,0 +1,197 @@ +# MAA Online 用户配置 + +`daily.example.toml` 是公开模板。执行 `install.sh` 后会复制为被 Git 忽略的 `daily.toml`,并建立以下链接: + +```text +config/tasks/daily.toml + -> user_config/daily.toml +``` + +## 使用前必须确认 + +下面这些参数会改变游戏资源或账号行为。首次运行前请逐项确认。 + +### 全局参数 + +| 参数 | 当前值 | 可填写值/作用 | +|---|---:|---| +| `client_type` | `Official` | 官服用 `Official`;B服用 `Bilibili` | +| `startup` | `true` | 运行任务前自动启动并进入游戏 | +| `closedown` | `true` | 全部任务结束后退出网易云游戏实例 | + +### 清理理智 `Fight` + +| 参数 | 当前值 | 说明 | +|---|---:|---| +| `stage` | `1-7` | 必填偏好。要刷的关卡,如 `1-7`、`CE-6`、`LS-6`、`AP-5`、活动关卡 | +| `medicine` | `0` | 最多使用多少瓶普通理智药;`0` 表示不用 | +| `medicine_expire_days` | `0` | 使用多少天内将过期的理智药;`0` 表示不用 | +| `stone` | `0` | 最多碎多少颗源石;强烈建议保持 `0` | +| `times` | `999` | 最大作战次数;理智不足时仍会停止 | +| `series` | `0` | `0` 自动选择最大代理倍率,`1` 单次代理,其他正数为指定倍率 | +| `report_to_penguin` | `false` | 是否匿名上报掉落至企鹅物流 | +| `report_to_yituliu` | `false` | 是否匿名上报至一图流 | + +修改关卡示例: + +```toml +stage = "CE-6" +``` + +只刷十次: + +```toml +times = 10 +``` + +### 自动公招 `Recruit` + +| 参数 | 当前值 | 说明 | +|---|---:|---| +| `refresh` | `true` | 是否刷新低价值三星标签,会消耗游戏内免费刷新次数 | +| `select` | `[5, 4]` | 允许选择的标签组合等级 | +| `confirm` | `[4, 3]` | 允许自动确认的标签等级;高价值组合不自动确认 | +| `first_tags` | `高级资深干员` | 三星组合时优先考虑的标签;通常无需修改 | +| `extra_tags_mode` | `0` | `0` 默认;`1` 尽量选三个标签;`2` 尽量选择更多高星标签 | +| `times` | `4` | 本轮最多处理/发起多少次招募,会消耗招聘许可 | +| `set_time` | `true` | 是否自动设置招募时间 | +| `expedite` | `false` | 是否使用加急许可;建议保持 `false` | +| `preserve_tags` | 高资、资深、支援机械 | 识别到任一标签时保留槽位,留给人工确认 | +| `recruitment_time` | 540 分钟 | 三星和四星组合默认设置为 9 小时 | +| `server` | `CN` | 国服为 `CN`,国际服可为 `US`、`JP`、`KR` | + +如果只想识别公招、不自动确认和发起招募: + +```toml +confirm = [] +times = 0 +``` + +### 基建换班 `Infrast` + +| 参数 | 当前值 | 说明 | +|---|---:|---| +| `mode` | `20000` | 已启用一键轮换;`0` 为自动计算换班,`10000` 为自定义排班 | +| `facility` | 七类常用设施 | 要处理的设施及顺序 | +| `drones` | `_NotUse` | 不使用无人机;也可填 `Money`、`CombatRecord`、`PureGold` 等 | +| `threshold` | `0.3` | 心情低于此比例时参与换班判断 | +| `dorm_notstationed_enabled` | `false` | 是否将未进驻干员放入宿舍 | +| `dorm_trust_enabled` | `false` | 是否优先安排需要提升信赖的干员进宿舍 | + +设施名称: + +```text +Mfg 制造站 +Trade 贸易站 +Power 发电站 +Control 控制中枢 +Reception 会客室 +Office 办公室 +Dorm 宿舍 +Processing 加工站 +Training 训练室 +``` + +### 信用商店 `Mall` + +| 参数 | 当前值 | 说明 | +|---|---:|---| +| `visit_friends` | `true` | 访问好友基建获取信用 | +| `shopping` | `true` | 是否自动购买信用商店商品 | +| `buy_first` | 招聘许可、龙门币 | 优先购买列表,按顺序购买 | +| `blacklist` | 家具零件、加急许可 | 第二轮购物时不购买的商品 | +| `force_shopping_if_credit_full` | `false` | 信用将溢出时是否无视黑名单;建议保持 `false` | +| `only_buy_discount` | `false` | 第二轮是否只购买折扣商品 | +| `reserve_max_credit` | `true` | 信用高于 300 时可继续购买其他非黑名单商品;降至 300 以下后停止第二轮购物 | +| `credit_fight` | `false` | 是否借助战打一局 OF-1 获取次日信用;会产生额外操作 | +| `formation_index` | `0` | 信用战使用的编队;`0` 当前编队,`1`~`4` 对应编队栏 | + +完全禁止自动购物,但仍访问好友: + +```toml +visit_friends = true +shopping = false +``` + +### 奖励领取 `Award` + +| 参数 | 当前值 | 说明 | +|---|---:|---| +| `award` | `true` | 领取每日和每周任务奖励 | +| `mail` | `true` | 领取所有邮件奖励 | +| `recruit` | `false` | 是否领取限定池每日免费单抽 | +| `orundum` | `false` | 是否领取幸运墙等活动合成玉奖励 | + +## 交互式编辑 + +运行: + +```bash +bin/maa-online-config +``` + +使用数字选择“全局”或某个任务,再选择具体配置项。字符串可以直接输入;列表既可以输入完整 TOML,也可以用逗号分隔,例如: + +```text +1-7 +招聘许可,龙门币 +5,4 +``` + +在主菜单输入 `s` 保存。每次保存前会自动备份到 `user_config/backups/`,随后运行 MAA dry-run;若验证失败会自动回滚。输入 `q` 退出,`Ctrl+C` 会取消未保存的修改。 + +## 检查配置但不执行 + +每次修改后先运行: + +```bash +bin/maa-online run daily --dry-run --batch +``` + +出现任务列表且没有 Error,表示 TOML 格式和参数能够被解析。 + +## 正式执行 + +终端一: + +```bash +bin/maa-online-server +``` + +终端二: + +```bash +curl -X POST http://127.0.0.1:22888/start +bin/maa-online run daily --batch -v \ + --log-file=maa-daily.log +``` + +## 紧急停止 + +停止当前 MAA: + +```bash +pkill -INT -f '/runtime/bin/maa' +``` + +退出网易云游戏实例: + +```bash +curl -X POST http://127.0.0.1:22888/exit +``` + +## 示例模板设置 + +公开示例模板会: + +- 使用基建一键轮换模式; +- 最多处理四次公招,但保留高资、资深和支援机械标签; +- 刷 `1-7` 直到理智不足; +- 不吃理智药; +- 绝不碎石; +- 不使用加急许可; +- 访问好友;优先购买指定商品,信用高于 300 时不限折扣购买其他非黑名单商品; +- 不无视信用商店黑名单; +- 领取每日/每周任务奖励; +- 领取所有邮件; +- 任务结束后退出云游戏。 diff --git a/user_config/daily.example.toml b/user_config/daily.example.toml new file mode 100644 index 0000000..5e02ba3 --- /dev/null +++ b/user_config/daily.example.toml @@ -0,0 +1,82 @@ +# MAA Online 一键日常配置 +# 修改参数前请阅读同目录 README.md。 + +client_type = "Official" +startup = true +closedown = true + +# 1. 基建一键轮换 +[[tasks]] +name = "基建一键轮换" +type = "Infrast" + +[tasks.params] +mode = 20000 +facility = ["Mfg", "Trade", "Power", "Control", "Reception", "Office", "Dorm"] +drones = "_NotUse" +threshold = 0.3 +dorm_notstationed_enabled = false +dorm_trust_enabled = false + +# 2. 自动公招 +[[tasks]] +name = "自动公招" +type = "Recruit" + +[tasks.params] +refresh = true +select = [5, 4] +confirm = [4, 3] +first_tags = ["高级资深干员"] +extra_tags_mode = 0 +times = 4 +set_time = true +expedite = false +preserve_tags = ["高级资深干员", "资深干员", "支援机械"] +recruitment_time = { "3" = 540, "4" = 540 } +report_to_penguin = false +report_to_yituliu = false +server = "CN" + +# 3. 清理理智:默认刷 1-7,不吃药、不碎石 +[[tasks]] +name = "清理理智" +type = "Fight" + +[tasks.params] +stage = "1-7" +medicine = 0 +medicine_expire_days = 0 +stone = 0 +times = 999 +series = 0 +report_to_penguin = false +report_to_yituliu = false +client_type = "Official" + +# 4. 好友信用与信用商店 +[[tasks]] +name = "信用商店" +type = "Mall" + +[tasks.params] +visit_friends = true +shopping = true +buy_first = ["招聘许可", "龙门币"] +blacklist = ["家具零件", "加急许可"] +force_shopping_if_credit_full = false +only_buy_discount = false +reserve_max_credit = true +credit_fight = false +formation_index = 0 + +# 5. 领取日常和周常任务奖励 +[[tasks]] +name = "领取奖励" +type = "Award" + +[tasks.params] +award = true +mail = true +recruit = false +orundum = false diff --git a/user_config/edit_config.py b/user_config/edit_config.py new file mode 100755 index 0000000..8bb25a9 --- /dev/null +++ b/user_config/edit_config.py @@ -0,0 +1,256 @@ +#!/usr/bin/env python3 +"""Interactive, comment-preserving editor for MAA Online daily.toml.""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import shutil +import subprocess +import tempfile +import tomllib +from datetime import datetime +from pathlib import Path +from typing import Any + +ROOT = Path(os.getenv("MAA_ONLINE_ROOT", Path(__file__).resolve().parents[1])) +DEFAULT_FILE = Path(os.getenv("MAA_ONLINE_CONFIG_FILE", Path(__file__).with_name("daily.toml"))) +MAA_DRY_RUN = [ + str(ROOT / "bin/maa-online"), + "run", + "daily", + "--dry-run", + "--batch", +] +KEY_RE = re.compile(r"^(?P\s*)(?P[A-Za-z0-9_]+)(?P\s*=\s*)(?P.*?)(?P\r?\n)?$") + + +def toml_literal(value: Any) -> str: + if isinstance(value, bool): + return "true" if value else "false" + if isinstance(value, str): + return json.dumps(value, ensure_ascii=False) + if isinstance(value, int): + return str(value) + if isinstance(value, float): + return repr(value) + if isinstance(value, list): + return "[" + ", ".join(toml_literal(item) for item in value) + "]" + if isinstance(value, dict): + pairs = [f"{toml_literal(str(key))} = {toml_literal(item)}" for key, item in value.items()] + return "{ " + ", ".join(pairs) + " }" + raise TypeError(f"不支持的值类型:{type(value).__name__}") + + +def parse_value(raw: str, current: Any) -> Any: + text = raw.strip() + if isinstance(current, bool): + aliases = { + "true": True, "yes": True, "y": True, "1": True, "是": True, "开": True, + "false": False, "no": False, "n": False, "0": False, "否": False, "关": False, + } + value = aliases.get(text.lower()) + if value is None: + raise ValueError("布尔值请输入 true/false、yes/no、是/否或开/关") + return value + + if isinstance(current, str) and not text.startswith(('"', "'")): + return text + + if isinstance(current, list) and not text.startswith("["): + parts = [part.strip() for part in text.split(",") if part.strip()] + if not parts: + return [] + sample = current[0] if current else "" + if isinstance(sample, int): + return [int(part) for part in parts] + return parts + + try: + return tomllib.loads("value = " + text)["value"] + except (tomllib.TOMLDecodeError, KeyError) as exc: + raise ValueError(f"无法解析 TOML 值:{exc}") from exc + + +def same_kind(current: Any, value: Any) -> bool: + if isinstance(current, bool): + return isinstance(value, bool) + if isinstance(current, int) and not isinstance(current, bool): + return isinstance(value, int) and not isinstance(value, bool) + if isinstance(current, float): + return isinstance(value, (int, float)) and not isinstance(value, bool) + return isinstance(value, type(current)) + + +class ConfigEditor: + def __init__(self, path: Path, maa_validate: bool = True): + self.path = path.resolve() + self.maa_validate = maa_validate and self.path == DEFAULT_FILE.resolve() + self.original_text = self.path.read_text(encoding="utf-8") + self.text = self.original_text + + def data(self) -> dict[str, Any]: + return tomllib.loads(self.text) + + def sections(self) -> list[tuple[str, int | None, dict[str, Any]]]: + data = self.data() + global_values = {key: value for key, value in data.items() if key != "tasks"} + result: list[tuple[str, int | None, dict[str, Any]]] = [("全局", None, global_values)] + for index, task in enumerate(data.get("tasks", [])): + label = f"{task.get('name', task.get('type', '任务'))} [{task.get('type', '?')}]" + result.append((label, index, task.get("params", {}))) + return result + + def replace(self, task_index: int | None, key: str, value: Any) -> None: + lines = self.text.splitlines(keepends=True) + current_task = -1 + in_params = False + for index, line in enumerate(lines): + stripped = line.strip() + if stripped == "[[tasks]]": + current_task += 1 + in_params = False + continue + if stripped == "[tasks.params]": + in_params = True + continue + if stripped.startswith("[") and stripped.endswith("]"): + in_params = False + + matches_section = current_task == -1 if task_index is None else current_task == task_index and in_params + if not matches_section: + continue + match = KEY_RE.match(line) + if not match or match.group("key") != key: + continue + newline = match.group("newline") or "" + lines[index] = f"{match.group('indent')}{key}{match.group('space')}{toml_literal(value)}{newline}" + self.text = "".join(lines) + self.data() # 再次解析,确保生成结果仍是合法 TOML。 + return + raise KeyError(f"找不到配置项:{key}") + + def save(self) -> Path: + self.data() + backup_dir = self.path.parent / "backups" + backup_dir.mkdir(exist_ok=True) + stamp = datetime.now().strftime("%Y%m%d%H%M%S") + backup = backup_dir / f"{self.path.name}.{stamp}.bak" + suffix = 1 + while backup.exists(): + backup = backup_dir / f"{self.path.name}.{stamp}.{suffix}.bak" + suffix += 1 + shutil.copy2(self.path, backup) + + mode = self.path.stat().st_mode & 0o777 + fd, temp_name = tempfile.mkstemp(prefix=f".{self.path.name}.", dir=self.path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="") as handle: + handle.write(self.text) + handle.flush() + os.fsync(handle.fileno()) + os.chmod(temp_name, mode) + os.replace(temp_name, self.path) + except Exception: + Path(temp_name).unlink(missing_ok=True) + raise + + if self.maa_validate: + result = subprocess.run(MAA_DRY_RUN, text=True, capture_output=True, timeout=120) + if result.returncode != 0: + shutil.copy2(backup, self.path) + detail = (result.stderr or result.stdout).strip() + raise RuntimeError(f"MAA dry-run 失败,已自动回滚:\n{detail}") + print(result.stdout.strip()) + + self.original_text = self.text + return backup + + @property + def changed(self) -> bool: + return self.text != self.original_text + + +def edit_section(editor: ConfigEditor, task_index: int | None, label: str) -> None: + while True: + section = next(values for section_label, index, values in editor.sections() if section_label == label and index == task_index) + keys = list(section) + print(f"\n=== {label} ===") + for number, key in enumerate(keys, 1): + print(f"{number:2}. {key} = {toml_literal(section[key])}") + print(" b. 返回") + choice = input("选择配置项:").strip().lower() + if choice in {"b", "back", "返回", ""}: + return + try: + key = keys[int(choice) - 1] + except (ValueError, IndexError): + print("无效选择。") + continue + + current = section[key] + print(f"当前值:{toml_literal(current)}") + raw = input("新值(直接回车取消;列表可用逗号分隔):") + if not raw.strip(): + continue + try: + value = parse_value(raw, current) + if not same_kind(current, value): + raise ValueError(f"类型不匹配,需要 {type(current).__name__}") + editor.replace(task_index, key, value) + print(f"已暂存:{key} = {toml_literal(value)}") + except (KeyError, TypeError, ValueError) as exc: + print(f"修改失败:{exc}") + + +def interactive(editor: ConfigEditor) -> int: + while True: + print("\n=== MAA Online TOML 配置编辑器 ===") + for number, (label, _, _) in enumerate(editor.sections(), 1): + print(f"{number:2}. {label}") + print(" s. 保存并验证" + (" *" if editor.changed else "")) + print(" q. 退出") + choice = input("请选择:").strip().lower() + if choice in {"q", "quit", "退出"}: + if editor.changed: + confirm = input("存在未保存修改,确认丢弃?[y/N] ").strip().lower() + if confirm not in {"y", "yes", "是"}: + continue + return 0 + if choice in {"s", "save", "保存"}: + if not editor.changed: + print("没有需要保存的修改。") + continue + try: + backup = editor.save() + print(f"保存成功;备份:{backup}") + except Exception as exc: + print(f"保存失败:{exc}") + continue + try: + label, task_index, _ = editor.sections()[int(choice) - 1] + except (ValueError, IndexError): + print("无效选择。") + continue + edit_section(editor, task_index, label) + + +def main() -> int: + parser = argparse.ArgumentParser(description="交互式编辑 MAA Online TOML 配置") + parser.add_argument("--file", type=Path, default=DEFAULT_FILE, help="要编辑的 TOML 文件") + parser.add_argument("--no-maa-validate", action="store_true", help="保存时不运行 MAA dry-run") + args = parser.parse_args() + if not args.file.is_file(): + parser.error(f"配置文件不存在:{args.file}") + editor = ConfigEditor(args.file, maa_validate=not args.no_maa_validate) + try: + return interactive(editor) + except (EOFError, KeyboardInterrupt): + print("\n已取消,未保存的修改不会写入文件。") + return 130 + + +if __name__ == "__main__": + raise SystemExit(main())