Compare commits
84 Commits
b46d69a22b
...
16360f5764
Author | SHA1 | Date |
---|---|---|
![]() |
16360f5764 | |
![]() |
a4d3b322e0 | |
![]() |
4bea42238f | |
![]() |
5db3ed6526 | |
![]() |
5cc9f454ee | |
![]() |
a8a5566f00 | |
![]() |
63f792db2d | |
![]() |
05a69ad947 | |
![]() |
8216310173 | |
![]() |
ca83fec19d | |
![]() |
ef725b4e6f | |
![]() |
68b0cbda73 | |
![]() |
41e7c8b4a8 | |
![]() |
4e4b91d670 | |
![]() |
e548518dcd | |
![]() |
a0d3c31b6b | |
![]() |
0651d949d7 | |
![]() |
497561c721 | |
![]() |
c7d5cd88d6 | |
![]() |
e0549c6b85 | |
![]() |
c3d24018db | |
![]() |
6dd2b3510b | |
![]() |
7ce4b17fb2 | |
![]() |
c6b52a599f | |
![]() |
b8b56bbf4c | |
![]() |
353fa3fcb2 | |
![]() |
03aa2b508c | |
![]() |
9b37bcf541 | |
![]() |
68dbc487e8 | |
![]() |
f5a4e50611 | |
![]() |
cf1605d913 | |
![]() |
3b3aac65dc | |
![]() |
c8fbf80640 | |
![]() |
0e183b0ca6 | |
![]() |
8e5fcaf4fc | |
![]() |
50d1403825 | |
![]() |
f2eadad7eb | |
![]() |
5306f5c875 | |
![]() |
d9077e74e2 | |
![]() |
a6bf0330cd | |
![]() |
66ea531ef3 | |
![]() |
b325a20b60 | |
![]() |
456019b5b5 | |
![]() |
3f88c3a6c4 | |
![]() |
b377b8445e | |
![]() |
c4b93f40d6 | |
![]() |
02860b6014 | |
![]() |
e8851a683d | |
![]() |
9935087753 | |
![]() |
b53a0555e2 | |
![]() |
1397587415 | |
![]() |
ad5c7b700b | |
![]() |
9574d2073a | |
![]() |
08a7e71881 | |
![]() |
a01c37d0fc | |
![]() |
2469fc09df | |
![]() |
71170e5c0c | |
![]() |
87da282530 | |
![]() |
0c94acff1b | |
![]() |
e2328264a7 | |
![]() |
e62e65da4c | |
![]() |
bcd8cf2874 | |
![]() |
d10a098383 | |
![]() |
6f31bab85b | |
![]() |
677932acb7 | |
![]() |
105a894a5c | |
![]() |
f01e0224cb | |
![]() |
d4e858a2c0 | |
![]() |
810c34156b | |
![]() |
07186787b4 | |
![]() |
784b8ed291 | |
![]() |
f0b91814f7 | |
![]() |
415a8dfc7d | |
![]() |
b0e77e2173 | |
![]() |
3ceae4c359 | |
![]() |
bb7f6038a2 | |
![]() |
1ced1e3714 | |
![]() |
4d76e1a9e8 | |
![]() |
16a267de79 | |
![]() |
b8b5ba8a98 | |
![]() |
bd57dc45be | |
![]() |
f2599e6dfd | |
![]() |
2fc9ad5200 | |
![]() |
86313ec52a |
|
@ -10,6 +10,7 @@ kotonebot-ui/.vite
|
|||
dumps*/
|
||||
config.json
|
||||
config.v*.json
|
||||
conf/
|
||||
reports/
|
||||
tmp/
|
||||
res/sprites_compiled/
|
||||
|
|
|
@ -24,6 +24,14 @@
|
|||
],
|
||||
// "module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}",
|
||||
},
|
||||
|
||||
{
|
||||
"name": "Python: Current Module(Without context)",
|
||||
"type": "python",
|
||||
"request": "launch",
|
||||
"console": "integratedTerminal",
|
||||
"module": "${command:extension.commandvariable.file.relativeDirDots}.${fileBasenameNoExtension}",
|
||||
},
|
||||
|
||||
{
|
||||
"name": "KotonebotDebug: Current Module",
|
||||
|
|
|
@ -20,5 +20,5 @@
|
|||
"venv",
|
||||
"**/node_modules"
|
||||
],
|
||||
"python.analysis.diagnosticMode": "workspace"
|
||||
// "python.analysis.diagnosticMode": "workspace"
|
||||
}
|
|
@ -76,6 +76,9 @@ kaa 的开发主要用到了以下开源项目:
|
|||
](https://github.com/AllenHeartcore/GkmasObjectManager):用于提取游戏图像资源,以 GPLv3 协议开源。
|
||||
* [gakumasu-diff](https://github.com/vertesan/gakumasu-diff):游戏数据。
|
||||
|
||||
kaa 的开发还参考了以下开源项目:
|
||||
* [EmulatorExtras](https://github.com/MaaXYZ/EmulatorExtras):MuMu 与雷电模拟器的截图与控制接口定义。
|
||||
* [blue_archive_auto_script](https://github.com/pur1fying/blue_archive_auto_script):MuMu 与雷电模拟器的截图与控制接口的 Python 实现,以及各模拟器的控制实现。
|
||||
|
||||
## 免责声明
|
||||
**请在使用本项目前仔细阅读以下内容。使用本脚本将带来包括但不限于账号被封禁的风险。**
|
||||
|
|
129
WHATS_NEW.md
|
@ -1,5 +1,134 @@
|
|||
# 更新日志
|
||||
## kaa
|
||||
### v2025.7.13.0
|
||||
脚本:
|
||||
* [新增] 上传报告时一并打包配置文件(#a8a5566)
|
||||
|
||||
界面:
|
||||
* [修复] 修复某些情况下热重载配置失败的问题(#4bea422)
|
||||
|
||||
启动器:
|
||||
* [修复] 修复当文件夹路径存在空格时启动器无法正确启动 kaa 的问题(#63f792d)
|
||||
* [新增] 启动器 EXE 新增多分辨率图标(#5db3ed6)
|
||||
|
||||
其他:
|
||||
* [其他] 增加对 Python 信息与分辨率信息的日志输出(#5cc9f45)
|
||||
|
||||
### v2025.7.9.0
|
||||
脚本:
|
||||
* [新增] 配置中支持储存多个培育方案并支持来回切换(#41e7c8b)
|
||||
* [重构] 将配置数据中的常量移动到单独一个文件中(#4e4b91d)
|
||||
* [重构] 配置迁移代码移动到单独的模块(#c7d5cd8)
|
||||
* [重构] 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config(#e0549c6)
|
||||
* [修复] 尝试修复周数 OCR 失败问题(#e548518)
|
||||
* [修复] 修复某些情况下培育会卡在初始饮料技能卡二选一上(#0651d94)
|
||||
* [修复] 修复部分日志缺失的问题(#497561c)
|
||||
|
||||
* [重构] 配置迁移代码移动到单独的模块(#c7d5cd8)
|
||||
* [重构] 将配置文件类从 kotonebot.kaa.common 中移动到专门的模块 kotonebot.kaa.config(#e0549c6)
|
||||
* [修复] 尝试修复周数 OCR 失败问题(#e548518)
|
||||
* [修复] 修复某些情况下培育会卡在初始饮料技能卡二选一上(#0651d94)
|
||||
* [修复] 修复部分日志缺失的问题(#497561c)
|
||||
|
||||
* [修复] 修复部分日志缺失的问题(#497561c)
|
||||
|
||||
|
||||
界面:
|
||||
* [新增] 为新的培育方案增加 UI(#68b0cbd)
|
||||
* [新增] 新增快速功能启停(#c3d2401)
|
||||
* [修复] 修复修改设置后需要重启才能生效的问题(#6dd2b35)
|
||||
|
||||
框架:
|
||||
* [新增] ContextOcr 类支持设置 OCR 语言(#a0d3c31)
|
||||
|
||||
其他:
|
||||
* [单测] 为新的培育方案编写单元测试(#ca83fec)
|
||||
* [其他] v5 到 v6 配置迁移脚本(#ef725b4)
|
||||
|
||||
### v2025.7.7.0
|
||||
脚本:
|
||||
* [修复] 修复 AP 商店购买时点击坐标偏移问题(#b8b56bb)
|
||||
* [修复] 修复商店购买时无法识别部分偶像碎片的问题(#353fa3f)
|
||||
* [修复] 修复 DMM 版购买时无法进入 AP 商店的问题(#f5a4e50)
|
||||
* [修复] 修复商店购买有几率卡在确认购买对话框上(#cf1605d)
|
||||
* [修复] 修复某些情况下无法自动关闭领取活动费后的弹窗(#3b3aac6)
|
||||
* [新增] 金币商店可选使用每日刷新次数(#68dbc48)
|
||||
|
||||
界面:
|
||||
* [新增] UI 支持向局域网开放(#9b37bcf)
|
||||
|
||||
框架:
|
||||
* [新增] 新增目标截图间隔功能(#c8fbf80)
|
||||
|
||||
其他:
|
||||
* [其他] 删除一些无用 Sprite 资源(#03aa2b5)
|
||||
|
||||
### v2025.7.5.0
|
||||
脚本:
|
||||
* [修复] 修复有几率无法识别到进行中培育的问题(#50d1403)
|
||||
* [修复] 修复分辨率缩放导致无法识别到菜单按钮(#f2eadad)
|
||||
* [修复] 修复分辨率缩放时无法检测到工作完成状态的问题(#d9077e7)
|
||||
|
||||
框架:
|
||||
* [重构] 移除 Device.pinned 方法(#5306f5c)
|
||||
|
||||
### v2025.7.3.0
|
||||
启动器:
|
||||
* [新增] 新启动器现在支持安装指定版本与指定补丁(#456019b)
|
||||
* [新增] 自动更新可禁用(#3f88c3a)
|
||||
* [新增] 启动器 C++ EXE 跳板程序(#b377b84)
|
||||
* [新增] 新启动器(#c4b93f4)
|
||||
|
||||
其他:
|
||||
* [其他] Git 记录提取工具支持 bootstrap scope(#66ea531)
|
||||
|
||||
### v2025.6.28.0
|
||||
脚本:
|
||||
* [修复] 修正 debug_entry 脚本路径处理逻辑(#b53a055)
|
||||
* [修复] 修复清理日志功能失效的问题(#ad5c7b7)
|
||||
* [修复] 修复 debug_entry 执行的脚本没有正确输出日志的问题(#3ceae4c)
|
||||
|
||||
界面:
|
||||
* [新增] 优化日志导出功能(#9574d20)
|
||||
* [新增] 为 UI 增加部分配置有效性验证(#08a7e71)
|
||||
* [新增] 加入调试模式警告提示(#a01c37d)
|
||||
* [新增] 画面 Tab 刷新机制由自动改为手动(#2469fc0)
|
||||
* [新增] MuMu12 保活模式可选开启(#71170e5)
|
||||
|
||||
框架:
|
||||
* [修复] 修复 Device 中缩放与截图 Hook 的处理顺序不正确问题
|
||||
* [修复] 修复 NemuIpc 在多显示器下的坐标系问题(#87da282)
|
||||
* [修复] 修复当目标分辨率与实际分辨率旋转不同时截图会强制拉伸的问题(#e62e65d)
|
||||
* [修复] 缩放处理支持自动识别旋转(#677932a)
|
||||
* [修复] 修复 NemuIpc 截图无法响应屏幕旋转导致的分辨率变化(#105a894)
|
||||
* [新增] NemuIpcImpl 获取显示器 ID 支持自动重试(#0c94acf)
|
||||
* [新增] 支持 MuMu12 后台保活模式(#e232826)
|
||||
* [新增] 引入 Nemu 截图与控制方式(#f0b9181)
|
||||
* [新增] 移除 WindowsImpl 中的分辨率缩放(#415a8df)
|
||||
* [新增] 支持等比例分辨率缩放(#bb7f603)
|
||||
* [重构] 组装 Device 改用 recipe 方案(#f01e022)
|
||||
* [重构] 将传递的 MuMu 路径从 shell 目录改为 MuMu 根目录(#784b8ed)
|
||||
|
||||
其他:
|
||||
* [其他] HsvRangeTool 支持从剪贴板粘贴图片(#1397587)
|
||||
* [其他] VSCode 新增不初始化 context 的启动配置(#0718678)
|
||||
* [其他] 调整部分日志输出格式(#bcd8cf2)
|
||||
|
||||
### v2025.6.23.0
|
||||
脚本:
|
||||
* [新增] 优化了培育开始的逻辑,修复若干 bug(#86313ec)
|
||||
|
||||
框架:
|
||||
* [修复] 修复由于分离 AndroidDevice 方法导致的 typing 问题(#4d76e1a)
|
||||
* [重构] 将 Commandable 分离为 WindowsCommandable 与 AndroidCommandable(#16a267d)
|
||||
* [重构] 将启动 remote_server 的逻辑移动到 kaa cli 中(#b8b5ba8)
|
||||
* [重构] 提取三个 adb-based 截图方法的工厂函数的共同部分(#bd57dc4)
|
||||
* [重构] 将创建设备的逻辑从 init_context 中移除(#f2599e6)
|
||||
* [重构] 重构 Device 与 Impl 的创建方式(#2fc9ad5)
|
||||
|
||||
其他:
|
||||
* [其他] 更新上游 submodules(#810c341)
|
||||
|
||||
### v2025.6.8.0
|
||||
脚本:
|
||||
* [新增] 新增支持自动禁用与恢复 Gakumasu Localify 汉化插件(#264dac2)
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
# bootstrap
|
||||
此文件夹下存放的是为了简化分发而编写的启动器代码。
|
||||
|
||||
## bootstrap/kaa-bootstrap
|
||||
启动器本体,由 Python 编写。负责:
|
||||
1. 寻找最快的 PyPI 镜像源
|
||||
2. 安装与更新 pip 和 kaa 本体
|
||||
3. 读入配置文件,检查是否需要管理员权限
|
||||
4. 启动 kaa
|
||||
|
||||
打包产物:bootstrap.pyz
|
||||
|
||||
## bootstrap/kaa-wrapper
|
||||
启动器包装器,由 C++ 编写,用于调用 Python 启动 kaa-bootstrap。
|
||||
|
||||
打包产物:kaa.exe
|
|
@ -0,0 +1,7 @@
|
|||
from terminal import print_status
|
||||
from launcher import main_launch
|
||||
|
||||
try:
|
||||
main_launch()
|
||||
except KeyboardInterrupt:
|
||||
print_status("运行结束", status='info')
|
|
@ -0,0 +1,754 @@
|
|||
import os
|
||||
import sys
|
||||
import json
|
||||
import ctypes
|
||||
import codecs
|
||||
import locale
|
||||
import logging
|
||||
import subprocess
|
||||
import importlib.metadata
|
||||
import argparse
|
||||
import tempfile
|
||||
import zipfile
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from collections import deque
|
||||
from datetime import datetime
|
||||
from time import sleep
|
||||
from typing import Optional, Dict, Any, TypedDict, Literal, List
|
||||
|
||||
from request import head, HTTPError, NetworkError
|
||||
from terminal import (
|
||||
Color, print_header, print_status, clear_screen,
|
||||
get_terminal_width, get_display_width, truncate_string,
|
||||
hide_cursor, show_cursor, move_cursor_up, wait_key, get_terminal_height
|
||||
)
|
||||
from repo import Version
|
||||
|
||||
# 配置文件的类型定义
|
||||
class BackendConfig(TypedDict, total=False):
|
||||
type: Literal['custom', 'mumu12', 'leidian', 'dmm']
|
||||
screenshot_impl: Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
|
||||
|
||||
class MiscConfig(TypedDict, total=False):
|
||||
check_update: Literal['never', 'startup']
|
||||
auto_install_update: bool
|
||||
|
||||
class UserConfig(TypedDict, total=False):
|
||||
name: str
|
||||
id: str
|
||||
category: str
|
||||
description: str
|
||||
backend: BackendConfig
|
||||
keep_screenshots: bool
|
||||
options: Dict[str, Any] # 这里包含 misc 等配置
|
||||
|
||||
class Config(TypedDict, total=False):
|
||||
version: int
|
||||
user_configs: List[UserConfig]
|
||||
|
||||
# 获取当前Python解释器路径
|
||||
PYTHON_EXECUTABLE = sys.executable
|
||||
TRUSTED_HOSTS = "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
|
||||
|
||||
def setup_logging():
|
||||
"""
|
||||
配置日志记录。
|
||||
"""
|
||||
log_dir = Path("logs")
|
||||
log_dir.mkdir(exist_ok=True)
|
||||
timestamp = datetime.now().strftime("%y-%m-%d-%H-%M-%S")
|
||||
log_file = log_dir / f"bootstrap-{timestamp}.log"
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG,
|
||||
format='[%(asctime)s][%(levelname)s][%(filename)s:%(lineno)d] %(message)s',
|
||||
filename=log_file,
|
||||
filemode='w',
|
||||
encoding='utf-8'
|
||||
)
|
||||
|
||||
# 记录未捕获的异常
|
||||
def handle_exception(exc_type, exc_value, exc_traceback):
|
||||
if issubclass(exc_type, KeyboardInterrupt):
|
||||
sys.__excepthook__(exc_type, exc_value, exc_traceback)
|
||||
return
|
||||
logging.error("未捕获的异常", exc_info=(exc_type, exc_value, exc_traceback))
|
||||
|
||||
sys.excepthook = handle_exception
|
||||
logging.info("日志记录器已初始化。")
|
||||
|
||||
PIP_SERVERS = [
|
||||
"https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple",
|
||||
"https://mirrors.aliyun.com/pypi/simple",
|
||||
"https://mirrors.cloud.tencent.com/pypi/simple",
|
||||
"https://pypi.org/simple",
|
||||
]
|
||||
|
||||
def is_admin() -> bool:
|
||||
"""
|
||||
检查当前进程是否具有管理员权限。
|
||||
|
||||
:return: 如果具有管理员权限返回True,否则返回False
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
return ctypes.windll.shell32.IsUserAnAdmin()
|
||||
except:
|
||||
return False
|
||||
|
||||
def test_url_availability(url: str) -> bool:
|
||||
"""
|
||||
测试URL是否可访问(返回200状态码)。
|
||||
|
||||
:param url: 要测试的URL
|
||||
:type url: str
|
||||
:return: 如果URL可访问返回True,否则返回False
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
with head(url, timeout=10) as response:
|
||||
return response.status_code == 200
|
||||
except (HTTPError, NetworkError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def get_working_pip_server() -> Optional[str]:
|
||||
"""
|
||||
获取可用的pip服务器。
|
||||
|
||||
:return: 第一个可用的pip服务器URL,如果都不可用返回None
|
||||
:rtype: Optional[str]
|
||||
"""
|
||||
for server in PIP_SERVERS:
|
||||
msg = f"正在测试: {server}"
|
||||
print_status(msg, status='info', indent=1)
|
||||
logging.info(msg)
|
||||
if test_url_availability(server):
|
||||
msg = f"找到可用的pip服务器: {server}"
|
||||
print_status(msg, status='success', indent=1)
|
||||
logging.info(msg)
|
||||
return server
|
||||
msg = "所有pip服务器都不可用"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg)
|
||||
return None
|
||||
|
||||
def package_version(package_name: str) -> Optional[str]:
|
||||
"""
|
||||
获取指定包的版本信息。
|
||||
|
||||
:param package_name: 包名称
|
||||
:type package_name: str
|
||||
:return: 包版本字符串,如果包不存在则返回 None
|
||||
:rtype: Optional[str]
|
||||
|
||||
:Example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
>>> package_version("requests")
|
||||
'2.31.0'
|
||||
>>> package_version("nonexistent_package")
|
||||
None
|
||||
|
||||
:raises: 无异常抛出,包不存在时返回 None
|
||||
"""
|
||||
try:
|
||||
return importlib.metadata.version(package_name)
|
||||
except importlib.metadata.PackageNotFoundError:
|
||||
return None
|
||||
|
||||
def run_command(command: str, check: bool = True, verbatim: bool = False, scroll_region_size: int = -1, log_output: bool = True) -> bool:
|
||||
"""
|
||||
运行命令并实时输出,返回是否成功。
|
||||
|
||||
:param command: 要运行的命令
|
||||
:param check: 是否检查返回码
|
||||
:param verbatim: 是否原样输出,不使用滚动UI
|
||||
:param scroll_region_size: 滚动区域的大小, -1 表示动态计算
|
||||
:param log_output: 是否将命令输出记录到日志中
|
||||
:return: 命令是否成功执行
|
||||
"""
|
||||
logging.info(f"执行命令: {command}")
|
||||
|
||||
# 设置环境变量以确保正确的编码处理
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
|
||||
# 获取系统默认编码
|
||||
system_encoding = locale.getpreferredencoding()
|
||||
|
||||
# 创建解码器
|
||||
def decode_output(line: bytes) -> str:
|
||||
try:
|
||||
# 首先尝试UTF-8解码
|
||||
return line.decode('utf-8')
|
||||
except UnicodeDecodeError:
|
||||
try:
|
||||
# 如果UTF-8失败,尝试系统默认编码
|
||||
return line.decode(system_encoding)
|
||||
except UnicodeDecodeError:
|
||||
# 如果都失败了,使用'replace'策略
|
||||
return line.decode('utf-8', errors='replace')
|
||||
|
||||
if verbatim:
|
||||
print(f"▶ 执行命令: {command}")
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
command, shell=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
env=env
|
||||
)
|
||||
if process.stdout:
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
clean_line = decode_output(line).strip('\n')
|
||||
print(clean_line)
|
||||
if log_output:
|
||||
logging.info(clean_line)
|
||||
|
||||
returncode = process.wait()
|
||||
logging.info(f"命令执行完毕,返回码: {returncode}")
|
||||
return returncode == 0
|
||||
except FileNotFoundError:
|
||||
msg = f"命令未找到: {command.split()[0]}"
|
||||
print_status(msg, status='error', indent=1)
|
||||
logging.error(msg)
|
||||
return False
|
||||
except Exception as e:
|
||||
msg = f"命令执行时发生错误: {e}"
|
||||
print_status(msg, status='error', indent=1)
|
||||
logging.error(msg, exc_info=True)
|
||||
return False
|
||||
|
||||
# --- 滚动UI模式 ---
|
||||
if scroll_region_size == -1:
|
||||
# Heuristic: leave some lines for context above and below.
|
||||
# Use at least 5 lines.
|
||||
SCROLL_REGION_SIZE = max(5, get_terminal_height() - 8)
|
||||
else:
|
||||
SCROLL_REGION_SIZE = scroll_region_size
|
||||
|
||||
terminal_width = get_terminal_width()
|
||||
|
||||
# 打印初始状态行
|
||||
prefix = "▶ "
|
||||
prefix_width = 2 # "▶ "
|
||||
available_width = terminal_width - prefix_width
|
||||
command_text = f"执行命令: {command}"
|
||||
truncated_command = truncate_string(command_text, available_width)
|
||||
padding_len = available_width - get_display_width(truncated_command)
|
||||
padding = ' ' * max(0, padding_len)
|
||||
|
||||
print(f"{Color.GRAY}{prefix}{truncated_command}{padding}{Color.RESET}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
command, shell=True,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
env=env
|
||||
)
|
||||
|
||||
output_buffer = deque(maxlen=SCROLL_REGION_SIZE)
|
||||
lines_rendered = 0
|
||||
hide_cursor()
|
||||
|
||||
try:
|
||||
if process.stdout:
|
||||
for line in iter(process.stdout.readline, b''):
|
||||
stripped_line = decode_output(line).strip()
|
||||
output_buffer.append(stripped_line)
|
||||
if log_output:
|
||||
logging.info(stripped_line)
|
||||
|
||||
# 移动光标到绘制区域顶部
|
||||
if lines_rendered > 0:
|
||||
move_cursor_up(lines_rendered)
|
||||
|
||||
lines_rendered = min(len(output_buffer), SCROLL_REGION_SIZE)
|
||||
# 重新绘制滚动区域
|
||||
lines_to_render = list(output_buffer)
|
||||
for i in range(lines_rendered):
|
||||
line_to_print = lines_to_render[i] if i < len(lines_to_render) else ""
|
||||
prefix = f"{Color.GRAY}|{Color.RESET} "
|
||||
prefix_width = 2
|
||||
|
||||
available_width = terminal_width - prefix_width
|
||||
truncated = truncate_string(line_to_print, available_width)
|
||||
padding_len = available_width - get_display_width(truncated)
|
||||
padding = ' ' * max(0, padding_len)
|
||||
|
||||
# 使用 \r 和 \n 刷新行
|
||||
print(f"\r{prefix}{truncated}{padding}")
|
||||
|
||||
|
||||
returncode = process.wait()
|
||||
logging.info(f"命令执行完毕,返回码: {returncode}")
|
||||
|
||||
finally:
|
||||
show_cursor()
|
||||
|
||||
# 清理滚动区域
|
||||
if lines_rendered > 0:
|
||||
move_cursor_up(lines_rendered)
|
||||
for _ in range(lines_rendered):
|
||||
print(' ' * terminal_width)
|
||||
move_cursor_up(lines_rendered)
|
||||
|
||||
# 更新最终状态行
|
||||
move_cursor_up(1)
|
||||
|
||||
if returncode == 0:
|
||||
final_symbol = f"{Color.GREEN}✓"
|
||||
success = True
|
||||
else:
|
||||
final_symbol = f"{Color.RED}✗"
|
||||
success = False
|
||||
|
||||
# 重新计算填充以确保行被完全覆盖
|
||||
final_prefix = f"{final_symbol} "
|
||||
final_prefix_width = 2 # "✓ " or "✗ "
|
||||
available_width = terminal_width - final_prefix_width
|
||||
|
||||
final_line_text = f"执行命令: {command}"
|
||||
truncated_final_line = truncate_string(final_line_text, available_width)
|
||||
padding_len = available_width - get_display_width(truncated_final_line)
|
||||
padding = ' ' * max(0, padding_len)
|
||||
|
||||
print(f"\r{final_prefix}{truncated_final_line}{Color.RESET}{padding}")
|
||||
|
||||
if check and not success:
|
||||
msg = f"命令执行失败,返回码: {returncode}"
|
||||
print_status(msg, status='error', indent=1)
|
||||
logging.error(msg)
|
||||
return False
|
||||
|
||||
return success
|
||||
|
||||
def check_ksaa_update_available(pip_server: str, current_version: Version) -> tuple[bool, Version | None, Version | None]:
|
||||
"""
|
||||
检查ksaa包是否有新版本可用。
|
||||
|
||||
:param pip_server: pip服务器URL
|
||||
:type pip_server: str
|
||||
:param current_version: 当前版本
|
||||
:type current_version: Version
|
||||
:return: (是否有更新, 当前版本, 最新版本)
|
||||
:rtype: tuple[bool, Optional[Version], Optional[Version]]
|
||||
"""
|
||||
try:
|
||||
# 使用repo.py中的list_versions函数和Version类获取最新版本信息
|
||||
from repo import list_versions, Version
|
||||
|
||||
try:
|
||||
versions = list_versions("ksaa", server_url=pip_server)
|
||||
if versions and len(versions) > 0:
|
||||
latest_version = versions[0].version
|
||||
|
||||
# 使用Version类的比较功能
|
||||
if latest_version > current_version:
|
||||
return True, current_version, latest_version
|
||||
except Exception as e:
|
||||
logging.warning(f"从服务器 {pip_server} 获取版本信息失败: {e}")
|
||||
print_status(f"从服务器 {pip_server} 获取版本信息失败: {e}", status='error')
|
||||
# 如果指定服务器失败,尝试使用默认PyPI服务器
|
||||
try:
|
||||
versions = list_versions("ksaa")
|
||||
if versions and len(versions) > 0:
|
||||
latest_version = versions[0].version
|
||||
|
||||
# 使用Version类的比较功能
|
||||
if latest_version > current_version:
|
||||
return True, current_version, latest_version
|
||||
except Exception as e2:
|
||||
logging.warning(f"从PyPI获取版本信息也失败: {e2}")
|
||||
|
||||
return False, current_version, latest_version if 'latest_version' in locals() else None
|
||||
|
||||
except Exception as e:
|
||||
logging.warning(f"检查ksaa更新时发生错误: {e}")
|
||||
return False, None, None
|
||||
|
||||
def print_update_notice(current_version: str, latest_version: str):
|
||||
"""
|
||||
打印更新提示信息。
|
||||
|
||||
:param current_version: 当前版本
|
||||
:type current_version: str
|
||||
:param latest_version: 最新版本
|
||||
:type latest_version: str
|
||||
"""
|
||||
clear_screen()
|
||||
print()
|
||||
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60)
|
||||
print(f"{Color.YELLOW}{Color.BOLD}⚠️ 发现新版本可用!")
|
||||
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60)
|
||||
print(f"{Color.YELLOW}当前版本: {current_version}")
|
||||
print(f"{Color.YELLOW}最新版本: {latest_version}")
|
||||
print(f"{Color.YELLOW}建议开启自动更新或在设置中手动安装新版本。")
|
||||
print(f"{Color.YELLOW}5s 后继续启动")
|
||||
print(f"{Color.YELLOW}{Color.BOLD}" + "=" * 60 + f"{Color.RESET}")
|
||||
print()
|
||||
sleep(5)
|
||||
|
||||
def install_ksaa_version(pip_server: str, trusted_hosts: str, version: str) -> bool:
|
||||
"""
|
||||
安装指定版本的ksaa包。
|
||||
|
||||
:param pip_server: pip服务器URL
|
||||
:type pip_server: str
|
||||
:param trusted_hosts: 信任的主机列表
|
||||
:type trusted_hosts: str
|
||||
:param version: 要安装的版本号
|
||||
:type version: str
|
||||
:return: 安装是否成功
|
||||
:rtype: bool
|
||||
"""
|
||||
print_status(f"安装琴音小助手 v{version}", status='info')
|
||||
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --index-url {pip_server} --trusted-host "{trusted_hosts}" --no-warn-script-location ksaa=={version}'
|
||||
return run_command(install_command)
|
||||
|
||||
def install_ksaa_from_zip(zip_path: str) -> bool:
|
||||
"""
|
||||
从zip文件安装ksaa包。
|
||||
|
||||
:param zip_path: zip文件路径
|
||||
:type zip_path: str
|
||||
:return: 安装是否成功
|
||||
:rtype: bool
|
||||
"""
|
||||
zip_file = Path(zip_path)
|
||||
if not zip_file.exists():
|
||||
msg = f"zip文件不存在: {zip_path}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg)
|
||||
return False
|
||||
|
||||
if not zip_file.suffix.lower() == '.zip':
|
||||
msg = f"文件不是zip格式: {zip_path}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg)
|
||||
return False
|
||||
|
||||
print_status(f"从zip文件安装琴音小助手: {zip_path}", status='info')
|
||||
|
||||
# 创建临时目录
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
temp_path = Path(temp_dir)
|
||||
|
||||
try:
|
||||
# 解压zip文件
|
||||
print_status("解压zip文件...", status='info', indent=1)
|
||||
with zipfile.ZipFile(zip_file, 'r') as zip_ref:
|
||||
zip_ref.extractall(temp_path)
|
||||
|
||||
# 使用pip install --find-links安装
|
||||
print_status("安装ksaa包...", status='info', indent=1)
|
||||
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --no-warn-script-location --no-cache-dir --upgrade --no-deps --force-reinstall --no-index --find-links "{temp_path.absolute()}" ksaa'
|
||||
return run_command(install_command)
|
||||
|
||||
except zipfile.BadZipFile:
|
||||
msg = f"无效的zip文件: {zip_path}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg)
|
||||
return False
|
||||
except Exception as e:
|
||||
msg = f"从zip文件安装失败: {e}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg, exc_info=True)
|
||||
return False
|
||||
|
||||
def install_pip_and_ksaa(pip_server: str, check_update: bool = True, install_update: bool = True) -> bool:
|
||||
"""
|
||||
安装和更新pip以及ksaa包。
|
||||
|
||||
:param pip_server: pip服务器URL
|
||||
:type pip_server: str
|
||||
:param check_update: 是否检查更新
|
||||
:type check_update: bool
|
||||
:param install_update: 是否安装更新
|
||||
:type install_update: bool
|
||||
:return: 安装是否成功
|
||||
:rtype: bool
|
||||
"""
|
||||
print_header("安装与更新小助手", color=Color.BLUE)
|
||||
|
||||
# 升级pip
|
||||
print_status("更新 pip", status='info')
|
||||
upgrade_pip_command = f'"{PYTHON_EXECUTABLE}" -m pip install -i {pip_server} --trusted-host "{TRUSTED_HOSTS}" --upgrade pip'
|
||||
if not run_command(upgrade_pip_command):
|
||||
return False
|
||||
|
||||
# 默认安装逻辑
|
||||
install_command = f'"{PYTHON_EXECUTABLE}" -m pip install --upgrade --index-url {pip_server} --trusted-host "{TRUSTED_HOSTS}" --no-warn-script-location ksaa'
|
||||
ksaa_version_str = package_version("ksaa")
|
||||
# 未安装
|
||||
if not ksaa_version_str:
|
||||
print_status("安装琴音小助手", status='info')
|
||||
return run_command(install_command)
|
||||
# 已安装,检查更新
|
||||
else:
|
||||
ksaa_version = Version(ksaa_version_str)
|
||||
if check_update:
|
||||
has_update, current_version, latest_version = check_ksaa_update_available(pip_server, ksaa_version)
|
||||
if has_update:
|
||||
if install_update:
|
||||
print_status("更新琴音小助手", status='info')
|
||||
return run_command(install_command)
|
||||
else:
|
||||
print_update_notice(str(current_version), str(latest_version))
|
||||
else:
|
||||
print_status("已是最新版本", status='success')
|
||||
return True
|
||||
|
||||
def load_config() -> Optional[Config]:
|
||||
"""
|
||||
加载config.json配置文件。
|
||||
|
||||
:return: 配置字典,如果加载失败返回None
|
||||
:rtype: Optional[Config]
|
||||
"""
|
||||
config_path = Path("./config.json")
|
||||
if not config_path.exists():
|
||||
msg = "配置文件 config.json 不存在,跳过配置加载"
|
||||
print_status(msg, status='warning')
|
||||
logging.warning(msg)
|
||||
return None
|
||||
|
||||
try:
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
config = json.load(f)
|
||||
msg = "成功加载配置文件"
|
||||
print_status(msg, status='success')
|
||||
logging.info(msg)
|
||||
return config
|
||||
except Exception as e:
|
||||
msg = f"加载配置文件失败: {e}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg, exc_info=True)
|
||||
return None
|
||||
|
||||
def get_update_settings(config: Config) -> tuple[bool, bool]:
|
||||
"""
|
||||
从配置中获取更新设置。
|
||||
|
||||
:param config: 配置字典
|
||||
:type config: Config
|
||||
:return: (是否检查更新, 是否自动安装更新)
|
||||
:rtype: tuple[bool, bool]
|
||||
"""
|
||||
# 默认值
|
||||
check_update = True
|
||||
auto_install_update = True
|
||||
|
||||
# 检查是否有用户配置
|
||||
user_configs = config.get("user_configs", [])
|
||||
if user_configs:
|
||||
first_config = user_configs[0]
|
||||
options = first_config.get("options", {})
|
||||
misc = options.get("misc", {})
|
||||
|
||||
# 获取检查更新设置
|
||||
check_update_setting = misc.get("check_update", "startup")
|
||||
check_update = check_update_setting == "startup"
|
||||
|
||||
# 获取自动安装更新设置
|
||||
auto_install_update = misc.get("auto_install_update", True)
|
||||
|
||||
msg = f"更新设置: 检查更新={check_update}, 自动安装={auto_install_update}"
|
||||
logging.info(msg)
|
||||
|
||||
return check_update, auto_install_update
|
||||
|
||||
def restart_as_admin() -> None:
|
||||
"""
|
||||
以管理员身份重启程序。
|
||||
"""
|
||||
if is_admin():
|
||||
return
|
||||
|
||||
script = os.path.abspath(sys.argv[0])
|
||||
params = ' '.join([f'"{item}"' for item in sys.argv[1:]])
|
||||
|
||||
try:
|
||||
# 使用 ShellExecute 以管理员身份启动程序
|
||||
ret = ctypes.windll.shell32.ShellExecuteW(
|
||||
None, "runas", PYTHON_EXECUTABLE, f'"{script}" {params}', None, 1
|
||||
)
|
||||
if ret > 32: # 返回值大于32表示成功
|
||||
msg = "正在以管理员身份重启程序..."
|
||||
print_status(msg, status='info')
|
||||
logging.info(msg)
|
||||
os._exit(0)
|
||||
else:
|
||||
msg = f"以管理员身份重启失败,错误码: {ret}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg)
|
||||
return
|
||||
except Exception as e:
|
||||
msg = f"以管理员身份重启时发生错误: {e}"
|
||||
print_status(msg, status='error')
|
||||
logging.error(msg, exc_info=True)
|
||||
return
|
||||
|
||||
def check_admin(config: Config) -> bool:
|
||||
"""
|
||||
检查Windows截图权限(管理员权限)。
|
||||
|
||||
:param config: 配置字典
|
||||
:type config: Config
|
||||
:return: 权限检查是否通过
|
||||
:rtype: bool
|
||||
"""
|
||||
# 检查是否有用户配置
|
||||
user_configs = config.get("user_configs", [])
|
||||
if not user_configs:
|
||||
msg = "配置文件中没有用户配置"
|
||||
print_status(msg, status='warning')
|
||||
logging.warning(msg)
|
||||
return True # Not a fatal error, allow to continue
|
||||
|
||||
# 检查第一个用户配置的截图方式
|
||||
first_config = user_configs[0]
|
||||
backend = first_config.get("backend", {})
|
||||
screenshot_impl = backend.get("screenshot_impl")
|
||||
|
||||
if screenshot_impl == "windows":
|
||||
msg = "检测到Windows截图模式,检查管理员权限..."
|
||||
print_status(msg, status='info')
|
||||
logging.info(msg)
|
||||
if not is_admin():
|
||||
msg1 = "需要管理员权限才能使用Windows截图模式"
|
||||
print_status(msg1, status='error')
|
||||
logging.error(msg1)
|
||||
|
||||
# 尝试以管理员身份重启
|
||||
msg2 = "正在尝试以管理员身份重启..."
|
||||
print_status(msg2, status='info', indent=1)
|
||||
logging.info(msg2)
|
||||
restart_as_admin()
|
||||
return False
|
||||
else:
|
||||
msg = "管理员权限检查通过"
|
||||
print_status(msg, status='success')
|
||||
logging.info(msg)
|
||||
|
||||
return True
|
||||
|
||||
def run_kaa() -> bool:
|
||||
"""
|
||||
运行琴音小助手。
|
||||
|
||||
:return: 运行是否成功
|
||||
:rtype: bool
|
||||
"""
|
||||
print_header("运行琴音小助手", color=Color.GREEN)
|
||||
clear_screen()
|
||||
|
||||
# 设置环境变量
|
||||
os.environ["no_proxy"] = "localhost, 127.0.0.1, ::1"
|
||||
|
||||
# 运行kaa命令
|
||||
if not run_command(f'"{PYTHON_EXECUTABLE}" -m kotonebot.kaa.main.cli', verbatim=True, log_output=False):
|
||||
return False
|
||||
|
||||
print_header("运行结束", color=Color.GREEN)
|
||||
return True
|
||||
|
||||
|
||||
def parse_arguments():
|
||||
"""
|
||||
解析命令行参数。
|
||||
|
||||
:return: 解析后的参数
|
||||
:rtype: argparse.Namespace
|
||||
"""
|
||||
parser = argparse.ArgumentParser(description='琴音小助手启动器')
|
||||
parser.add_argument('zip_file', nargs='?', help='要安装的 zip 文件路径(与--install-from-zip等价)')
|
||||
parser.add_argument('--install-version', type=str, help='安装指定版本的 ksaa (例如: --install-version=1.2.3)')
|
||||
parser.add_argument('--install-from-zip', type=str, help='从 zip 文件安装 ksaa (例如: --install-from-zip=/path/to/file.zip)')
|
||||
|
||||
return parser.parse_args()
|
||||
|
||||
def main_launch():
|
||||
"""
|
||||
主启动函数,执行完整的安装和启动流程。
|
||||
"""
|
||||
# 解析命令行参数
|
||||
args = parse_arguments()
|
||||
|
||||
# 处理位置参数:如果提供了zip_file位置参数,将其设置为install_from_zip
|
||||
if args.zip_file and not args.install_from_zip:
|
||||
args.install_from_zip = args.zip_file
|
||||
|
||||
setup_logging()
|
||||
run_command("title 琴音小助手(运行时请勿关闭此窗口)", verbatim=True, log_output=False)
|
||||
clear_screen()
|
||||
print_header("琴音小助手启动器")
|
||||
logging.info("启动器已启动。")
|
||||
|
||||
try:
|
||||
# 1. 加载配置文件(提前加载以获取更新设置)
|
||||
print_header("加载配置", color=Color.BLUE)
|
||||
logging.info("加载配置。")
|
||||
config = load_config()
|
||||
|
||||
# 2. 获取更新设置
|
||||
check_update, auto_install_update = get_update_settings(config if config else {"version": 5, "user_configs": []})
|
||||
|
||||
# 3. 如果指定了特殊安装参数,跳过更新检查
|
||||
if args.install_version or args.install_from_zip:
|
||||
check_update = False
|
||||
auto_install_update = False
|
||||
|
||||
# 4. 根据配置决定是否检查更新
|
||||
print_status("正在寻找最快的 PyPI 镜像源...", status='info')
|
||||
logging.info("正在寻找最快的 PyPI 镜像源...")
|
||||
pip_server = get_working_pip_server()
|
||||
if not pip_server:
|
||||
raise RuntimeError("没有找到可用的pip服务器,请检查网络连接。")
|
||||
|
||||
# 5. 处理特殊安装情况
|
||||
if args.install_from_zip:
|
||||
# 从zip文件安装
|
||||
print_header("安装补丁", color=Color.BLUE)
|
||||
if not install_ksaa_from_zip(args.install_from_zip):
|
||||
raise RuntimeError("从zip文件安装失败,请检查上面的错误日志。")
|
||||
elif args.install_version:
|
||||
# 安装指定版本
|
||||
print_header("安装指定版本", color=Color.BLUE)
|
||||
if not install_ksaa_version(pip_server, TRUSTED_HOSTS, args.install_version):
|
||||
raise RuntimeError("安装指定版本失败,请检查上面的错误日志。")
|
||||
else:
|
||||
# 默认安装和更新逻辑
|
||||
if not install_pip_and_ksaa(pip_server, check_update, auto_install_update):
|
||||
raise RuntimeError("依赖安装失败,请检查上面的错误日志。")
|
||||
|
||||
# 6. 检查Windows截图权限
|
||||
if config:
|
||||
if not check_admin(config):
|
||||
raise RuntimeError("权限检查失败。")
|
||||
|
||||
# 7. 运行琴音小助手
|
||||
if not run_kaa():
|
||||
raise RuntimeError("琴音小助手主程序运行失败。")
|
||||
|
||||
msg = "琴音小助手已退出。"
|
||||
print_status(msg, status='success')
|
||||
logging.info(msg)
|
||||
|
||||
except Exception as e:
|
||||
msg = f"发生致命错误: {e}"
|
||||
print_status(msg, status='error')
|
||||
print_status("压缩 kaa 目录下的 logs 文件夹并给此窗口截图后一并发送给开发者", status='error')
|
||||
logging.critical(msg, exc_info=True)
|
||||
|
||||
finally:
|
||||
logging.info("启动器运行结束。")
|
||||
wait_key("\n按任意键退出...")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main_launch()
|
||||
except KeyboardInterrupt:
|
||||
print_status("运行结束。现在可以安全关闭此窗口。", status='info')
|
|
@ -0,0 +1,235 @@
|
|||
import re
|
||||
import html.parser
|
||||
import urllib.parse
|
||||
from typing import List
|
||||
from dataclasses import dataclass
|
||||
from request import get, HTTPError, NetworkError
|
||||
|
||||
|
||||
@dataclass
|
||||
class Version:
|
||||
"""版本信息"""
|
||||
version_str: str
|
||||
major: int = 0
|
||||
minor: int = 0
|
||||
patch: int = 0
|
||||
prerelease: str = ""
|
||||
prerelease_num: int = 0
|
||||
|
||||
def __post_init__(self):
|
||||
"""初始化后解析版本号"""
|
||||
self._parse_version()
|
||||
|
||||
def _parse_version(self):
|
||||
"""解析版本号字符串"""
|
||||
version_str = self.version_str.lower()
|
||||
|
||||
# 基本版本号匹配 (如 1.2.3, 1.2, 1)
|
||||
version_match = re.match(r'^(\d+)(?:\.(\d+))?(?:\.(\d+))?', version_str)
|
||||
if version_match:
|
||||
self.major = int(version_match.group(1))
|
||||
self.minor = int(version_match.group(2)) if version_match.group(2) else 0
|
||||
self.patch = int(version_match.group(3)) if version_match.group(3) else 0
|
||||
|
||||
# 预发布版本匹配 (如 alpha1, beta2, rc3)
|
||||
prerelease_match = re.search(r'(alpha|beta|rc|dev|pre|post)(\d*)', version_str)
|
||||
if prerelease_match:
|
||||
self.prerelease = prerelease_match.group(1)
|
||||
self.prerelease_num = int(prerelease_match.group(2)) if prerelease_match.group(2) else 0
|
||||
|
||||
def __lt__(self, other):
|
||||
"""版本比较"""
|
||||
if not isinstance(other, Version):
|
||||
return NotImplemented
|
||||
|
||||
# 比较主版本号
|
||||
if self.major != other.major:
|
||||
return self.major < other.major
|
||||
if self.minor != other.minor:
|
||||
return self.minor < other.minor
|
||||
if self.patch != other.patch:
|
||||
return self.patch < other.patch
|
||||
|
||||
# 比较预发布版本
|
||||
prerelease_order = {'': 4, 'rc': 3, 'beta': 2, 'alpha': 1, 'dev': 0, 'pre': 0, 'post': 5}
|
||||
self_order = prerelease_order.get(self.prerelease, 0)
|
||||
other_order = prerelease_order.get(other.prerelease, 0)
|
||||
|
||||
if self_order != other_order:
|
||||
return self_order < other_order
|
||||
|
||||
# 同类型预发布版本比较数字
|
||||
if self.prerelease == other.prerelease:
|
||||
return self.prerelease_num < other.prerelease_num
|
||||
|
||||
return False
|
||||
|
||||
def __eq__(self, other):
|
||||
"""版本相等比较"""
|
||||
if not isinstance(other, Version):
|
||||
return NotImplemented
|
||||
|
||||
return (self.major == other.major and
|
||||
self.minor == other.minor and
|
||||
self.patch == other.patch and
|
||||
self.prerelease == other.prerelease and
|
||||
self.prerelease_num == other.prerelease_num)
|
||||
|
||||
def __repr__(self):
|
||||
return f"Version('{self.version_str}')"
|
||||
|
||||
def __str__(self):
|
||||
return self.version_str
|
||||
|
||||
@dataclass
|
||||
class PackageVersion:
|
||||
"""包版本信息"""
|
||||
version: Version
|
||||
url: str
|
||||
|
||||
|
||||
class PyPIHTMLParser(html.parser.HTMLParser):
|
||||
"""解析PyPI HTML响应的解析器"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.links = []
|
||||
self.current_href = None
|
||||
self.current_text = None
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
if tag == 'a':
|
||||
# 提取href属性
|
||||
for attr_name, attr_value in attrs:
|
||||
if attr_name == 'href':
|
||||
self.current_href = attr_value
|
||||
break
|
||||
|
||||
def handle_data(self, data):
|
||||
if self.current_href:
|
||||
self.current_text = data.strip()
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if tag == 'a' and self.current_href and self.current_text:
|
||||
self.links.append((self.current_text, self.current_href))
|
||||
self.current_href = None
|
||||
self.current_text = None
|
||||
|
||||
|
||||
def normalize_package_name(package_name: str) -> str:
|
||||
"""
|
||||
标准化包名,将 _, -, . 字符视为相等
|
||||
"""
|
||||
return re.sub(r'[_.-]', '-', package_name.lower())
|
||||
|
||||
|
||||
def extract_version_from_filename(filename: str) -> str:
|
||||
"""
|
||||
从文件名中提取版本号
|
||||
|
||||
例如: beautifulsoup4-4.13.0b2-py3-none-any.whl -> 4.13.0b2
|
||||
"""
|
||||
# 匹配版本号模式
|
||||
version_pattern = r'^[^-]+-([^-]+?)(?:-py\d+)?(?:-none-any)?\.(?:whl|tar\.gz|zip)$'
|
||||
match = re.match(version_pattern, filename)
|
||||
if match:
|
||||
return match.group(1)
|
||||
|
||||
# 备用模式:查找版本号
|
||||
version_match = re.search(r'-(\d+\.\d+(?:\.\d+)?(?:[a-zA-Z0-9]*))', filename)
|
||||
if version_match:
|
||||
return version_match.group(1)
|
||||
|
||||
return "unknown"
|
||||
|
||||
|
||||
def list_versions(package_name: str, *, server_url: str | None = None) -> List[PackageVersion]:
|
||||
"""
|
||||
获取指定包的所有可用版本,按版本号降序排列
|
||||
|
||||
:param package_name: 包名
|
||||
:type package_name: str
|
||||
:param server_url: 可选的服务器URL,默认为None时使用PyPI官方服务器(https://pypi.org/simple)。
|
||||
:type server_url: str | None
|
||||
:return: 包含版本信息的列表,按版本号降序排列
|
||||
:rtype: List[PackageVersion]
|
||||
:raises HTTPError: 当包不存在或网络错误时
|
||||
:raises NetworkError: 当网络连接错误时
|
||||
"""
|
||||
# 标准化包名
|
||||
normalized_name = normalize_package_name(package_name)
|
||||
|
||||
# 构建API URL
|
||||
if server_url is None:
|
||||
base_url = "https://pypi.org/simple"
|
||||
else:
|
||||
base_url = server_url.rstrip('/')
|
||||
|
||||
url = f"{base_url}/{urllib.parse.quote(normalized_name)}/"
|
||||
|
||||
# 设置请求头
|
||||
headers = {
|
||||
'Accept': 'application/vnd.pypi.simple.v1+html'
|
||||
}
|
||||
|
||||
try:
|
||||
# 发送请求
|
||||
html_content = get(url, headers=headers).decode('utf-8')
|
||||
|
||||
# 解析HTML
|
||||
parser = PyPIHTMLParser()
|
||||
parser.feed(html_content)
|
||||
|
||||
# 处理链接并提取版本信息
|
||||
versions = []
|
||||
for filename, href in parser.links:
|
||||
# 提取版本号
|
||||
version_str = extract_version_from_filename(filename)
|
||||
|
||||
# 创建Version对象
|
||||
version = Version(version_str)
|
||||
|
||||
# 创建PackageVersion对象
|
||||
package_version = PackageVersion(
|
||||
version=version,
|
||||
url=href
|
||||
)
|
||||
versions.append(package_version)
|
||||
|
||||
# 按版本号降序排列
|
||||
versions.sort(key=lambda x: x.version, reverse=True)
|
||||
|
||||
return versions
|
||||
|
||||
except HTTPError as e:
|
||||
if e.code == 404:
|
||||
raise ValueError(f"包 '{package_name}' 不存在") from e
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def main():
|
||||
"""测试函数"""
|
||||
try:
|
||||
# 测试获取beautifulsoup4的版本
|
||||
print("获取 beautifulsoup4 的版本信息...")
|
||||
versions = list_versions("beautifulsoup4")
|
||||
|
||||
print(f"找到 {len(versions)} 个版本:")
|
||||
for i, pkg_version in enumerate(versions[:10], 1): # 只显示前10个
|
||||
print(f"{i}. 版本: {pkg_version.version.version_str}")
|
||||
print(f" 主版本: {pkg_version.version.major}.{pkg_version.version.minor}.{pkg_version.version.patch}")
|
||||
if pkg_version.version.prerelease:
|
||||
print(f" 预发布: {pkg_version.version.prerelease}{pkg_version.version.prerelease_num}")
|
||||
print(f" URL: {pkg_version.url}")
|
||||
print()
|
||||
|
||||
if len(versions) > 10:
|
||||
print(f"... 还有 {len(versions) - 10} 个版本")
|
||||
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -0,0 +1,248 @@
|
|||
import os
|
||||
import urllib.parse
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Dict, Any, Optional, Callable, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from http.client import HTTPResponse
|
||||
|
||||
|
||||
class HTTPError(Exception):
|
||||
"""HTTP请求错误"""
|
||||
def __init__(self, code: int, message: str):
|
||||
self.code = code
|
||||
self.message = message
|
||||
super().__init__(f"HTTP {code}: {message}")
|
||||
|
||||
|
||||
class NetworkError(Exception):
|
||||
"""网络连接错误"""
|
||||
pass
|
||||
|
||||
|
||||
class Response:
|
||||
"""HTTP响应封装"""
|
||||
|
||||
def __init__(self, http_response: "HTTPResponse"):
|
||||
self._response = http_response
|
||||
self._content: Optional[bytes] = None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.close()
|
||||
|
||||
def close(self):
|
||||
"""关闭响应和底层连接。"""
|
||||
self._response.close()
|
||||
|
||||
@property
|
||||
def status_code(self) -> int:
|
||||
"""响应状态码"""
|
||||
return self._response.getcode()
|
||||
|
||||
@property
|
||||
def reason(self) -> str:
|
||||
"""响应原因短语"""
|
||||
return self._response.reason
|
||||
|
||||
@property
|
||||
def headers(self) -> Dict[str, Any]:
|
||||
"""响应头"""
|
||||
return dict(self._response.headers)
|
||||
|
||||
def read(self) -> bytes:
|
||||
"""读取响应内容 (bytes)"""
|
||||
if self._content is None:
|
||||
self._content = self._response.read()
|
||||
return self._content
|
||||
|
||||
def json(self) -> Any:
|
||||
"""将响应内容解析为JSON"""
|
||||
import json
|
||||
return json.loads(self.read())
|
||||
|
||||
|
||||
def request(
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
headers: Optional[Dict[str, str]] = None,
|
||||
data: Optional[bytes] = None,
|
||||
timeout: Optional[float] = None
|
||||
) -> Response:
|
||||
"""
|
||||
发送HTTP请求
|
||||
|
||||
:param url: 请求URL
|
||||
:type url: str
|
||||
:param method: HTTP方法,默认为GET
|
||||
:type method: str
|
||||
:param headers: 请求头
|
||||
:type headers: Optional[Dict[str, str]]
|
||||
:param data: 请求数据
|
||||
:type data: Optional[bytes]
|
||||
:param timeout: 超时时间(秒)
|
||||
:type timeout: Optional[float]
|
||||
:return: 响应对象
|
||||
:rtype: Response
|
||||
:raises HTTPError: HTTP错误
|
||||
:raises NetworkError: 网络连接错误
|
||||
"""
|
||||
# 设置默认请求头
|
||||
default_headers = {
|
||||
'User-Agent': 'Python-urllib/3.10'
|
||||
}
|
||||
|
||||
if headers:
|
||||
default_headers.update(headers)
|
||||
|
||||
# 创建请求
|
||||
req = urllib.request.Request(url, data=data, headers=default_headers, method=method)
|
||||
|
||||
try:
|
||||
# 发送请求
|
||||
response = urllib.request.urlopen(req, timeout=timeout)
|
||||
return Response(response)
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
raise HTTPError(e.code, e.reason) from e
|
||||
except urllib.error.URLError as e:
|
||||
raise NetworkError(f"网络连接错误: {e.reason}") from e
|
||||
|
||||
|
||||
def get(url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> bytes:
|
||||
"""
|
||||
发送GET请求
|
||||
|
||||
:param url: 请求URL
|
||||
:type url: str
|
||||
:param headers: 请求头
|
||||
:type headers: Optional[Dict[str, str]]
|
||||
:param timeout: 超时时间(秒)
|
||||
:type timeout: Optional[float]
|
||||
:return: 响应内容
|
||||
:rtype: bytes
|
||||
"""
|
||||
with request(url, method="GET", headers=headers, timeout=timeout) as response:
|
||||
return response.read()
|
||||
|
||||
|
||||
def head(url: str, headers: Optional[Dict[str, str]] = None, timeout: Optional[float] = None) -> Response:
|
||||
"""
|
||||
发送HEAD请求
|
||||
|
||||
:param url: 请求URL
|
||||
:type url: str
|
||||
:param headers: 请求头
|
||||
:type headers: Optional[Dict[str, str]]
|
||||
:param timeout: 超时时间(秒)
|
||||
:type timeout: Optional[float]
|
||||
:return: 响应对象
|
||||
:rtype: Response
|
||||
:raises HTTPError: HTTP错误
|
||||
:raises NetworkError: 网络连接错误
|
||||
"""
|
||||
return request(url, method="HEAD", headers=headers, timeout=timeout)
|
||||
|
||||
|
||||
def download_file(
|
||||
url: str,
|
||||
dst_path: str,
|
||||
*,
|
||||
callback: Optional[Callable[[int, int], None]] = None,
|
||||
timeout: Optional[float] = None
|
||||
) -> None:
|
||||
"""
|
||||
下载文件
|
||||
|
||||
:param url: 文件URL
|
||||
:type url: str
|
||||
:param dst_path: 目标路径
|
||||
:type dst_path: str
|
||||
:param callback: 进度回调函数,参数为(已下载字节数, 总字节数)
|
||||
:type callback: Optional[Callable[[int, int], None]]
|
||||
:param timeout: 超时时间(秒)
|
||||
:type timeout: Optional[float]
|
||||
:raises HTTPError: HTTP错误
|
||||
:raises NetworkError: 网络连接错误
|
||||
"""
|
||||
# 设置请求头
|
||||
headers = {
|
||||
'User-Agent': 'Python-urllib/3.10'
|
||||
}
|
||||
|
||||
# 创建请求
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
|
||||
try:
|
||||
# 发送请求
|
||||
with urllib.request.urlopen(req, timeout=timeout) as response:
|
||||
# 获取文件大小
|
||||
content_length = response.headers.get('Content-Length')
|
||||
total_size = int(content_length) if content_length else None
|
||||
|
||||
# 确保目标目录存在
|
||||
os.makedirs(os.path.dirname(dst_path), exist_ok=True)
|
||||
|
||||
# 下载文件
|
||||
downloaded_size = 0
|
||||
with open(dst_path, 'wb') as f:
|
||||
while True:
|
||||
chunk = response.read(8192) # 8KB chunks
|
||||
if not chunk:
|
||||
break
|
||||
|
||||
f.write(chunk)
|
||||
downloaded_size += len(chunk)
|
||||
|
||||
# 调用进度回调
|
||||
if callback and total_size:
|
||||
callback(downloaded_size, total_size)
|
||||
elif callback and not total_size:
|
||||
callback(downloaded_size, -1) # 未知总大小
|
||||
|
||||
except urllib.error.HTTPError as e:
|
||||
raise HTTPError(e.code, e.reason) from e
|
||||
except urllib.error.URLError as e:
|
||||
raise NetworkError(f"网络连接错误: {e.reason}") from e
|
||||
except OSError as e:
|
||||
raise NetworkError(f"文件写入错误: {e}") from e
|
||||
|
||||
|
||||
def main():
|
||||
"""测试函数"""
|
||||
try:
|
||||
# 测试GET请求
|
||||
print("测试GET请求...")
|
||||
response_bytes = get("https://httpbin.org/get")
|
||||
print(f"响应长度: {len(response_bytes)} 字节")
|
||||
|
||||
# 测试文件下载
|
||||
print("\n测试文件下载...")
|
||||
def progress_callback(downloaded: int, total: int):
|
||||
if total > 0:
|
||||
percentage = (downloaded / total) * 100
|
||||
print(f"下载进度: {downloaded}/{total} 字节 ({percentage:.1f}%)")
|
||||
else:
|
||||
print(f"已下载: {downloaded} 字节")
|
||||
|
||||
# 下载一个小文件进行测试
|
||||
download_file(
|
||||
"https://httpbin.org/bytes/1024",
|
||||
"test_download.bin",
|
||||
callback=progress_callback
|
||||
)
|
||||
print("下载完成!")
|
||||
|
||||
# 清理测试文件
|
||||
if os.path.exists("test_download.bin"):
|
||||
os.remove("test_download.bin")
|
||||
|
||||
except Exception as e:
|
||||
print(f"错误: {e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
|
@ -0,0 +1,178 @@
|
|||
import os
|
||||
import ctypes
|
||||
import shutil
|
||||
import unicodedata
|
||||
from typing import Optional
|
||||
|
||||
def _enable_windows_ansi():
|
||||
"""
|
||||
On Windows, attempts to enable ANSI escape sequence processing.
|
||||
"""
|
||||
if os.name == 'nt':
|
||||
try:
|
||||
kernel32 = ctypes.windll.kernel32
|
||||
handle = kernel32.GetStdHandle(-11) # STD_OUTPUT_HANDLE
|
||||
mode = ctypes.c_ulong()
|
||||
if kernel32.GetConsoleMode(handle, ctypes.byref(mode)) == 0:
|
||||
return # Failed to get console mode
|
||||
|
||||
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
|
||||
|
||||
if (mode.value & ENABLE_VIRTUAL_TERMINAL_PROCESSING) == 0:
|
||||
mode.value |= ENABLE_VIRTUAL_TERMINAL_PROCESSING
|
||||
if kernel32.SetConsoleMode(handle, mode) == 0:
|
||||
# Fallback for older systems
|
||||
os.system('')
|
||||
except Exception:
|
||||
# Fallback for environments where ctypes fails (e.g., some IDE terminals)
|
||||
os.system('')
|
||||
|
||||
_enable_windows_ansi()
|
||||
|
||||
class Color:
|
||||
"""ANSI color codes"""
|
||||
RESET = '\033[0m'
|
||||
BOLD = '\033[1m'
|
||||
|
||||
BLACK = '\033[30m'
|
||||
RED = '\033[31m'
|
||||
GREEN = '\033[32m'
|
||||
YELLOW = '\033[33m'
|
||||
BLUE = '\033[34m'
|
||||
MAGENTA = '\033[35m'
|
||||
CYAN = '\033[36m'
|
||||
WHITE = '\033[37m'
|
||||
|
||||
GRAY = '\033[90m'
|
||||
|
||||
def get_terminal_width(default=80):
|
||||
"""Gets the width of the terminal."""
|
||||
# shutil.get_terminal_size is a high-level and more robust way
|
||||
return shutil.get_terminal_size((default, 24)).columns
|
||||
|
||||
def get_terminal_height(default=24):
|
||||
"""Gets the height of the terminal."""
|
||||
return shutil.get_terminal_size((default, 24)).lines
|
||||
|
||||
def get_display_width(s: str) -> int:
|
||||
"""Calculates the display width of a string, accounting for wide characters."""
|
||||
width = 0
|
||||
for char in s:
|
||||
# 'F' (Fullwidth), 'W' (Wide) characters take up 2 columns.
|
||||
if unicodedata.east_asian_width(char) in ('F', 'W'):
|
||||
width += 2
|
||||
else:
|
||||
width += 1
|
||||
return width
|
||||
|
||||
def truncate_string(s: str, max_width: int) -> str:
|
||||
"""Truncates a string to a maximum display width, handling wide characters."""
|
||||
if not s or max_width <= 0:
|
||||
return ""
|
||||
|
||||
width = 0
|
||||
end_pos = 0
|
||||
for i, char in enumerate(s):
|
||||
# 'F' (Fullwidth), 'W' (Wide) characters take up 2 columns.
|
||||
char_width = 2 if unicodedata.east_asian_width(char) in ('F', 'W') else 1
|
||||
if width + char_width > max_width:
|
||||
break
|
||||
width += char_width
|
||||
end_pos = i + 1
|
||||
return s[:end_pos]
|
||||
|
||||
def hide_cursor():
|
||||
"""Hides the terminal cursor."""
|
||||
print('\033[?25l', end='')
|
||||
|
||||
def show_cursor():
|
||||
"""Shows the terminal cursor."""
|
||||
print('\033[?25h', end='')
|
||||
|
||||
def move_cursor_up(lines: int):
|
||||
"""Moves the cursor up by a number of lines."""
|
||||
if lines > 0:
|
||||
print(f'\033[{lines}A', end='')
|
||||
|
||||
def clear_screen():
|
||||
"""Clears the terminal screen."""
|
||||
os.system('cls' if os.name == 'nt' else 'clear')
|
||||
|
||||
def print_header(text: str, color: str = Color.CYAN):
|
||||
"""
|
||||
Prints a centered header with separators that fill the terminal width.
|
||||
Accounts for CJK character widths.
|
||||
|
||||
:param text: The text to display in the header.
|
||||
:param color: ANSI color code for the header text.
|
||||
"""
|
||||
width = get_terminal_width()
|
||||
padded_text = f" {text} "
|
||||
text_display_width = get_display_width(padded_text)
|
||||
|
||||
# Handle cases where the text is wider than the terminal
|
||||
if text_display_width >= width:
|
||||
print(f"\n{Color.BOLD}{color}{text}{Color.RESET}")
|
||||
return
|
||||
|
||||
separator_total_len = width - text_display_width
|
||||
l_separator_len = separator_total_len // 2
|
||||
r_separator_len = separator_total_len - l_separator_len
|
||||
|
||||
l_separator = "━" * l_separator_len
|
||||
r_separator = "━" * r_separator_len
|
||||
|
||||
print(f"\n{Color.BOLD}{color}{l_separator}{padded_text}{r_separator}{Color.RESET}")
|
||||
|
||||
def print_status(message: str, success: Optional[bool] = None, status: str = 'info', indent: int = 0):
|
||||
"""
|
||||
Prints a status message with a symbol and color.
|
||||
|
||||
:param message: The status message to print.
|
||||
:param success: (Deprecated) If True, sets status to 'success'; if False, 'error'.
|
||||
:param status: 'success', 'error', 'warning', or 'info'.
|
||||
:param indent: Number of spaces to indent.
|
||||
"""
|
||||
prefix = " " * indent
|
||||
|
||||
# Backward compatibility
|
||||
if success is not None:
|
||||
status = 'success' if success else 'error'
|
||||
|
||||
if status == 'success':
|
||||
symbol = f"{Color.GREEN}✓"
|
||||
elif status == 'error':
|
||||
symbol = f"{Color.RED}✗"
|
||||
elif status == 'warning':
|
||||
symbol = f"{Color.YELLOW}⚠"
|
||||
else: # 'info'
|
||||
symbol = f"{Color.BLUE}ℹ{Color.RESET}"
|
||||
|
||||
print(f"{prefix}[{symbol}] {message}{Color.RESET}")
|
||||
|
||||
def wait_key(message: str = ""):
|
||||
"""
|
||||
Prints a message and waits for a single key press from the user.
|
||||
This is a cross-platform function.
|
||||
|
||||
:param message: The message to display before waiting.
|
||||
"""
|
||||
print(message, end="", flush=True)
|
||||
|
||||
if os.name == 'nt':
|
||||
import msvcrt
|
||||
msvcrt.getch()
|
||||
else:
|
||||
import sys
|
||||
import tty
|
||||
import termios
|
||||
fd = sys.stdin.fileno()
|
||||
old_settings = termios.tcgetattr(fd)
|
||||
try:
|
||||
tty.setraw(sys.stdin.fileno())
|
||||
sys.stdin.read(1)
|
||||
finally:
|
||||
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
||||
|
||||
# Add a newline after the key is pressed for cleaner output
|
||||
print()
|
|
@ -0,0 +1,418 @@
|
|||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
*.env
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
[Aa][Rr][Mm]64[Ee][Cc]/
|
||||
bld/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Build results on 'Bin' directories
|
||||
**/[Bb]in/*
|
||||
# Uncomment if you have tasks that rely on *.refresh files to move binaries
|
||||
# (https://github.com/github/gitignore/pull/3736)
|
||||
#!**/[Bb]in/*.refresh
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
*.trx
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Approval Tests result files
|
||||
*.received.*
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.idb
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
# but not Directory.Build.rsp, as it configures directory-level build defaults
|
||||
!Directory.Build.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.tlog
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
|
||||
*.vbp
|
||||
|
||||
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
|
||||
*.dsw
|
||||
*.dsp
|
||||
|
||||
# Visual Studio 6 technical files
|
||||
*.ncb
|
||||
*.aps
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
**/.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
**/.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
**/.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
**/__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
#tools/**
|
||||
#!tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
MSBuild_Logs/
|
||||
|
||||
# AWS SAM Build and Temporary Artifacts folder
|
||||
.aws-sam
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
**/.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
**/.localhistory/
|
||||
|
||||
# Visual Studio History (VSHistory) files
|
||||
.vshistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
**/.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
|
||||
# VS Code files for those working on multiple tools
|
||||
.vscode/*
|
||||
!.vscode/settings.json
|
||||
!.vscode/tasks.json
|
||||
!.vscode/launch.json
|
||||
!.vscode/extensions.json
|
||||
!.vscode/*.code-snippets
|
||||
|
||||
# Local History for Visual Studio Code
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
# Windows Installer files from build outputs
|
||||
*.cab
|
||||
*.msi
|
||||
*.msix
|
||||
*.msm
|
||||
*.msp
|
|
@ -0,0 +1,18 @@
|
|||
//{{NO_DEPENDENCIES}}
|
||||
// Microsoft Visual C++ 生成的包含文件。
|
||||
// 使用者 kaa-wrapper.rc
|
||||
|
||||
#define IDI_KAAWRAPPER 107
|
||||
#define IDI_SMALL 108
|
||||
|
||||
// 新对象的下一组默认值
|
||||
//
|
||||
#ifdef APSTUDIO_INVOKED
|
||||
#ifndef APSTUDIO_READONLY_SYMBOLS
|
||||
#define _APS_NO_MFC 130
|
||||
#define _APS_NEXT_RESOURCE_VALUE 129
|
||||
#define _APS_NEXT_COMMAND_VALUE 32771
|
||||
#define _APS_NEXT_CONTROL_VALUE 1000
|
||||
#define _APS_NEXT_SYMED_VALUE 110
|
||||
#endif
|
||||
#endif
|
|
@ -0,0 +1,10 @@
|
|||
// header.h: 标准系统包含文件的包含文件,
|
||||
// 或特定于项目的包含文件
|
||||
//
|
||||
|
||||
#pragma once
|
||||
|
||||
#include "targetver.h"
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
#include <tchar.h>
|
|
@ -0,0 +1,88 @@
|
|||
// kaa-wrapper.cpp : 定义应用程序的入口点。
|
||||
//
|
||||
|
||||
#include "framework.h"
|
||||
#include "kaa-wrapper.h"
|
||||
#include <string>
|
||||
|
||||
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
|
||||
_In_opt_ HINSTANCE hPrevInstance,
|
||||
_In_ LPWSTR lpCmdLine,
|
||||
_In_ int nCmdShow)
|
||||
{
|
||||
UNREFERENCED_PARAMETER(hInstance);
|
||||
UNREFERENCED_PARAMETER(hPrevInstance);
|
||||
UNREFERENCED_PARAMETER(nCmdShow);
|
||||
|
||||
// 设置当前目录为程序所在目录
|
||||
WCHAR szPath[MAX_PATH];
|
||||
if (GetModuleFileNameW(NULL, szPath, MAX_PATH) == 0) {
|
||||
MessageBoxW(NULL, L"无法获取程序所在目录", L"错误", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
std::wstring path(szPath);
|
||||
size_t pos = path.find_last_of(L"\\");
|
||||
if (pos == std::wstring::npos) {
|
||||
MessageBoxW(NULL, L"程序路径格式错误", L"错误", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
path = path.substr(0, pos);
|
||||
if (!SetCurrentDirectoryW(path.c_str())) {
|
||||
MessageBoxW(NULL, L"无法设置工作目录", L"错误", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 检查 Python 解释器是否存在
|
||||
std::wstring pythonPath = path + L"\\WPy64-310111\\python-3.10.11.amd64\\python.exe";
|
||||
if (GetFileAttributesW(pythonPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
|
||||
MessageBoxW(NULL, L"找不到 Python 解释器", L"错误", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 检查 bootstrap.pyz 是否存在
|
||||
std::wstring bootstrapPath = path + L"\\bootstrap.pyz";
|
||||
if (GetFileAttributesW(bootstrapPath.c_str()) == INVALID_FILE_ATTRIBUTES) {
|
||||
MessageBoxW(NULL, L"找不到 bootstrap.pyz 文件", L"错误", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 构建命令行
|
||||
std::wstring cmd = L"\"" + pythonPath + L"\" \"" + bootstrapPath + L"\"";
|
||||
|
||||
// 如果有命令行参数,将其传递给 bootstrap
|
||||
if (lpCmdLine && wcslen(lpCmdLine) > 0) {
|
||||
cmd += L" ";
|
||||
cmd += lpCmdLine;
|
||||
}
|
||||
|
||||
// 启动信息
|
||||
STARTUPINFOW si = { sizeof(si) };
|
||||
PROCESS_INFORMATION pi;
|
||||
|
||||
// 创建进程,使用当前目录作为工作目录
|
||||
if (!CreateProcessW(NULL,
|
||||
const_cast<LPWSTR>(cmd.c_str()),
|
||||
NULL,
|
||||
NULL,
|
||||
FALSE,
|
||||
0,
|
||||
NULL,
|
||||
path.c_str(), // 设置工作目录为当前目录
|
||||
&si,
|
||||
&pi))
|
||||
{
|
||||
DWORD error = GetLastError();
|
||||
WCHAR errorMsg[256];
|
||||
swprintf_s(errorMsg, L"无法启动程序 (错误代码: %d)", error);
|
||||
MessageBoxW(NULL, errorMsg, L"错误", MB_OK | MB_ICONERROR);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// 关闭进程和线程句柄
|
||||
CloseHandle(pi.hProcess);
|
||||
CloseHandle(pi.hThread);
|
||||
|
||||
return 0;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
#pragma once
|
||||
|
||||
#include "resource.h"
|
After Width: | Height: | Size: 160 KiB |
|
@ -0,0 +1,31 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.10.35013.160
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "kaa-wrapper", "kaa-wrapper.vcxproj", "{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x64.ActiveCfg = Debug|x64
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x64.Build.0 = Debug|x64
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x86.ActiveCfg = Debug|Win32
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Debug|x86.Build.0 = Debug|Win32
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x64.ActiveCfg = Release|x64
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x64.Build.0 = Release|x64
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x86.ActiveCfg = Release|Win32
|
||||
{F4F29940-A4DD-40C0-A433-1CF3C7B6F55C}.Release|x86.Build.0 = Release|Win32
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {304B08B9-3494-48C3-94A5-9486F9D16B10}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
|
@ -0,0 +1,148 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Debug|Win32">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<Keyword>Win32Proj</Keyword>
|
||||
<ProjectGuid>{f4f29940-a4dd-40c0-a433-1cf3c7b6f55c}</ProjectGuid>
|
||||
<RootNamespace>kaawrapper</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'" Label="Configuration">
|
||||
<ConfigurationType>Application</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
<ImportGroup Label="ExtensionSettings">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="Shared">
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<ImportGroup Label="PropertySheets" Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<Import Project="$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props" Condition="exists('$(UserRootDir)\Microsoft.Cpp.$(Platform).user.props')" Label="LocalAppDataPlatform" />
|
||||
</ImportGroup>
|
||||
<PropertyGroup Label="UserMacros" />
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>WIN32;NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Debug|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>_DEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemDefinitionGroup Condition="'$(Configuration)|$(Platform)'=='Release|x64'">
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<FunctionLevelLinking>true</FunctionLevelLinking>
|
||||
<IntrinsicFunctions>true</IntrinsicFunctions>
|
||||
<SDLCheck>true</SDLCheck>
|
||||
<PreprocessorDefinitions>NDEBUG;_WINDOWS;%(PreprocessorDefinitions)</PreprocessorDefinitions>
|
||||
<ConformanceMode>true</ConformanceMode>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<EnableCOMDATFolding>true</EnableCOMDATFolding>
|
||||
<OptimizeReferences>true</OptimizeReferences>
|
||||
<GenerateDebugInformation>true</GenerateDebugInformation>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="framework.h" />
|
||||
<ClInclude Include="kaa-wrapper.h" />
|
||||
<ClInclude Include="Resource.h" />
|
||||
<ClInclude Include="targetver.h" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="kaa-wrapper.cpp" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ResourceCompile Include="kaa-wrapper.rc" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Image Include="kaa-wrapper.ico" />
|
||||
<Image Include="small.ico" />
|
||||
</ItemGroup>
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
<ImportGroup Label="ExtensionTargets">
|
||||
</ImportGroup>
|
||||
</Project>
|
|
@ -0,0 +1,49 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="4.0" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<ItemGroup>
|
||||
<Filter Include="源文件">
|
||||
<UniqueIdentifier>{4FC737F1-C7A5-4376-A066-2A32D752A2FF}</UniqueIdentifier>
|
||||
<Extensions>cpp;c;cc;cxx;c++;cppm;ixx;def;odl;idl;hpj;bat;asm;asmx</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="头文件">
|
||||
<UniqueIdentifier>{93995380-89BD-4b04-88EB-625FBE52EBFB}</UniqueIdentifier>
|
||||
<Extensions>h;hh;hpp;hxx;h++;hm;inl;inc;ipp;xsd</Extensions>
|
||||
</Filter>
|
||||
<Filter Include="资源文件">
|
||||
<UniqueIdentifier>{67DA6AB6-F800-4c08-8B7A-83BB121AAD01}</UniqueIdentifier>
|
||||
<Extensions>rc;ico;cur;bmp;dlg;rc2;rct;bin;rgs;gif;jpg;jpeg;jpe;resx;tiff;tif;png;wav;mfcribbon-ms</Extensions>
|
||||
</Filter>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClInclude Include="framework.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="targetver.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="Resource.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
<ClInclude Include="kaa-wrapper.h">
|
||||
<Filter>头文件</Filter>
|
||||
</ClInclude>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ClCompile Include="kaa-wrapper.cpp">
|
||||
<Filter>源文件</Filter>
|
||||
</ClCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ResourceCompile Include="kaa-wrapper.rc">
|
||||
<Filter>资源文件</Filter>
|
||||
</ResourceCompile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Image Include="small.ico">
|
||||
<Filter>资源文件</Filter>
|
||||
</Image>
|
||||
<Image Include="kaa-wrapper.ico">
|
||||
<Filter>资源文件</Filter>
|
||||
</Image>
|
||||
</ItemGroup>
|
||||
</Project>
|
After Width: | Height: | Size: 160 KiB |
|
@ -0,0 +1,6 @@
|
|||
#pragma once
|
||||
|
||||
// // 包含 SDKDDKVer.h 可定义可用的最高版本的 Windows 平台。
|
||||
// 如果希望为之前的 Windows 平台构建应用程序,在包含 SDKDDKVer.h 之前请先包含 WinSDKVer.h 并
|
||||
// 将 _WIN32_WINNT 宏设置为想要支持的平台。
|
||||
#include <SDKDDKVer.h>
|
|
@ -1,41 +0,0 @@
|
|||
@echo off
|
||||
call WPy64-310111\scripts\env.bat
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
set PIP_EXTRA_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ http://mirrors.aliyun.com/pypi/simple/
|
||||
|
||||
echo =========== 安装与更新 KAA ===========
|
||||
:INSTALL
|
||||
echo 检查 pip
|
||||
python -m pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade pip
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
pip config set global.trusted-host "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
|
||||
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
echo 安装 ksaa
|
||||
pip install --upgrade ksaa
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
echo =========== 当前版本 ===========
|
||||
pip show ksaa
|
||||
|
||||
echo =========== 运行 KAA ===========
|
||||
:RUN
|
||||
set no_proxy=localhost, 127.0.0.1, ::1
|
||||
kaa
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
echo =========== 运行结束 ===========
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:ERROR
|
||||
echo 发生错误,程序退出
|
||||
pause
|
||||
exit /b 1
|
|
@ -1,54 +0,0 @@
|
|||
@echo off
|
||||
cd /D %~dp0
|
||||
|
||||
REM https://superuser.com/questions/788924/is-it-possible-to-automatically-run-a-batch-file-as-administrator
|
||||
REM --> Check for permissions
|
||||
>nul 2>&1 "%SYSTEMROOT%\system32\cacls.exe" "%SYSTEMROOT%\system32\config\system"
|
||||
|
||||
REM --> If error flag set, we do not have admin.
|
||||
if '%errorlevel%' NEQ '0' (
|
||||
echo 需要以管理员身份运行。右键此脚本,选择“以管理员身份运行”。
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call WPy64-310111\scripts\env.bat
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
set PIP_EXTRA_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple/ http://mirrors.aliyun.com/pypi/simple/
|
||||
|
||||
echo =========== 安装与更新 KAA ===========
|
||||
:INSTALL
|
||||
echo 检查 pip
|
||||
python -m pip install -i https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple --upgrade pip
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
pip config set global.trusted-host "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn"
|
||||
pip config set global.index-url https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple
|
||||
echo 安装 ksaa
|
||||
pip install --upgrade ksaa
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
echo =========== 当前版本 ===========
|
||||
pip show ksaa
|
||||
|
||||
echo =========== 运行 KAA ===========
|
||||
:RUN
|
||||
set no_proxy=localhost, 127.0.0.1, ::1
|
||||
kaa
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
echo =========== 运行结束 ===========
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:ERROR
|
||||
echo 发生错误,程序退出
|
||||
pause
|
||||
exit /b 1
|
|
@ -1,40 +0,0 @@
|
|||
@echo off
|
||||
call WPy64-310111\scripts\env.bat
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
if "%~1"=="" (
|
||||
echo 请将 Python 包文件拖到此脚本上
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo =========== 卸载原有包 ===========
|
||||
pip uninstall -y ksaa
|
||||
pip uninstall -y ksaa_res
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
:INSTALL_LOOP
|
||||
if "%~1"=="" goto INSTALL_DONE
|
||||
|
||||
echo =========== 安装 %~1 ===========
|
||||
pip install "%~1"
|
||||
if errorlevel 1 (
|
||||
goto ERROR
|
||||
)
|
||||
|
||||
shift
|
||||
goto INSTALL_LOOP
|
||||
|
||||
:INSTALL_DONE
|
||||
echo =========== 安装完成 ===========
|
||||
pause
|
||||
exit /b 0
|
||||
|
||||
:ERROR
|
||||
echo 发生错误,程序退出
|
||||
pause
|
||||
exit /b 1
|
22
justfile
|
@ -41,10 +41,6 @@ env: fetch-submodule
|
|||
}
|
||||
python tools/make_resources.py
|
||||
|
||||
# Build the project using pyinstaller
|
||||
build: env
|
||||
pyinstaller -y kotonebot-gr.spec
|
||||
|
||||
generate-metadata: env
|
||||
#!{{shebang_python}}
|
||||
# 更新日志
|
||||
|
@ -124,3 +120,21 @@ publish-test: package
|
|||
|
||||
#
|
||||
build-bootstrap:
|
||||
#!{{shebang_pwsh}}
|
||||
echo "Building bootstrap..."
|
||||
# 构建 Python
|
||||
cd bootstrap
|
||||
python -m zipapp kaa-bootstrap
|
||||
mv kaa-bootstrap.pyz ../dist/bootstrap.pyz -fo
|
||||
|
||||
# 构建 C++
|
||||
$msbuild = &"${env:ProgramFiles(x86)}\Microsoft Visual Studio\Installer\vswhere.exe" -latest -prerelease -products * -requires Microsoft.Component.MSBuild -find MSBuild\**\Bin\MSBuild.exe
|
||||
if ($msbuild) {
|
||||
& $msbuild kaa-wrapper/kaa-wrapper.sln /p:Configuration=Release
|
||||
mv kaa-wrapper/x64/Release/kaa-wrapper.exe ../dist/kaa.exe -fo
|
||||
} else {
|
||||
Write-Host "MSBuild not found. Please install Visual Studio or build kaa-wrapper manually."
|
||||
}
|
||||
|
||||
# Build kaa and bootstrap
|
||||
build: package build-bootstrap
|
||||
|
|
|
@ -1 +1 @@
|
|||
{"definitions":{"0949c622-9067-4f0d-bac2-3f938a1d2ed2":{"name":"Shop.ItemLessonNote","displayName":"レッスンノート","type":"template","annotationId":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","useHintRect":false},"b2af59e9-60e3-4d97-8c72-c7ba092113a3":{"name":"Shop.ItemVeteranNote","displayName":"ベテランノート","type":"template","annotationId":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","useHintRect":false},"835489e2-b29b-426c-b4c9-3bb9f8eb6195":{"name":"Shop.ItemSupportEnhancementPt","displayName":"サポート強化Pt 支援强化Pt","type":"template","annotationId":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","useHintRect":false},"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf":{"name":"Shop.ItemSenseNoteVocal","displayName":"センスノート(ボーカル)感性笔记(声乐)","type":"template","annotationId":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","useHintRect":false},"0f7d581d-cea3-4039-9205-732e4cd29293":{"name":"Shop.ItemSenseNoteDance","displayName":"センスノート(ダンス)感性笔记(舞蹈)","type":"template","annotationId":"0f7d581d-cea3-4039-9205-732e4cd29293","useHintRect":false},"d3cc3323-51af-4882-ae12-49e7384b746d":{"name":"Shop.ItemSenseNoteVisual","displayName":"センスノート(ビジュアル)感性笔记(形象)","type":"template","annotationId":"d3cc3323-51af-4882-ae12-49e7384b746d","useHintRect":false},"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57":{"name":"Shop.ItemLogicNoteVocal","displayName":"ロジックノート(ボーカル)理性笔记(声乐)","type":"template","annotationId":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","useHintRect":false},"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f":{"name":"Shop.ItemLogicNoteDance","displayName":"ロジックノート(ダンス)理性笔记(舞蹈)","type":"template","annotationId":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","useHintRect":false},"c3f536d6-a04a-4651-b3f9-dd2c22593f7f":{"name":"Shop.ItemLogicNoteVisual","displayName":"ロジックノート(ビジュアル)理性笔记(形象)","type":"template","annotationId":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","useHintRect":false},"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0":{"name":"Shop.ItemAnomalyNoteVocal","displayName":"アノマリーノート(ボーカル)非凡笔记(声乐)","type":"template","annotationId":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","useHintRect":false},"df991b42-ed8e-4f2c-bf0c-aa7522f147b6":{"name":"Shop.ItemAnomalyNoteDance","displayName":"アノマリーノート(ダンス)非凡笔记(舞蹈)","type":"template","annotationId":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","useHintRect":false}},"annotations":[{"id":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","type":"rect","data":{"x1":243,"y1":355,"x2":313,"y2":441}},{"id":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","type":"rect","data":{"x1":414,"y1":355,"x2":484,"y2":441}},{"id":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","type":"rect","data":{"x1":574,"y1":363,"x2":662,"y2":438}},{"id":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","type":"rect","data":{"x1":71,"y1":594,"x2":142,"y2":667}},{"id":"0f7d581d-cea3-4039-9205-732e4cd29293","type":"rect","data":{"x1":241,"y1":593,"x2":309,"y2":667}},{"id":"d3cc3323-51af-4882-ae12-49e7384b746d","type":"rect","data":{"x1":417,"y1":586,"x2":481,"y2":668}},{"id":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","type":"rect","data":{"x1":585,"y1":591,"x2":651,"y2":669}},{"id":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","type":"rect","data":{"x1":69,"y1":825,"x2":138,"y2":899}},{"id":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","type":"rect","data":{"x1":242,"y1":820,"x2":310,"y2":898}},{"id":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","type":"rect","data":{"x1":413,"y1":821,"x2":481,"y2":897}},{"id":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","type":"rect","data":{"x1":583,"y1":823,"x2":649,"y2":900}}]}
|
||||
{"definitions":{"0949c622-9067-4f0d-bac2-3f938a1d2ed2":{"name":"Shop.ItemLessonNote","displayName":"レッスンノート","type":"template","annotationId":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","useHintRect":false},"b2af59e9-60e3-4d97-8c72-c7ba092113a3":{"name":"Shop.ItemVeteranNote","displayName":"ベテランノート","type":"template","annotationId":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","useHintRect":false},"835489e2-b29b-426c-b4c9-3bb9f8eb6195":{"name":"Shop.ItemSupportEnhancementPt","displayName":"サポート強化Pt 支援强化Pt","type":"template","annotationId":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","useHintRect":false},"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf":{"name":"Shop.ItemSenseNoteVocal","displayName":"センスノート(ボーカル)感性笔记(声乐)","type":"template","annotationId":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","useHintRect":false},"0f7d581d-cea3-4039-9205-732e4cd29293":{"name":"Shop.ItemSenseNoteDance","displayName":"センスノート(ダンス)感性笔记(舞蹈)","type":"template","annotationId":"0f7d581d-cea3-4039-9205-732e4cd29293","useHintRect":false},"d3cc3323-51af-4882-ae12-49e7384b746d":{"name":"Shop.ItemSenseNoteVisual","displayName":"センスノート(ビジュアル)感性笔记(形象)","type":"template","annotationId":"d3cc3323-51af-4882-ae12-49e7384b746d","useHintRect":false},"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57":{"name":"Shop.ItemLogicNoteVocal","displayName":"ロジックノート(ボーカル)理性笔记(声乐)","type":"template","annotationId":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","useHintRect":false},"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f":{"name":"Shop.ItemLogicNoteDance","displayName":"ロジックノート(ダンス)理性笔记(舞蹈)","type":"template","annotationId":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","useHintRect":false},"c3f536d6-a04a-4651-b3f9-dd2c22593f7f":{"name":"Shop.ItemLogicNoteVisual","displayName":"ロジックノート(ビジュアル)理性笔记(形象)","type":"template","annotationId":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","useHintRect":false},"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0":{"name":"Shop.ItemAnomalyNoteVocal","displayName":"アノマリーノート(ボーカル)非凡笔记(声乐)","type":"template","annotationId":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","useHintRect":false},"df991b42-ed8e-4f2c-bf0c-aa7522f147b6":{"name":"Shop.ItemAnomalyNoteDance","displayName":"アノマリーノート(ダンス)非凡笔记(舞蹈)","type":"template","annotationId":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","useHintRect":false},"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a":{"name":"Daily.ButtonRefreshMoneyShop","displayName":"リスト更新:1回無料","type":"template","annotationId":"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a","useHintRect":false}},"annotations":[{"id":"0949c622-9067-4f0d-bac2-3f938a1d2ed2","type":"rect","data":{"x1":243,"y1":355,"x2":313,"y2":441}},{"id":"b2af59e9-60e3-4d97-8c72-c7ba092113a3","type":"rect","data":{"x1":414,"y1":355,"x2":484,"y2":441}},{"id":"835489e2-b29b-426c-b4c9-3bb9f8eb6195","type":"rect","data":{"x1":574,"y1":363,"x2":662,"y2":438}},{"id":"c5b7d67e-7260-4f08-a0e9-4d31ce9bbecf","type":"rect","data":{"x1":71,"y1":594,"x2":142,"y2":667}},{"id":"0f7d581d-cea3-4039-9205-732e4cd29293","type":"rect","data":{"x1":241,"y1":593,"x2":309,"y2":667}},{"id":"d3cc3323-51af-4882-ae12-49e7384b746d","type":"rect","data":{"x1":417,"y1":586,"x2":481,"y2":668}},{"id":"a1df3af1-a3e7-4521-a085-e4dc3cd1cc57","type":"rect","data":{"x1":585,"y1":591,"x2":651,"y2":669}},{"id":"a9fcaf04-0c1f-4b0f-bb5b-ede9da96180f","type":"rect","data":{"x1":69,"y1":825,"x2":138,"y2":899}},{"id":"c3f536d6-a04a-4651-b3f9-dd2c22593f7f","type":"rect","data":{"x1":242,"y1":820,"x2":310,"y2":898}},{"id":"eef25cf9-afd0-43b1-b9c5-fbd997bd5fe0","type":"rect","data":{"x1":413,"y1":821,"x2":481,"y2":897}},{"id":"df991b42-ed8e-4f2c-bf0c-aa7522f147b6","type":"rect","data":{"x1":583,"y1":823,"x2":649,"y2":900}},{"id":"81c97cd3-df53-44d3-bf3d-1eb4dc67b62a","type":"rect","data":{"x1":440,"y1":149,"x2":679,"y2":179}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"9340b854-025c-40da-9387-385d38433bef":{"name":"Shop.ItemAnomalyNoteVisual","displayName":"アノマリーノート(ビジュアル)非凡笔记(形象)","type":"template","annotationId":"9340b854-025c-40da-9387-385d38433bef","useHintRect":false},"ea1ba124-9cb3-4427-969a-bacd47e7d920":{"name":"Shop.ItemRechallengeTicket","displayName":"再挑戦チケット 重新挑战券","type":"template","annotationId":"ea1ba124-9cb3-4427-969a-bacd47e7d920","useHintRect":false},"1926f2f9-4bd7-48eb-9eba-28ec4efb0606":{"name":"Shop.ItemRecordKey","displayName":"記録の鍵 解锁交流的物品","type":"template","annotationId":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","useHintRect":false},"6720b6e8-ae80-4cc0-a885-518efe12b707":{"name":"Shop.IdolPiece.倉本千奈_WonderScale","displayName":"倉本千奈 WonderScale 碎片","type":"template","annotationId":"6720b6e8-ae80-4cc0-a885-518efe12b707","useHintRect":false},"afa06fdc-a345-4384-b25d-b16540830256":{"name":"Shop.IdolPiece.篠泽广_光景","displayName":"篠泽广 光景 碎片","type":"template","annotationId":"afa06fdc-a345-4384-b25d-b16540830256","useHintRect":false},"278b7d9c-707e-4392-9677-74574b5cdf42":{"name":"Shop.IdolPiece.紫云清夏_TameLieOneStep","displayName":"紫云清夏 Tame-Lie-One-Step 碎片","type":"template","annotationId":"278b7d9c-707e-4392-9677-74574b5cdf42","useHintRect":false},"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730":{"name":"Daily.IconTitleDailyShop","displayName":"日常商店标题图标","type":"template","annotationId":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","useHintRect":false}},"annotations":[{"id":"9340b854-025c-40da-9387-385d38433bef","type":"rect","data":{"x1":72,"y1":611,"x2":138,"y2":693}},{"id":"ea1ba124-9cb3-4427-969a-bacd47e7d920","type":"rect","data":{"x1":227,"y1":639,"x2":316,"y2":674}},{"id":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","type":"rect","data":{"x1":385,"y1":591,"x2":508,"y2":694}},{"id":"6720b6e8-ae80-4cc0-a885-518efe12b707","type":"rect","data":{"x1":589,"y1":633,"x2":638,"y2":678}},{"id":"afa06fdc-a345-4384-b25d-b16540830256","type":"rect","data":{"x1":83,"y1":867,"x2":134,"y2":912}},{"id":"278b7d9c-707e-4392-9677-74574b5cdf42","type":"rect","data":{"x1":247,"y1":864,"x2":301,"y2":907}},{"id":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","type":"rect","data":{"x1":17,"y1":35,"x2":59,"y2":76}}]}
|
||||
{"definitions":{"9340b854-025c-40da-9387-385d38433bef":{"name":"Shop.ItemAnomalyNoteVisual","displayName":"アノマリーノート(ビジュアル)非凡笔记(形象)","type":"template","annotationId":"9340b854-025c-40da-9387-385d38433bef","useHintRect":false},"ea1ba124-9cb3-4427-969a-bacd47e7d920":{"name":"Shop.ItemRechallengeTicket","displayName":"再挑戦チケット 重新挑战券","type":"template","annotationId":"ea1ba124-9cb3-4427-969a-bacd47e7d920","useHintRect":false},"1926f2f9-4bd7-48eb-9eba-28ec4efb0606":{"name":"Shop.ItemRecordKey","displayName":"記録の鍵 解锁交流的物品","type":"template","annotationId":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","useHintRect":false},"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730":{"name":"Daily.IconTitleDailyShop","displayName":"日常商店标题图标","type":"template","annotationId":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","useHintRect":false}},"annotations":[{"id":"9340b854-025c-40da-9387-385d38433bef","type":"rect","data":{"x1":72,"y1":611,"x2":138,"y2":693}},{"id":"ea1ba124-9cb3-4427-969a-bacd47e7d920","type":"rect","data":{"x1":227,"y1":639,"x2":316,"y2":674}},{"id":"1926f2f9-4bd7-48eb-9eba-28ec4efb0606","type":"rect","data":{"x1":385,"y1":591,"x2":508,"y2":694}},{"id":"e9ee330d-dfca-440e-8b8c-0a3b4e8c8730","type":"rect","data":{"x1":17,"y1":35,"x2":59,"y2":76}}]}
|
Before Width: | Height: | Size: 300 KiB |
|
@ -1 +0,0 @@
|
|||
{"definitions":{"74ff07b3-d91c-4579-80cd-379ed7020622":{"name":"Shop.IdolPiece.葛城リーリヤ_白線","displayName":"葛城リーリヤ 白線 碎片","type":"template","annotationId":"74ff07b3-d91c-4579-80cd-379ed7020622","useHintRect":false}},"annotations":[{"id":"74ff07b3-d91c-4579-80cd-379ed7020622","type":"rect","data":{"x1":101,"y1":630,"x2":135,"y2":664}}]}
|
Before Width: | Height: | Size: 307 KiB |
|
@ -1 +0,0 @@
|
|||
{"definitions":{"a7f5abf1-982f-4a55-8d41-3ad6f56798e0":{"name":"Shop.IdolPiece.姫崎薪波_cIclumsy_trick ","displayName":"姫崎薪波 cIclumsy trick 碎片","type":"template","annotationId":"a7f5abf1-982f-4a55-8d41-3ad6f56798e0","useHintRect":false}},"annotations":[{"id":"a7f5abf1-982f-4a55-8d41-3ad6f56798e0","type":"rect","data":{"x1":113,"y1":628,"x2":148,"y2":656}}]}
|
Before Width: | Height: | Size: 295 KiB |
|
@ -1 +0,0 @@
|
|||
{"definitions":{"2bc00520-0afe-40e5-8743-d33fc6b2945a":{"name":"Shop.IdolPiece.花海咲季_FightingMyWay","displayName":"花海咲季 FightingMyWay 碎片","type":"template","annotationId":"2bc00520-0afe-40e5-8743-d33fc6b2945a","useHintRect":false}},"annotations":[{"id":"2bc00520-0afe-40e5-8743-d33fc6b2945a","type":"rect","data":{"x1":112,"y1":601,"x2":148,"y2":640}}]}
|
Before Width: | Height: | Size: 297 KiB |
|
@ -1 +0,0 @@
|
|||
{"definitions":{"135ee57a-d30d-4ba8-83f0-9f1681a49ff7":{"name":"Shop.IdolPiece.藤田ことね_世界一可愛い私","displayName":"藤田ことね 世界一可愛い私 碎片","type":"template","annotationId":"135ee57a-d30d-4ba8-83f0-9f1681a49ff7","useHintRect":false}},"annotations":[{"id":"135ee57a-d30d-4ba8-83f0-9f1681a49ff7","type":"rect","data":{"x1":113,"y1":602,"x2":146,"y2":635}}]}
|
Before Width: | Height: | Size: 294 KiB |
|
@ -1 +0,0 @@
|
|||
{"definitions":{"d15959bf-d07b-4f07-948a-c0aeaf17756a":{"name":"Shop.IdolPiece.花海佑芽_TheRollingRiceball","displayName":"花海佑芽 The Rolling Riceball 碎片","type":"template","annotationId":"d15959bf-d07b-4f07-948a-c0aeaf17756a","useHintRect":false}},"annotations":[{"id":"d15959bf-d07b-4f07-948a-c0aeaf17756a","type":"rect","data":{"x1":103,"y1":605,"x2":137,"y2":635}}]}
|
Before Width: | Height: | Size: 291 KiB |
|
@ -1 +0,0 @@
|
|||
{"definitions":{"868b97a9-492e-4712-b47f-82b97495b019":{"name":"Shop.IdolPiece.月村手毬_LunaSayMaybe","displayName":"月村手毬 Luna say maybe 碎片","type":"template","annotationId":"868b97a9-492e-4712-b47f-82b97495b019","useHintRect":false}},"annotations":[{"id":"868b97a9-492e-4712-b47f-82b97495b019","type":"rect","data":{"x1":106,"y1":601,"x2":145,"y2":633}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"85c24a02-dac3-4e6c-978e-b11963e0e92d":{"name":"Produce.BoxProduceOngoing","displayName":"首页开始培育按钮(当前有培育)","type":"hint-box","annotationId":"85c24a02-dac3-4e6c-978e-b11963e0e92d","useHintRect":false},"4fe748c8-a535-4824-aefc-244e3ad34bd4":{"name":"Daily.BoxHomeAssignment","displayName":"首页工作按钮","type":"hint-box","annotationId":"4fe748c8-a535-4824-aefc-244e3ad34bd4","useHintRect":false},"469f7f21-067c-476c-9bfc-c3ec83b935ea":{"name":"Daily.BoxHomeAP","displayName":"首页体力","type":"hint-box","annotationId":"469f7f21-067c-476c-9bfc-c3ec83b935ea","useHintRect":false},"565df1a0-d494-41a4-a3bf-603857bd8dec":{"name":"Daily.BoxHomeJewel","displayName":"首页珠宝数量","type":"hint-box","annotationId":"565df1a0-d494-41a4-a3bf-603857bd8dec","useHintRect":false},"76c92bd0-496b-403e-b545-92eb1f2f941f":{"name":"Daily.BoxHomeActivelyFunds","displayName":"首页活动费按钮","type":"hint-box","annotationId":"76c92bd0-496b-403e-b545-92eb1f2f941f","useHintRect":false}},"annotations":[{"id":"85c24a02-dac3-4e6c-978e-b11963e0e92d","type":"rect","data":{"x1":179,"y1":937,"x2":551,"y2":1091}},{"id":"4fe748c8-a535-4824-aefc-244e3ad34bd4","type":"rect","data":{"x1":16,"y1":642,"x2":127,"y2":752}},{"id":"469f7f21-067c-476c-9bfc-c3ec83b935ea","type":"rect","data":{"x1":291,"y1":4,"x2":500,"y2":82}},{"id":"565df1a0-d494-41a4-a3bf-603857bd8dec","type":"rect","data":{"x1":500,"y1":7,"x2":703,"y2":82}},{"id":"76c92bd0-496b-403e-b545-92eb1f2f941f","type":"rect","data":{"x1":11,"y1":517,"x2":137,"y2":637}}]}
|
||||
{"definitions":{"85c24a02-dac3-4e6c-978e-b11963e0e92d":{"name":"Produce.BoxProduceOngoing","displayName":"首页开始培育按钮(当前有培育)","type":"hint-box","annotationId":"85c24a02-dac3-4e6c-978e-b11963e0e92d","useHintRect":false},"4fe748c8-a535-4824-aefc-244e3ad34bd4":{"name":"Daily.BoxHomeAssignment","displayName":"首页工作按钮","type":"hint-box","annotationId":"4fe748c8-a535-4824-aefc-244e3ad34bd4","useHintRect":false},"469f7f21-067c-476c-9bfc-c3ec83b935ea":{"name":"Daily.BoxHomeAP","displayName":"首页体力","type":"hint-box","annotationId":"469f7f21-067c-476c-9bfc-c3ec83b935ea","useHintRect":false},"565df1a0-d494-41a4-a3bf-603857bd8dec":{"name":"Daily.BoxHomeJewel","displayName":"首页珠宝数量","type":"hint-box","annotationId":"565df1a0-d494-41a4-a3bf-603857bd8dec","useHintRect":false},"76c92bd0-496b-403e-b545-92eb1f2f941f":{"name":"Daily.BoxHomeActivelyFunds","displayName":"首页活动费按钮","type":"hint-box","annotationId":"76c92bd0-496b-403e-b545-92eb1f2f941f","useHintRect":false}},"annotations":[{"id":"85c24a02-dac3-4e6c-978e-b11963e0e92d","type":"rect","data":{"x1":179,"y1":937,"x2":551,"y2":1091}},{"id":"4fe748c8-a535-4824-aefc-244e3ad34bd4","type":"rect","data":{"x1":33,"y1":650,"x2":107,"y2":746}},{"id":"469f7f21-067c-476c-9bfc-c3ec83b935ea","type":"rect","data":{"x1":291,"y1":4,"x2":500,"y2":82}},{"id":"565df1a0-d494-41a4-a3bf-603857bd8dec","type":"rect","data":{"x1":500,"y1":7,"x2":703,"y2":82}},{"id":"76c92bd0-496b-403e-b545-92eb1f2f941f","type":"rect","data":{"x1":29,"y1":530,"x2":109,"y2":633}}]}
|
After Width: | Height: | Size: 549 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"3942ae40-7f22-412c-aebe-4b064f68db9b":{"name":"Shop.IdolPiece.花海咲季_FightingMyWay","displayName":"","type":"template","annotationId":"3942ae40-7f22-412c-aebe-4b064f68db9b","useHintRect":false},"185f7838-92a7-460b-9340-f60858948ce9":{"name":"Shop.IdolPiece.月村手毬_LunaSayMaybe","displayName":"","type":"template","annotationId":"185f7838-92a7-460b-9340-f60858948ce9","useHintRect":false},"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c":{"name":"Shop.IdolPiece.藤田ことね_世界一可愛い私 ","displayName":"","type":"template","annotationId":"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c","useHintRect":false},"213016c2-c3a2-43d8-86a3-ab4d27666ced":{"name":"Shop.IdolPiece.花海佑芽_TheRollingRiceball","displayName":"","type":"template","annotationId":"213016c2-c3a2-43d8-86a3-ab4d27666ced","useHintRect":false},"cc60b509-2ed5-493d-bb9f-333c6d2a6006":{"name":"Shop.IdolPiece.葛城リーリヤ_白線","displayName":"","type":"template","annotationId":"cc60b509-2ed5-493d-bb9f-333c6d2a6006","useHintRect":false},"5031808b-5525-4118-92b4-317ec8bda985":{"name":"Shop.IdolPiece.紫云清夏_TameLieOneStep","displayName":"","type":"template","annotationId":"5031808b-5525-4118-92b4-317ec8bda985","useHintRect":false},"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5":{"name":"Shop.IdolPiece.篠泽广_光景","displayName":"","type":"template","annotationId":"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5","useHintRect":false},"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4":{"name":"Shop.IdolPiece.倉本千奈_WonderScale","displayName":"","type":"template","annotationId":"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4","useHintRect":false},"0d9ac648-eefa-4869-ac99-1b0c83649681":{"name":"Shop.IdolPiece.有村麻央_Fluorite","displayName":"","type":"template","annotationId":"0d9ac648-eefa-4869-ac99-1b0c83649681","useHintRect":false}},"annotations":[{"id":"3942ae40-7f22-412c-aebe-4b064f68db9b","type":"rect","data":{"x1":409,"y1":342,"x2":477,"y2":413}},{"id":"185f7838-92a7-460b-9340-f60858948ce9","type":"rect","data":{"x1":71,"y1":512,"x2":140,"y2":585}},{"id":"cb3d0ca7-8d14-408a-a2f5-2e25f7b86d6c","type":"rect","data":{"x1":410,"y1":513,"x2":475,"y2":581}},{"id":"213016c2-c3a2-43d8-86a3-ab4d27666ced","type":"rect","data":{"x1":585,"y1":858,"x2":640,"y2":913}},{"id":"cc60b509-2ed5-493d-bb9f-333c6d2a6006","type":"rect","data":{"x1":247,"y1":690,"x2":303,"y2":743}},{"id":"5031808b-5525-4118-92b4-317ec8bda985","type":"rect","data":{"x1":80,"y1":860,"x2":133,"y2":908}},{"id":"ae9fe233-9acc-4e96-ba8e-1fb1d9bc2ea5","type":"rect","data":{"x1":418,"y1":852,"x2":471,"y2":912}},{"id":"8f8b7b46-53bb-42ab-907a-4ea87eb09ab4","type":"rect","data":{"x1":589,"y1":679,"x2":639,"y2":742}},{"id":"0d9ac648-eefa-4869-ac99-1b0c83649681","type":"rect","data":{"x1":83,"y1":690,"x2":136,"y2":744}}]}
|
After Width: | Height: | Size: 539 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"921eefeb-730e-46fc-9924-d338fb286592":{"name":"Shop.IdolPiece.姬崎莉波_clumsy_trick","displayName":"","type":"template","annotationId":"921eefeb-730e-46fc-9924-d338fb286592","useHintRect":false}},"annotations":[{"id":"921eefeb-730e-46fc-9924-d338fb286592","type":"rect","data":{"x1":88,"y1":914,"x2":141,"y2":963}}]}
|
After Width: | Height: | Size: 506 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"5d36b880-7b3f-49b1-a018-7de59867d376":{"name":"Daily.TextShopItemPurchased","displayName":"交換しました","type":"template","annotationId":"5d36b880-7b3f-49b1-a018-7de59867d376","useHintRect":false}},"annotations":[{"id":"5d36b880-7b3f-49b1-a018-7de59867d376","type":"rect","data":{"x1":275,"y1":626,"x2":432,"y2":655}}]}
|
After Width: | Height: | Size: 522 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"24dc7158-036c-4a66-9280-e934f470be53":{"name":"Daily.TextShopItemSoldOut","displayName":"交換済みです","type":"template","annotationId":"24dc7158-036c-4a66-9280-e934f470be53","useHintRect":false}},"annotations":[{"id":"24dc7158-036c-4a66-9280-e934f470be53","type":"rect","data":{"x1":287,"y1":625,"x2":434,"y2":655}}]}
|
Before Width: | Height: | Size: 5.1 KiB |
Before Width: | Height: | Size: 6.5 KiB |
Before Width: | Height: | Size: 7.6 KiB |
Before Width: | Height: | Size: 405 B |
Before Width: | Height: | Size: 1.8 KiB |
Before Width: | Height: | Size: 973 B |
After Width: | Height: | Size: 885 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e":{"name":"Produce.LogoNia","displayName":"NIA LOGO (NEXT IDOL AUDITION)","type":"template","annotationId":"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e","useHintRect":false},"48a458a9-b6cf-4199-850e-78f679f4f337":{"name":"Produce.PointNiaToHajime","displayName":"NIA 左侧翻页箭头","type":"hint-point","annotationId":"48a458a9-b6cf-4199-850e-78f679f4f337","useHintRect":false}},"annotations":[{"id":"a0bd6a5f-784d-4f0a-9d66-10f4b80c8d3e","type":"rect","data":{"x1":195,"y1":424,"x2":540,"y2":466}},{"id":"48a458a9-b6cf-4199-850e-78f679f4f337","type":"point","data":{"x":34,"y":596}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe":{"name":"Produce.BoxModeButtons","displayName":"培育模式选择按钮","type":"hint-box","annotationId":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","useHintRect":false}},"annotations":[{"id":"12c5fd7c-0a6f-423c-bbfa-e88d19806bbe","type":"rect","data":{"x1":7,"y1":818,"x2":713,"y2":996}}]}
|
||||
{"definitions":{"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743":{"name":"Produce.ButtonHajime0Regular","displayName":"","type":"template","annotationId":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","useHintRect":false},"55f7db71-0a18-4b3d-b847-57959b8d2e32":{"name":"Produce.ButtonHajime0Pro","displayName":"","type":"template","annotationId":"55f7db71-0a18-4b3d-b847-57959b8d2e32","useHintRect":false},"0bf5e34e-afc6-4447-bbac-67026ce2ad26":{"name":"Produce.TitleIconProudce","displayName":"培育页面左上角标题图标","type":"template","annotationId":"0bf5e34e-afc6-4447-bbac-67026ce2ad26","useHintRect":false}},"annotations":[{"id":"6cd80be8-c9b3-4ba5-bf17-3ffc9b000743","type":"rect","data":{"x1":145,"y1":859,"x2":314,"y2":960}},{"id":"55f7db71-0a18-4b3d-b847-57959b8d2e32","type":"rect","data":{"x1":434,"y1":857,"x2":545,"y2":961}},{"id":"0bf5e34e-afc6-4447-bbac-67026ce2ad26","type":"rect","data":{"x1":12,"y1":33,"x2":63,"y2":82}}]}
|
After Width: | Height: | Size: 791 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"3b473fe6-e147-477f-b088-9b8fb042a4f6":{"name":"Produce.ButtonHajime1Regular","displayName":"","type":"template","annotationId":"3b473fe6-e147-477f-b088-9b8fb042a4f6","useHintRect":false},"2ededcf5-1d80-4e2a-9c83-2a31998331ce":{"name":"Produce.ButtonHajime1Pro","displayName":"","type":"template","annotationId":"2ededcf5-1d80-4e2a-9c83-2a31998331ce","useHintRect":false},"24e99232-9434-457f-a9a0-69dd7ecf675f":{"name":"Produce.ButtonHajime1Master","displayName":"","type":"template","annotationId":"24e99232-9434-457f-a9a0-69dd7ecf675f","useHintRect":false},"aca9e953-1955-46eb-920c-77b1750bcb34":{"name":"Produce.PointHajimeToNia","displayName":"Hajime 右侧翻页箭头","type":"hint-point","annotationId":"aca9e953-1955-46eb-920c-77b1750bcb34","useHintRect":false},"e6b45405-cd9f-4c6e-a9f1-6ec953747c65":{"name":"Produce.LogoHajime","displayName":"Hajime LOGO 定期公演","type":"template","annotationId":"e6b45405-cd9f-4c6e-a9f1-6ec953747c65","useHintRect":false}},"annotations":[{"id":"3b473fe6-e147-477f-b088-9b8fb042a4f6","type":"rect","data":{"x1":65,"y1":867,"x2":214,"y2":950}},{"id":"2ededcf5-1d80-4e2a-9c83-2a31998331ce","type":"rect","data":{"x1":307,"y1":869,"x2":421,"y2":952}},{"id":"24e99232-9434-457f-a9a0-69dd7ecf675f","type":"rect","data":{"x1":521,"y1":863,"x2":657,"y2":951}},{"id":"aca9e953-1955-46eb-920c-77b1750bcb34","type":"point","data":{"x":680,"y":592}},{"id":"e6b45405-cd9f-4c6e-a9f1-6ec953747c65","type":"rect","data":{"x1":274,"y1":169,"x2":443,"y2":212}}]}
|
|
@ -1 +1 @@
|
|||
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.TextStepIndicator1","displayName":"1. アイドル選択","type":"template","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":18,"y1":32,"x2":168,"y2":66}}]}
|
||||
{"definitions":{"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd":{"name":"Produce.ButtonPIdolOverview","displayName":"Pアイドルー覧 P偶像列表展示","type":"template","annotationId":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","useHintRect":false},"44ba8515-4a60-42c9-8878-b42e4e34ee15":{"name":"Produce.TextStepIndicator1","displayName":"1. アイドル選択","type":"template","annotationId":"44ba8515-4a60-42c9-8878-b42e4e34ee15","useHintRect":false},"34606d7d-52c8-4cd1-b7f4-b31032f1fb70":{"name":"Produce.BoxSelectedIdol","displayName":"当前选中的偶像","type":"hint-box","annotationId":"34606d7d-52c8-4cd1-b7f4-b31032f1fb70","useHintRect":false,"description":"偶像选择界面当前选中的偶像"}},"annotations":[{"id":"e88c9ad1-ec37-4fcd-b086-862e1e7ce8fd","type":"rect","data":{"x1":49,"y1":736,"x2":185,"y2":759},"tip":"Pアイドルー覧 P偶像列表展示"},{"id":"44ba8515-4a60-42c9-8878-b42e4e34ee15","type":"rect","data":{"x1":18,"y1":32,"x2":168,"y2":66}},{"id":"34606d7d-52c8-4cd1-b7f4-b31032f1fb70","type":"rect","data":{"x1":149,"y1":783,"x2":317,"y2":1006}}]}
|
After Width: | Height: | Size: 934 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"d3424d31-0502-4623-996e-f0194e5085ce":{"name":"Produce.EmptySupportCardSlot","displayName":"空支援卡槽位","type":"template","annotationId":"d3424d31-0502-4623-996e-f0194e5085ce","useHintRect":false}},"annotations":[{"id":"d3424d31-0502-4623-996e-f0194e5085ce","type":"rect","data":{"x1":481,"y1":844,"x2":692,"y2":962}}]}
|
After Width: | Height: | Size: 531 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"f5c16d2f-ebc5-4617-9b96-971696af7c52":{"name":"Produce.TextAutoSet","displayName":"おまかせ編成","type":"template","annotationId":"f5c16d2f-ebc5-4617-9b96-971696af7c52","useHintRect":false}},"annotations":[{"id":"f5c16d2f-ebc5-4617-9b96-971696af7c52","type":"rect","data":{"x1":56,"y1":919,"x2":257,"y2":957}}]}
|
After Width: | Height: | Size: 499 KiB |
|
@ -0,0 +1 @@
|
|||
{"definitions":{"74ec3510-583d-4a76-ac69-38480fbf1387":{"name":"Produce.TextRentAvailable","displayName":"レンタル可能","type":"template","annotationId":"74ec3510-583d-4a76-ac69-38480fbf1387","useHintRect":false}},"annotations":[{"id":"74ec3510-583d-4a76-ac69-38480fbf1387","type":"rect","data":{"x1":53,"y1":848,"x2":256,"y2":887}}]}
|
Before Width: | Height: | Size: 4.2 KiB |
|
@ -145,6 +145,18 @@ class KotoneBot:
|
|||
"""
|
||||
raise NotImplementedError('Implement `_create_device` before using Kotonebot.')
|
||||
|
||||
def _on_init_context(self) -> None:
|
||||
"""
|
||||
初始化 Context 的钩子方法。子类可以重写此方法来自定义初始化逻辑。
|
||||
默认实现调用 init_context 而不传入 target_screenshot_interval。
|
||||
"""
|
||||
d = self._on_create_device()
|
||||
init_context(
|
||||
config_path=self.config_path,
|
||||
config_type=self.config_type,
|
||||
target_device=d
|
||||
)
|
||||
|
||||
def _on_after_init_context(self):
|
||||
"""
|
||||
抽象方法,在 init_context() 被调用后立即执行。
|
||||
|
@ -155,8 +167,7 @@ class KotoneBot:
|
|||
"""
|
||||
按优先级顺序运行所有任务。
|
||||
"""
|
||||
d = self._on_create_device()
|
||||
init_context(config_path=self.config_path, config_type=self.config_type, target_device=d)
|
||||
self._on_init_context()
|
||||
self._on_after_init_context()
|
||||
vars.flow.clear_interrupt()
|
||||
|
||||
|
|
|
@ -25,8 +25,9 @@ from typing_extensions import deprecated
|
|||
import cv2
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot.client.device import Device
|
||||
from kotonebot.client.device import Device, AndroidDevice, WindowsDevice
|
||||
from kotonebot.backend.flow_controller import FlowController
|
||||
from kotonebot.util import Interval
|
||||
import kotonebot.backend.image as raw_image
|
||||
from kotonebot.backend.image import (
|
||||
TemplateMatchResult,
|
||||
|
@ -46,12 +47,10 @@ from kotonebot.backend.color import (
|
|||
from kotonebot.backend.ocr import (
|
||||
Ocr, OcrResult, OcrResultList, jp, en, StringMatchFunction
|
||||
)
|
||||
from kotonebot.client.factory import create_device
|
||||
from kotonebot.config.manager import load_config, save_config
|
||||
from kotonebot.config.base_config import UserConfig
|
||||
from kotonebot.backend.core import Image, HintBox
|
||||
from kotonebot.errors import KotonebotWarning
|
||||
from kotonebot.client.factory import DeviceImpl
|
||||
from kotonebot.errors import ContextNotInitializedError, KotonebotWarning
|
||||
from kotonebot.backend.preprocessor import PreprocessorProtocol
|
||||
from kotonebot.primitives import Rect
|
||||
|
||||
|
@ -286,11 +285,17 @@ class ContextOcr:
|
|||
self.context = context
|
||||
self.__engine = jp()
|
||||
|
||||
def raw(self, lang: OcrLanguage = 'jp') -> Ocr:
|
||||
def _get_engine(self, lang: OcrLanguage | None = None) -> Ocr:
|
||||
"""获取指定语言的OCR引擎,如果lang为None则使用默认引擎。"""
|
||||
return self.__engine if lang is None else self.raw(lang)
|
||||
|
||||
def raw(self, lang: OcrLanguage | None = None) -> Ocr:
|
||||
"""
|
||||
返回 `kotonebot.backend.ocr` 中的 Ocr 对象。\n
|
||||
Ocr 对象与此对象(ContextOcr)的区别是,此对象会自动截图,而 Ocr 对象需要手动传入图像参数。
|
||||
"""
|
||||
if lang is None:
|
||||
lang = 'jp'
|
||||
match lang:
|
||||
case 'jp':
|
||||
return jp()
|
||||
|
@ -302,9 +307,11 @@ class ContextOcr:
|
|||
def ocr(
|
||||
self,
|
||||
rect: Rect | None = None,
|
||||
lang: OcrLanguage | None = None,
|
||||
) -> OcrResultList:
|
||||
"""OCR 当前设备画面或指定图像。"""
|
||||
return self.__engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
|
||||
engine = self._get_engine(lang)
|
||||
return engine.ocr(ContextStackVars.ensure_current().screenshot, rect=rect)
|
||||
|
||||
def find(
|
||||
self,
|
||||
|
@ -312,9 +319,11 @@ class ContextOcr:
|
|||
*,
|
||||
hint: HintBox | None = None,
|
||||
rect: Rect | None = None,
|
||||
lang: OcrLanguage | None = None,
|
||||
) -> OcrResult | None:
|
||||
"""检查当前设备画面是否包含指定文本。"""
|
||||
ret = self.__engine.find(
|
||||
engine = self._get_engine(lang)
|
||||
ret = engine.find(
|
||||
ContextStackVars.ensure_current().screenshot,
|
||||
pattern,
|
||||
hint=hint,
|
||||
|
@ -329,9 +338,10 @@ class ContextOcr:
|
|||
*,
|
||||
hint: HintBox | None = None,
|
||||
rect: Rect | None = None,
|
||||
|
||||
lang: OcrLanguage | None = None,
|
||||
) -> list[OcrResult | None]:
|
||||
return self.__engine.find_all(
|
||||
engine = self._get_engine(lang)
|
||||
return engine.find_all(
|
||||
ContextStackVars.ensure_current().screenshot,
|
||||
list(patterns),
|
||||
hint=hint,
|
||||
|
@ -344,6 +354,7 @@ class ContextOcr:
|
|||
*,
|
||||
rect: Rect | None = None,
|
||||
hint: HintBox | None = None,
|
||||
lang: OcrLanguage | None = None,
|
||||
) -> OcrResult:
|
||||
|
||||
"""
|
||||
|
@ -351,7 +362,8 @@ class ContextOcr:
|
|||
|
||||
与 `find()` 的区别在于,`expect()` 未找到时会抛出异常。
|
||||
"""
|
||||
ret = self.__engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
|
||||
engine = self._get_engine(lang)
|
||||
ret = engine.expect(ContextStackVars.ensure_current().screenshot, pattern, rect=rect, hint=hint)
|
||||
self.context.device.last_find = ret.original_rect if ret else None
|
||||
return ret
|
||||
|
||||
|
@ -707,20 +719,33 @@ class Forwarded:
|
|||
if name.startswith('_FORWARD_'):
|
||||
return object.__getattribute__(self, name)
|
||||
if self._FORWARD_getter is None:
|
||||
raise ValueError(f"Forwarded object {self._FORWARD_name} called before initialization.")
|
||||
raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
|
||||
return getattr(self._FORWARD_getter(), name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
if name.startswith('_FORWARD_'):
|
||||
return object.__setattr__(self, name, value)
|
||||
if self._FORWARD_getter is None:
|
||||
raise ValueError(f"Forwarded object {self._FORWARD_name} called before initialization.")
|
||||
raise ContextNotInitializedError(f"Forwarded object {self._FORWARD_name} called before initialization.")
|
||||
setattr(self._FORWARD_getter(), name, value)
|
||||
|
||||
# HACK: 这应该要有个更好的实现方式
|
||||
class ContextDevice(Device):
|
||||
def __init__(self, device: Device):
|
||||
|
||||
T_Device = TypeVar('T_Device', bound=Device)
|
||||
class ContextDevice(Generic[T_Device], Device):
|
||||
def __init__(self, device: T_Device, target_screenshot_interval: float | None = None):
|
||||
"""
|
||||
:param device: 目标设备。
|
||||
:param target_screenshot_interval: 见 `ContextDevice.target_screenshot_interval`。
|
||||
"""
|
||||
self._device = device
|
||||
self.target_screenshot_interval: float | None = target_screenshot_interval
|
||||
"""
|
||||
目标截图间隔,可用于限制截图速度。若两次截图实际间隔小于该值,则会自动等待。
|
||||
为 None 时不限制截图速度。
|
||||
"""
|
||||
self._screenshot_interval: Interval | None = None
|
||||
if self.target_screenshot_interval is not None:
|
||||
self._screenshot_interval = Interval(self.target_screenshot_interval)
|
||||
|
||||
def screenshot(self, *, force: bool = False):
|
||||
"""
|
||||
|
@ -735,6 +760,9 @@ class ContextDevice(Device):
|
|||
img = current._inherit_screenshot
|
||||
current._inherit_screenshot = None
|
||||
else:
|
||||
if self._screenshot_interval is not None:
|
||||
self._screenshot_interval.wait()
|
||||
|
||||
if next_wait == 'screenshot':
|
||||
delta = time.time() - last_screenshot_time
|
||||
if delta < next_wait_time:
|
||||
|
@ -746,26 +774,43 @@ class ContextDevice(Device):
|
|||
current._screenshot = img
|
||||
return img
|
||||
|
||||
def __getattribute__(self, name: str) -> Any:
|
||||
if name in ['_device', 'screenshot']:
|
||||
def __getattribute__(self, name: str):
|
||||
if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
|
||||
return object.__getattribute__(self, name)
|
||||
else:
|
||||
return getattr(self._device, name)
|
||||
|
||||
def __setattr__(self, name: str, value: Any):
|
||||
if name in ['_device', 'screenshot']:
|
||||
if name in ['_device', 'screenshot', 'of_android', 'of_windows']:
|
||||
return object.__setattr__(self, name, value)
|
||||
else:
|
||||
return setattr(self._device, name, value)
|
||||
|
||||
def of_android(self) -> 'ContextDevice | AndroidDevice':
|
||||
"""
|
||||
确保此 ContextDevice 底层为 Android 平台。
|
||||
同时通过返回的对象可以调用 Android 平台特有的方法。
|
||||
"""
|
||||
if not isinstance(self._device, AndroidDevice):
|
||||
raise ValueError("Device is not AndroidDevice")
|
||||
return self
|
||||
|
||||
def of_windows(self) -> 'ContextDevice | WindowsDevice':
|
||||
"""
|
||||
确保此 ContextDevice 底层为 Windows 平台。
|
||||
同时通过返回的对象可以调用 Windows 平台特有的方法。
|
||||
"""
|
||||
if not isinstance(self._device, WindowsDevice):
|
||||
raise ValueError("Device is not WindowsDevice")
|
||||
return self
|
||||
|
||||
class Context(Generic[T]):
|
||||
def __init__(
|
||||
self,
|
||||
config_path: str,
|
||||
config_type: Type[T],
|
||||
screenshot_impl: Optional[DeviceImpl] = None,
|
||||
device: Optional[Device] = None
|
||||
device: Device,
|
||||
target_screenshot_interval: float | None = None
|
||||
):
|
||||
self.__ocr = ContextOcr(self)
|
||||
self.__image = ContextImage(self)
|
||||
|
@ -773,14 +818,7 @@ class Context(Generic[T]):
|
|||
self.__vars = ContextGlobalVars()
|
||||
self.__debug = ContextDebug(self)
|
||||
self.__config = ContextConfig[T](self, config_path, config_type)
|
||||
|
||||
ip = self.config.current.backend.adb_ip
|
||||
port = self.config.current.backend.adb_port
|
||||
# TODO: 处理链接失败情况
|
||||
if screenshot_impl is None:
|
||||
screenshot_impl = self.config.current.backend.screenshot_impl
|
||||
logger.info(f'Using "{screenshot_impl}" as screenshot implementation')
|
||||
self.__device = ContextDevice(device or create_device(f'{ip}:{port}', screenshot_impl))
|
||||
self.__device = ContextDevice(device, target_screenshot_interval)
|
||||
|
||||
def inject(
|
||||
self,
|
||||
|
@ -891,8 +929,8 @@ def init_context(
|
|||
config_path: str = 'config.json',
|
||||
config_type: Type[T] = dict[str, Any],
|
||||
force: bool = False,
|
||||
screenshot_impl: Optional[DeviceImpl] = None,
|
||||
target_device: Device | None = None,
|
||||
target_device: Device,
|
||||
target_screenshot_interval: float | None = None,
|
||||
):
|
||||
"""
|
||||
初始化 Context 模块。
|
||||
|
@ -903,9 +941,8 @@ def init_context(
|
|||
默认为 `dict[str, Any]`,即普通的 JSON 数据,不包含任何类型信息。
|
||||
:param force: 是否强制重新初始化。
|
||||
若为 `True`,则忽略已存在的 Context 实例,并重新创建一个新的实例。
|
||||
:param screenshot_impl: 截图实现。
|
||||
若为 `None`,则使用默认配置文件中指定的截图实现。
|
||||
:param target_device: 目标设备
|
||||
:param target_screenshot_interval: 见 `ContextDevice.target_screenshot_interval`。
|
||||
"""
|
||||
global _c, device, ocr, image, color, vars, debug, config
|
||||
if _c is not None and not force:
|
||||
|
@ -913,8 +950,8 @@ def init_context(
|
|||
_c = Context(
|
||||
config_path=config_path,
|
||||
config_type=config_type,
|
||||
screenshot_impl=screenshot_impl,
|
||||
device=target_device
|
||||
device=target_device,
|
||||
target_screenshot_interval=target_screenshot_interval,
|
||||
)
|
||||
device._FORWARD_getter = lambda: _c.device # type: ignore
|
||||
ocr._FORWARD_getter = lambda: _c.ocr # type: ignore
|
||||
|
@ -937,7 +974,7 @@ def inject_context(
|
|||
):
|
||||
global _c
|
||||
if _c is None:
|
||||
raise RuntimeError('Context not initialized')
|
||||
raise ContextNotInitializedError('Context not initialized')
|
||||
_c.inject(device=device, ocr=ocr, image=image, color=color, vars=vars, debug=debug, config=config)
|
||||
|
||||
class ManualContextManager:
|
||||
|
|
|
@ -216,8 +216,6 @@ def wait_message_all_done():
|
|||
threading.Thread(target=_wait, daemon=True).start()
|
||||
|
||||
if __name__ == "__main__":
|
||||
from kotonebot.backend.context import init_context
|
||||
init_context()
|
||||
debug_vars.debug.hide_server_log = False
|
||||
process = subprocess.Popen(["pylsp", "--port", "5479", "--ws"])
|
||||
print("LSP started. PID=", process.pid)
|
||||
|
|
|
@ -328,20 +328,4 @@ class SimpleDispatcher:
|
|||
self.result = self.timeout_result
|
||||
break
|
||||
device.screenshot()
|
||||
return self.result
|
||||
|
||||
if __name__ == '__main__':
|
||||
from .context.task_action import action
|
||||
from .context import init_context
|
||||
init_context()
|
||||
@action('inner', dispatcher=True)
|
||||
def inner(ctx: DispatcherContext):
|
||||
print('inner')
|
||||
ctx.finish()
|
||||
|
||||
@action('test', dispatcher=True)
|
||||
def test(ctx: DispatcherContext):
|
||||
print('test')
|
||||
inner()
|
||||
ctx.finish()
|
||||
test()
|
||||
return self.result
|
|
@ -0,0 +1,277 @@
|
|||
import time
|
||||
from functools import lru_cache, partial
|
||||
from typing import Callable, Any, overload, Literal, Generic, TypeVar, cast, get_args, get_origin
|
||||
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from kotonebot.util import Interval
|
||||
from kotonebot import device, image, ocr
|
||||
from kotonebot.backend.core import Image
|
||||
from kotonebot.backend.ocr import TextComparator
|
||||
from kotonebot.client.protocol import ClickableObjectProtocol
|
||||
|
||||
|
||||
class LoopAction:
|
||||
def __init__(self, loop: 'Loop', func: Callable[[], ClickableObjectProtocol | None]):
|
||||
self.loop = loop
|
||||
self.func = func
|
||||
self.result: ClickableObjectProtocol | None = None
|
||||
|
||||
@property
|
||||
def found(self):
|
||||
"""
|
||||
是否找到结果。若父 Loop 未在运行中,则返回 False。
|
||||
"""
|
||||
if not self.loop.running:
|
||||
return False
|
||||
return bool(self.result)
|
||||
|
||||
def __bool__(self):
|
||||
return self.found
|
||||
|
||||
def reset(self):
|
||||
"""
|
||||
重置 LoopAction,以复用此对象。
|
||||
"""
|
||||
self.result = None
|
||||
|
||||
def do(self):
|
||||
"""
|
||||
执行 LoopAction。
|
||||
:return: 执行结果。
|
||||
"""
|
||||
if not self.loop.running:
|
||||
return
|
||||
if self.loop.found_anything:
|
||||
# 本轮循环已执行任意操作,因此不需要再继续检测
|
||||
return
|
||||
self.result = self.func()
|
||||
if self.result:
|
||||
self.loop.found_anything = True
|
||||
|
||||
def click(self, *, at: tuple[int, int] | None = None):
|
||||
"""
|
||||
点击寻找结果。若结果为空,会跳过执行。
|
||||
|
||||
:return:
|
||||
"""
|
||||
if self.result:
|
||||
if at is not None:
|
||||
device.click(*at)
|
||||
else:
|
||||
device.click(self.result)
|
||||
|
||||
def call(self, func: Callable[[ClickableObjectProtocol], Any]):
|
||||
pass
|
||||
|
||||
|
||||
class Loop:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
timeout: float = 300,
|
||||
interval: float = 0.3,
|
||||
auto_screenshot: bool = True
|
||||
):
|
||||
self.running = True
|
||||
self.found_anything = False
|
||||
self.auto_screenshot = auto_screenshot
|
||||
"""
|
||||
是否在每次循环开始时(Loop.tick() 被调用时)截图。
|
||||
"""
|
||||
self.__last_loop: float = -1
|
||||
self.__interval = Interval(interval)
|
||||
self.screenshot: MatLike | None = None
|
||||
"""上次截图时的图像数据。"""
|
||||
|
||||
def __iter__(self):
|
||||
self.__interval.reset()
|
||||
return self
|
||||
|
||||
def __next__(self):
|
||||
if not self.running:
|
||||
raise StopIteration
|
||||
self.found_anything = False
|
||||
self.__last_loop = time.time()
|
||||
return self.tick()
|
||||
|
||||
def tick(self):
|
||||
self.__interval.wait()
|
||||
if self.auto_screenshot:
|
||||
self.screenshot = device.screenshot()
|
||||
self.__last_loop = time.time()
|
||||
self.found_anything = False
|
||||
return self
|
||||
|
||||
def exit(self):
|
||||
"""
|
||||
结束循环。
|
||||
"""
|
||||
self.running = False
|
||||
|
||||
@overload
|
||||
def when(self, condition: Image) -> LoopAction:
|
||||
...
|
||||
|
||||
@overload
|
||||
def when(self, condition: TextComparator) -> LoopAction:
|
||||
...
|
||||
|
||||
def when(self, condition: Any):
|
||||
"""
|
||||
判断某个条件是否成立。
|
||||
|
||||
:param condition:
|
||||
:return:
|
||||
"""
|
||||
if isinstance(condition, Image):
|
||||
func = partial(image.find, condition)
|
||||
elif isinstance(condition, TextComparator):
|
||||
func = partial(ocr.find, condition)
|
||||
else:
|
||||
raise ValueError('Invalid condition type.')
|
||||
la = LoopAction(self, func)
|
||||
la.reset()
|
||||
la.do()
|
||||
return la
|
||||
|
||||
def until(self, condition: Any):
|
||||
"""
|
||||
当满足指定条件时,结束循环。
|
||||
|
||||
等价于 ``loop.when(...).call(lambda _: loop.exit())``
|
||||
"""
|
||||
return self.when(condition).call(lambda _: self.exit())
|
||||
|
||||
def click_if(self, condition: Any, *, at: tuple[int, int] | None = None):
|
||||
"""
|
||||
检测指定对象是否出现,若出现,点击该对象或指定位置。
|
||||
|
||||
``click_if()`` 等价于 ``loop.when(...).click(...)``。
|
||||
|
||||
:param condition: 检测目标。
|
||||
:param at: 点击位置。若为 None,表示点击找到的目标。
|
||||
"""
|
||||
return self.when(condition).click(at=at)
|
||||
|
||||
StateType = TypeVar('StateType')
|
||||
class StatedLoop(Loop, Generic[StateType]):
|
||||
def __init__(
|
||||
self,
|
||||
states: list[Any] | None = None,
|
||||
initial_state: StateType | None = None,
|
||||
*,
|
||||
timeout: float = 300,
|
||||
interval: float = 0.3,
|
||||
auto_screenshot: bool = True
|
||||
):
|
||||
self.__tmp_states = states
|
||||
self.__tmp_initial_state = initial_state
|
||||
self.state: StateType
|
||||
super().__init__(timeout=timeout, interval=interval, auto_screenshot=auto_screenshot)
|
||||
|
||||
def __iter__(self):
|
||||
# __retrive_state_values() 只能在非 __init__ 中调用
|
||||
self.__retrive_state_values()
|
||||
return super().__iter__()
|
||||
|
||||
def __retrive_state_values(self):
|
||||
# HACK: __orig_class__ 是 undocumented 属性
|
||||
if not hasattr(self, '__orig_class__'):
|
||||
# 如果 Foo 不是以参数化泛型的方式实例化的,可能没有 __orig_class__
|
||||
if self.state is None:
|
||||
raise ValueError('Either specify `states` or use StatedLoop[Literal[...]] syntax.')
|
||||
else:
|
||||
generic_type_args = get_args(self.__orig_class__) # type: ignore
|
||||
if len(generic_type_args) != 1:
|
||||
raise ValueError('StatedLoop must have exactly one generic type argument.')
|
||||
state_values = get_args(generic_type_args[0])
|
||||
if not state_values:
|
||||
raise ValueError('StatedLoop must have at least one state value.')
|
||||
self.states = cast(tuple[StateType, ...], state_values)
|
||||
self.state = self.__tmp_initial_state or self.states[0]
|
||||
return state_values
|
||||
|
||||
|
||||
def StatedLoop2(states: StateType) -> StatedLoop[StateType]:
|
||||
state_values = get_args(states)
|
||||
return cast(StatedLoop[StateType], Loop())
|
||||
|
||||
if __name__ == '__main__':
|
||||
from kotonebot.kaa.tasks import R
|
||||
from kotonebot.backend.ocr import contains
|
||||
from kotonebot.backend.context import manual_context, init_context
|
||||
|
||||
# T = TypeVar('T')
|
||||
# class Foo(Generic[T]):
|
||||
# def get_literal_params(self) -> list | None:
|
||||
# """
|
||||
# 尝试获取泛型参数 T (如果它是 Literal 类型) 的参数列表。
|
||||
# """
|
||||
# # self.__orig_class__ 会是 Foo 的具体参数化类型,
|
||||
# # 例如 Foo[Literal['p0', 'p1', 'p2', 'p3', 'ap']]
|
||||
# if not hasattr(self, '__orig_class__'):
|
||||
# # 如果 Foo 不是以参数化泛型的方式实例化的,可能没有 __orig_class__
|
||||
# return None
|
||||
#
|
||||
# # generic_type_args 是传递给 Foo 的类型参数元组
|
||||
# # 例如 (Literal['p0', 'p1', 'p2', 'p3', 'ap'],)
|
||||
# generic_type_args = get_args(self.__orig_class__)
|
||||
#
|
||||
# if not generic_type_args:
|
||||
# # Foo 没有类型参数
|
||||
# return None
|
||||
#
|
||||
# # T_type 是 Foo 的第一个类型参数
|
||||
# # 例如 Literal['p0', 'p1', 'p2', 'p3', 'ap']
|
||||
# t_type = generic_type_args[0]
|
||||
#
|
||||
# # 检查 T_type 是否是 Literal 类型
|
||||
# if get_origin(t_type) is Literal:
|
||||
# # literal_args 是 Literal 类型的参数元组
|
||||
# # 例如 ('p0', 'p1', 'p2', 'p3', 'ap')
|
||||
# literal_args = get_args(t_type)
|
||||
# return list(literal_args)
|
||||
# else:
|
||||
# # T 不是 Literal 类型
|
||||
# return None
|
||||
# f = Foo[Literal['p0', 'p1', 'p2', 'p3', 'ap']]()
|
||||
# values = f.get_literal_params()
|
||||
# 1
|
||||
|
||||
from typing_extensions import reveal_type
|
||||
slp = StatedLoop[Literal['p0', 'p1', 'p2', 'p3', 'ap']]()
|
||||
for l in slp:
|
||||
reveal_type(l.states)
|
||||
|
||||
# init_context()
|
||||
# manual_context().begin()
|
||||
# for l in Loop():
|
||||
# l.when(R.Produce.ButtonUse).click()
|
||||
# l.when(R.Produce.ButtonRefillAP).click()
|
||||
# l.when(contains("123")).click()
|
||||
# l.click_if(contains("!23"), at=(1, 2))
|
||||
|
||||
# State = Literal['p0', 'p1', 'p2', 'p3', 'ap']
|
||||
# for sl in StatedLoop[State]():
|
||||
# match sl.state:
|
||||
# case 'p0':
|
||||
# sl.click_if(R.Produce.ButtonProduce)
|
||||
# sl.click_if(contains('master'))
|
||||
# sl.when(R.Produce.ButtonPIdolOverview).goto('p1')
|
||||
# # AP 不足
|
||||
# sl.when(R.Produce.TextAPInsufficient).goto('ap')
|
||||
# case 'ap':
|
||||
# pass
|
||||
# # p1: 选择偶像
|
||||
# case 'p1':
|
||||
# sl.call(lambda _: select_idol(idol_skin_id), once=True)
|
||||
# sl.when(R.Produce.TextAnotherIdolAvailableDialog).call(dialog.no)
|
||||
# sl.click_if(R.Common.ButtonNextNoIcon)
|
||||
# sl.until(R.Produce.TextStepIndicator2).goto('p2')
|
||||
# case 'p2':
|
||||
# sl.when(contains("123")).click()
|
||||
# case 'p3':
|
||||
# sl.click_if(contains("!23"), at=(1, 2))
|
||||
# case _:
|
||||
# assert_never(sl.state)
|
|
@ -1,8 +1,10 @@
|
|||
from .device import Device
|
||||
from .factory import create_device, DeviceImpl
|
||||
from .registration import DeviceImpl
|
||||
|
||||
# 确保所有实现都被注册
|
||||
from . import implements # noqa: F401
|
||||
|
||||
__all__ = [
|
||||
'Device',
|
||||
'create_device',
|
||||
'DeviceImpl',
|
||||
]
|
|
@ -9,9 +9,10 @@ from cv2.typing import MatLike
|
|||
from adbutils._device import AdbDevice as AdbUtilsDevice
|
||||
|
||||
from ..backend.debug import result
|
||||
from ..errors import UnscalableResolutionError
|
||||
from kotonebot.backend.core import HintBox
|
||||
from kotonebot.primitives import Rect, Point, is_point
|
||||
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable
|
||||
from .protocol import ClickableObjectProtocol, Commandable, Touchable, Screenshotable, AndroidCommandable, WindowsCommandable
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
@ -28,31 +29,6 @@ class HookContextManager:
|
|||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.device.screenshot_hook_after = self.old_func
|
||||
|
||||
|
||||
class PinContextManager:
|
||||
def __init__(self, device: 'Device'):
|
||||
self.device = device
|
||||
self.old_hook = device.screenshot_hook_before
|
||||
self.memo = None
|
||||
|
||||
def __hook(self) -> MatLike:
|
||||
if self.memo is None:
|
||||
self.memo = self.device.screenshot_raw()
|
||||
return self.memo
|
||||
|
||||
def __enter__(self):
|
||||
self.device.screenshot_hook_before = self.__hook
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_value, traceback):
|
||||
self.device.screenshot_hook_before = self.old_hook
|
||||
|
||||
def update(self) -> None:
|
||||
"""
|
||||
更新记住的截图
|
||||
"""
|
||||
self.memo = self.device.screenshot_raw()
|
||||
|
||||
class Device:
|
||||
def __init__(self, platform: str = 'unknown') -> None:
|
||||
self.screenshot_hook_after: Callable[[MatLike], MatLike] | None = None
|
||||
|
@ -71,7 +47,6 @@ class Device:
|
|||
横屏时为 'landscape',竖屏时为 'portrait'。
|
||||
"""
|
||||
|
||||
self._command: Commandable
|
||||
self._touch: Touchable
|
||||
self._screenshot: Screenshotable
|
||||
|
||||
|
@ -79,6 +54,31 @@ class Device:
|
|||
"""
|
||||
设备平台名称。
|
||||
"""
|
||||
self.target_resolution: tuple[int, int] | None = None
|
||||
"""
|
||||
目标分辨率。
|
||||
|
||||
若设置,则在截图、点击、滑动等时会缩放到目标分辨率。
|
||||
仅支持等比例缩放,若无法等比例缩放,则会抛出异常 `UnscalableResolutionError`。
|
||||
"""
|
||||
self.match_rotation: bool = True
|
||||
"""
|
||||
分辨率缩放是否自动匹配旋转。
|
||||
|
||||
当目标与真实分辨率的宽高比不一致时,是否允许通过旋转(交换宽高)后再进行匹配。
|
||||
为 True 则忽略方向差异,只要宽高比一致就视为可缩放;False 则必须匹配旋转。
|
||||
|
||||
例如,当目标分辨率为 1920x1080,而真实分辨率为 1080x1920 时,
|
||||
``match_rotation`` 为 True 则认为可以缩放,为 False 则会抛出异常。
|
||||
"""
|
||||
self.aspect_ratio_tolerance: float = 0.1
|
||||
"""
|
||||
宽高比容差阈值。
|
||||
|
||||
判断两分辨率宽高比差异是否接受的阈值。
|
||||
该值越小,对比例一致性的要求越严格。
|
||||
默认为 0.1(即 10% 容差)。
|
||||
"""
|
||||
|
||||
@property
|
||||
def adb(self) -> AdbUtilsDevice:
|
||||
|
@ -90,12 +90,50 @@ class Device:
|
|||
def adb(self, value: AdbUtilsDevice) -> None:
|
||||
self._adb = value
|
||||
|
||||
def launch_app(self, package_name: str) -> None:
|
||||
"""
|
||||
根据包名启动 app
|
||||
"""
|
||||
self._command.launch_app(package_name)
|
||||
|
||||
def _scale_pos_real_to_target(self, real_x: int, real_y: int) -> tuple[int, int]:
|
||||
"""将真实屏幕坐标缩放到目标逻辑坐标"""
|
||||
if self.target_resolution is None:
|
||||
return real_x, real_y
|
||||
|
||||
real_w, real_h = self.screen_size
|
||||
target_w, target_h = self.target_resolution
|
||||
|
||||
# 校验分辨率是否可缩放并获取调整后的目标分辨率
|
||||
adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
|
||||
|
||||
scale_w = adjusted_target_w / real_w
|
||||
scale_h = adjusted_target_h / real_h
|
||||
|
||||
return int(real_x * scale_w), int(real_y * scale_h)
|
||||
|
||||
def _scale_pos_target_to_real(self, target_x: int, target_y: int) -> tuple[int, int]:
|
||||
"""将目标逻辑坐标缩放到真实屏幕坐标"""
|
||||
if self.target_resolution is None:
|
||||
return target_x, target_y # 输入坐标已是真实坐标
|
||||
|
||||
real_w, real_h = self.screen_size
|
||||
target_w, target_h = self.target_resolution
|
||||
|
||||
# 校验分辨率是否可缩放并获取调整后的目标分辨率
|
||||
adjusted_target_w, adjusted_target_h = self.__assert_scalable((real_w, real_h), (target_w, target_h))
|
||||
|
||||
scale_to_real_w = real_w / adjusted_target_w
|
||||
scale_to_real_h = real_h / adjusted_target_h
|
||||
|
||||
return int(target_x * scale_to_real_w), int(target_y * scale_to_real_h)
|
||||
|
||||
def __scale_image (self, img: MatLike) -> MatLike:
|
||||
if self.target_resolution is None:
|
||||
return img
|
||||
|
||||
target_w, target_h = self.target_resolution
|
||||
h, w = img.shape[:2]
|
||||
|
||||
# 校验分辨率是否可缩放并获取调整后的目标分辨率
|
||||
adjusted_target = self.__assert_scalable((w, h), (target_w, target_h))
|
||||
|
||||
return cv2.resize(img, adjusted_target)
|
||||
|
||||
@overload
|
||||
def click(self) -> None:
|
||||
"""
|
||||
|
@ -168,7 +206,12 @@ class Device:
|
|||
logger.debug(f"Executing click hook before: ({x}, {y})")
|
||||
x, y = hook(x, y)
|
||||
logger.debug(f"Click hook before result: ({x}, {y})")
|
||||
logger.debug(f"Click: {x}, {y}")
|
||||
if self.target_resolution is not None:
|
||||
# 输入坐标为逻辑坐标,需要转换为真实坐标
|
||||
real_x, real_y = self._scale_pos_target_to_real(x, y)
|
||||
else:
|
||||
real_x, real_y = x, y
|
||||
logger.debug(f"Click: {x}, {y}%s", f"(Physical: {real_x}, {real_y})" if self.target_resolution is not None else "")
|
||||
from ..backend.context import ContextStackVars
|
||||
if ContextStackVars.current() is not None:
|
||||
image = ContextStackVars.ensure_current()._screenshot
|
||||
|
@ -176,9 +219,11 @@ class Device:
|
|||
image = np.array([])
|
||||
if image is not None and image.size > 0:
|
||||
cv2.circle(image, (x, y), 10, (0, 0, 255), -1)
|
||||
message = f"point: ({x}, {y})"
|
||||
message = f"Point: ({x}, {y})"
|
||||
if self.target_resolution is not None:
|
||||
message += f" physical: ({real_x}, {real_y})"
|
||||
result("device.click", image, message)
|
||||
self._touch.click(x, y)
|
||||
self._touch.click(real_x, real_y)
|
||||
|
||||
def __click_point_tuple(self, point: Point) -> None:
|
||||
self.click(point[0], point[1])
|
||||
|
@ -239,6 +284,10 @@ class Device:
|
|||
"""
|
||||
滑动屏幕
|
||||
"""
|
||||
if self.target_resolution is not None:
|
||||
# 输入坐标为逻辑坐标,需要转换为真实坐标
|
||||
x1, y1 = self._scale_pos_target_to_real(x1, y1)
|
||||
x2, y2 = self._scale_pos_target_to_real(x2, y2)
|
||||
self._touch.swipe(x1, y1, x2, y2, duration)
|
||||
|
||||
def swipe_scaled(self, x1: float, y1: float, x2: float, y2: float, duration: float|None = None) -> None:
|
||||
|
@ -265,6 +314,7 @@ class Device:
|
|||
logger.debug("screenshot hook before returned image")
|
||||
return img
|
||||
img = self.screenshot_raw()
|
||||
img = self.__scale_image(img)
|
||||
if self.screenshot_hook_after is not None:
|
||||
img = self.screenshot_hook_after(img)
|
||||
return img
|
||||
|
@ -281,16 +331,6 @@ class Device:
|
|||
"""
|
||||
return HookContextManager(self, func)
|
||||
|
||||
@deprecated('改用 @task/@action 装饰器中的 screenshot_mode 参数')
|
||||
def pinned(self) -> PinContextManager:
|
||||
"""
|
||||
记住下次截图结果,并将截图调整为手动挡。
|
||||
之后截图都会返回记住的数据,节省重复截图时间。
|
||||
|
||||
调用返回对象中的 PinContextManager.update() 可以立刻更新记住的截图。
|
||||
"""
|
||||
return PinContextManager(self)
|
||||
|
||||
@property
|
||||
def screen_size(self) -> tuple[int, int]:
|
||||
"""
|
||||
|
@ -303,19 +343,15 @@ class Device:
|
|||
`self.orientation` 属性默认为竖屏。如果需要自动检测,
|
||||
调用 `self.detect_orientation()` 方法。
|
||||
如果已知方向,也可以直接设置 `self.orientation` 属性。
|
||||
|
||||
即使设置了 `self.target_resolution`,返回的分辨率仍然是真实分辨率。
|
||||
"""
|
||||
return self._screenshot.screen_size
|
||||
|
||||
def current_package(self) -> str | None:
|
||||
"""
|
||||
获取前台 APP 的包名。
|
||||
|
||||
:return: 前台 APP 的包名。如果获取失败,则返回 None。
|
||||
:exception: 如果设备不支持此功能,则抛出 NotImplementedError。
|
||||
"""
|
||||
ret = self._command.current_package()
|
||||
logger.debug("current_package: %s", ret)
|
||||
return ret
|
||||
size = self._screenshot.screen_size
|
||||
if self.orientation == 'landscape':
|
||||
size = sorted(size, reverse=True)
|
||||
else:
|
||||
size = sorted(size, reverse=False)
|
||||
return size[0], size[1]
|
||||
|
||||
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
|
||||
"""
|
||||
|
@ -325,15 +361,97 @@ class Device:
|
|||
"""
|
||||
return self._screenshot.detect_orientation()
|
||||
|
||||
def __aspect_ratio_compatible(self, src_size: tuple[int, int], tgt_size: tuple[int, int]) -> bool:
|
||||
"""
|
||||
判断两个尺寸在宽高比意义上是否兼容
|
||||
|
||||
若 ``self.match_rotation`` 为 True,忽略方向(长边/短边)进行比较。
|
||||
判断标准由 ``self.aspect_ratio_tolerance`` 决定(默认 0.1)。
|
||||
"""
|
||||
src_w, src_h = src_size
|
||||
tgt_w, tgt_h = tgt_size
|
||||
|
||||
# 尺寸必须为正
|
||||
if src_w <= 0 or src_h <= 0:
|
||||
raise ValueError(f"Source size dimensions must be positive for scaling: {src_size}")
|
||||
if tgt_w <= 0 or tgt_h <= 0:
|
||||
raise ValueError(f"Target size dimensions must be positive for scaling: {tgt_size}")
|
||||
|
||||
tolerant = self.aspect_ratio_tolerance
|
||||
|
||||
# 直接比较宽高比
|
||||
if abs((tgt_w / src_w) - (tgt_h / src_h)) <= tolerant:
|
||||
return True
|
||||
|
||||
# 尝试忽略方向差异
|
||||
if self.match_rotation:
|
||||
ratio_src = max(src_w, src_h) / min(src_w, src_h)
|
||||
ratio_tgt = max(tgt_w, tgt_h) / min(tgt_w, tgt_h)
|
||||
return abs(ratio_src - ratio_tgt) <= tolerant
|
||||
|
||||
return False
|
||||
|
||||
def __assert_scalable(self, source: tuple[int, int], target: tuple[int, int]) -> tuple[int, int]:
|
||||
"""
|
||||
校验分辨率是否可缩放,并返回调整后的目标分辨率。
|
||||
|
||||
当 match_rotation 为 True 且源分辨率与目标分辨率的旋转方向不一致时,
|
||||
自动交换目标分辨率的宽高,使其与源分辨率的方向保持一致。
|
||||
|
||||
:param src_size: 源分辨率 (width, height)
|
||||
:param tgt_size: 目标分辨率 (width, height)
|
||||
:return: 调整后的目标分辨率 (width, height)
|
||||
:raises UnscalableResolutionError: 若宽高比不兼容
|
||||
"""
|
||||
# 智能调整目标分辨率方向
|
||||
adjusted_tgt_size = target
|
||||
if self.match_rotation:
|
||||
src_w, src_h = source
|
||||
tgt_w, tgt_h = target
|
||||
|
||||
# 判断源分辨率和目标分辨率的方向
|
||||
src_is_landscape = src_w > src_h
|
||||
tgt_is_landscape = tgt_w > tgt_h
|
||||
|
||||
# 如果方向不一致,交换目标分辨率的宽高
|
||||
if src_is_landscape != tgt_is_landscape:
|
||||
adjusted_tgt_size = (tgt_h, tgt_w)
|
||||
|
||||
# 校验调整后的分辨率是否兼容
|
||||
if not self.__aspect_ratio_compatible(source, adjusted_tgt_size):
|
||||
raise UnscalableResolutionError(target, source)
|
||||
|
||||
return adjusted_tgt_size
|
||||
|
||||
|
||||
class AndroidDevice(Device):
|
||||
def __init__(self, adb_connection: AdbUtilsDevice | None = None) -> None:
|
||||
super().__init__('android')
|
||||
self._adb: AdbUtilsDevice | None = adb_connection
|
||||
self.commands: AndroidCommandable
|
||||
|
||||
def current_package(self) -> str | None:
|
||||
"""
|
||||
获取前台 APP 的包名。
|
||||
|
||||
:return: 前台 APP 的包名。如果获取失败,则返回 None。
|
||||
:exception: 如果设备不支持此功能,则抛出 NotImplementedError。
|
||||
"""
|
||||
ret = self.commands.current_package()
|
||||
logger.debug("current_package: %s", ret)
|
||||
return ret
|
||||
|
||||
def launch_app(self, package_name: str) -> None:
|
||||
"""
|
||||
根据包名启动 app
|
||||
"""
|
||||
self.commands.launch_app(package_name)
|
||||
|
||||
|
||||
class WindowsDevice(Device):
|
||||
def __init__(self) -> None:
|
||||
super().__init__('windows')
|
||||
self.commands: WindowsCommandable
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
@ -346,10 +464,10 @@ if __name__ == "__main__":
|
|||
d = adb.device_list()[-1]
|
||||
d.shell("dumpsys activity top | grep ACTIVITY | tail -n 1")
|
||||
dd = AndroidDevice(d)
|
||||
adb_imp = AdbRawImpl(dd)
|
||||
dd._command = adb_imp
|
||||
adb_imp = AdbRawImpl(d)
|
||||
dd._touch = adb_imp
|
||||
dd._screenshot = adb_imp
|
||||
dd.commands = adb_imp
|
||||
# dd._screenshot = MinicapScreenshotImpl(dd)
|
||||
# dd._screenshot = UiAutomator2Impl(dd)
|
||||
|
||||
|
|
|
@ -1,92 +0,0 @@
|
|||
import logging
|
||||
from typing import Literal
|
||||
|
||||
from .implements.adb import AdbImpl
|
||||
from .implements.adb_raw import AdbRawImpl
|
||||
from .implements.windows import WindowsImpl
|
||||
from .implements.remote_windows import RemoteWindowsImpl
|
||||
from .implements.uiautomator2 import UiAutomator2Impl
|
||||
from .device import Device, AndroidDevice, WindowsDevice
|
||||
|
||||
from adbutils import adb
|
||||
|
||||
DeviceImpl = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows']
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def create_device(
|
||||
addr: str,
|
||||
impl: DeviceImpl,
|
||||
*,
|
||||
connect: bool = True,
|
||||
disconnect: bool = True,
|
||||
device_serial: str | None = None,
|
||||
timeout: float = 180,
|
||||
) -> Device:
|
||||
"""
|
||||
根据指定的实现方式创建 Device 实例。
|
||||
|
||||
:param addr: 设备地址,如 `127.0.0.1:5555`。
|
||||
仅当通过无线方式连接 Android 设备,或者使用 `remote_windows` 时有效。
|
||||
:param impl: 实现方式。
|
||||
:param connect: 是否在创建时连接设备,默认为 True。
|
||||
仅对 ADB-based 的实现方式有效。
|
||||
:param disconnect: 是否在连接前先断开设备,默认为 True。
|
||||
仅对 ADB-based 的实现方式有效。
|
||||
:param device_serial: 设备序列号,默认为 None。
|
||||
若为非 None,则当存在多个设备时通过该值判断是否为目标设备。
|
||||
仅对 ADB-based 的实现方式有效。
|
||||
:param timeout: 连接超时时间,默认为 180 秒。
|
||||
仅对 ADB-based 的实现方式有效。
|
||||
"""
|
||||
if impl in ['adb', 'adb_raw', 'uiautomator2']:
|
||||
if disconnect:
|
||||
logger.debug('adb disconnect %s', addr)
|
||||
adb.disconnect(addr)
|
||||
if connect:
|
||||
logger.debug('adb connect %s', addr)
|
||||
result = adb.connect(addr)
|
||||
if 'cannot connect to' in result:
|
||||
raise ValueError(result)
|
||||
serial = device_serial or addr
|
||||
logger.debug('adb wait for %s', serial)
|
||||
adb.wait_for(serial, timeout=timeout)
|
||||
devices = adb.device_list()
|
||||
logger.debug('adb device_list: %s', devices)
|
||||
d = [d for d in devices if d.serial == serial]
|
||||
if len(d) == 0:
|
||||
raise ValueError(f"Device {addr} not found")
|
||||
d = d[0]
|
||||
device = AndroidDevice(d)
|
||||
if impl == 'adb':
|
||||
device._command = AdbImpl(device)
|
||||
device._touch = AdbImpl(device)
|
||||
device._screenshot = AdbImpl(device)
|
||||
elif impl == 'adb_raw':
|
||||
device._command = AdbRawImpl(device)
|
||||
device._touch = AdbRawImpl(device)
|
||||
device._screenshot = AdbRawImpl(device)
|
||||
elif impl == 'uiautomator2':
|
||||
device._command = UiAutomator2Impl(device)
|
||||
device._touch = UiAutomator2Impl(device)
|
||||
device._screenshot = UiAutomator2Impl(device)
|
||||
elif impl == 'windows':
|
||||
device = WindowsDevice()
|
||||
device._touch = WindowsImpl(device)
|
||||
device._screenshot = WindowsImpl(device)
|
||||
elif impl == 'remote_windows':
|
||||
# For remote_windows, addr should be in the format 'host:port'
|
||||
if ':' not in addr:
|
||||
raise ValueError(f"Invalid address format for remote_windows: {addr}. Expected format: 'host:port'")
|
||||
host, port_str = addr.split(':', 1)
|
||||
try:
|
||||
port = int(port_str)
|
||||
except ValueError:
|
||||
raise ValueError(f"Invalid port in address: {port_str}")
|
||||
|
||||
device = WindowsDevice()
|
||||
remote_impl = RemoteWindowsImpl(device, host, port)
|
||||
device._touch = remote_impl
|
||||
device._screenshot = remote_impl
|
||||
else:
|
||||
raise ValueError(f"Unsupported device implementation: {impl}")
|
||||
return device
|
|
@ -1,10 +1,11 @@
|
|||
from .protocol import HostProtocol, Instance
|
||||
from .protocol import HostProtocol, Instance, AdbHostConfig, WindowsHostConfig, RemoteWindowsHostConfig
|
||||
from .custom import CustomInstance, create as create_custom
|
||||
from .mumu12_host import Mumu12Host, Mumu12Instance
|
||||
from .leidian_host import LeidianHost, LeidianInstance
|
||||
|
||||
__all__ = [
|
||||
'HostProtocol', 'Instance',
|
||||
'AdbHostConfig', 'WindowsHostConfig', 'RemoteWindowsHostConfig',
|
||||
'CustomInstance', 'create_custom',
|
||||
'Mumu12Host', 'Mumu12Instance',
|
||||
'LeidianHost', 'LeidianInstance'
|
||||
|
|
|
@ -0,0 +1,94 @@
|
|||
from abc import ABC
|
||||
from typing import Any, Literal, TypeGuard, TypeVar, get_args
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from adbutils import adb
|
||||
from adbutils._device import AdbDevice
|
||||
from kotonebot import logging
|
||||
from kotonebot.client.device import AndroidDevice
|
||||
from .protocol import Instance, AdbHostConfig, Device
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
AdbRecipes = Literal['adb', 'adb_raw', 'uiautomator2']
|
||||
|
||||
def is_adb_recipe(recipe: Any) -> TypeGuard[AdbRecipes]:
|
||||
return recipe in get_args(AdbRecipes)
|
||||
|
||||
def connect_adb(
|
||||
ip: str,
|
||||
port: int,
|
||||
connect: bool = True,
|
||||
disconnect: bool = True,
|
||||
timeout: float = 180,
|
||||
device_serial: str | None = None
|
||||
) -> AdbDevice:
|
||||
"""
|
||||
创建 ADB 连接。
|
||||
"""
|
||||
if disconnect:
|
||||
logger.debug('adb disconnect %s:%d', ip, port)
|
||||
adb.disconnect(f'{ip}:{port}')
|
||||
if connect:
|
||||
logger.debug('adb connect %s:%d', ip, port)
|
||||
result = adb.connect(f'{ip}:{port}')
|
||||
if 'cannot connect to' in result:
|
||||
raise ValueError(result)
|
||||
serial = device_serial or f'{ip}:{port}'
|
||||
logger.debug('adb wait for %s', serial)
|
||||
adb.wait_for(serial, timeout=timeout)
|
||||
devices = adb.device_list()
|
||||
logger.debug('adb device_list: %s', devices)
|
||||
d = [d for d in devices if d.serial == serial]
|
||||
if len(d) == 0:
|
||||
raise ValueError(f"Device {serial} not found")
|
||||
d = d[0]
|
||||
return d
|
||||
|
||||
class CommonAdbCreateDeviceMixin(ABC):
|
||||
"""
|
||||
通用 ADB 创建设备的 Mixin。
|
||||
该 Mixin 定义了创建 ADB 设备的通用接口。
|
||||
"""
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
# 下面的属性只是为了让类型检查通过,无实际实现
|
||||
self.adb_ip: str
|
||||
self.adb_port: int
|
||||
self.adb_name: str
|
||||
|
||||
def create_device(self, recipe: AdbRecipes, config: AdbHostConfig) -> Device:
|
||||
"""
|
||||
创建 ADB 设备。
|
||||
"""
|
||||
connection = connect_adb(
|
||||
self.adb_ip,
|
||||
self.adb_port,
|
||||
connect=True,
|
||||
disconnect=True,
|
||||
timeout=config.timeout,
|
||||
device_serial=self.adb_name
|
||||
)
|
||||
d = AndroidDevice(connection)
|
||||
match recipe:
|
||||
case 'adb':
|
||||
from kotonebot.client.implements.adb import AdbImpl
|
||||
impl = AdbImpl(connection)
|
||||
d._screenshot = impl
|
||||
d._touch = impl
|
||||
d.commands = impl
|
||||
case 'adb_raw':
|
||||
from kotonebot.client.implements.adb_raw import AdbRawImpl
|
||||
impl = AdbRawImpl(connection)
|
||||
d._screenshot = impl
|
||||
d._touch = impl
|
||||
d.commands = impl
|
||||
case 'uiautomator2':
|
||||
from kotonebot.client.implements.uiautomator2 import UiAutomator2Impl
|
||||
from kotonebot.client.implements.adb import AdbImpl
|
||||
impl = UiAutomator2Impl(connection)
|
||||
d._screenshot = impl
|
||||
d._touch = impl
|
||||
d.commands = AdbImpl(connection)
|
||||
case _:
|
||||
assert_never(f'Unsupported ADB recipe: {recipe}')
|
||||
return d
|
|
@ -1,19 +1,21 @@
|
|||
import os
|
||||
import subprocess
|
||||
from psutil import process_iter
|
||||
from .protocol import HostProtocol, Instance
|
||||
from typing import Optional, ParamSpec, TypeVar, TypeGuard
|
||||
from .protocol import Instance, AdbHostConfig, HostProtocol
|
||||
from typing import ParamSpec, TypeVar
|
||||
from typing_extensions import override
|
||||
|
||||
from kotonebot import logging
|
||||
from kotonebot.client.device import Device
|
||||
from kotonebot.client import Device
|
||||
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
CustomRecipes = AdbRecipes
|
||||
|
||||
P = ParamSpec('P')
|
||||
T = TypeVar('T')
|
||||
|
||||
class CustomInstance(Instance):
|
||||
class CustomInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
|
||||
def __init__(self, exe_path: str | None, emulator_args: str = "", *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.exe_path: str | None = exe_path
|
||||
|
@ -65,6 +67,14 @@ class CustomInstance(Instance):
|
|||
def refresh(self):
|
||||
pass
|
||||
|
||||
@override
|
||||
def create_device(self, impl: CustomRecipes, host_config: AdbHostConfig) -> Device:
|
||||
"""为自定义实例创建 Device。"""
|
||||
if self.adb_port is None:
|
||||
raise ValueError("ADB port is not set and is required.")
|
||||
|
||||
return super().create_device(impl, host_config)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f'CustomInstance(#{self.id}# at "{self.exe_path}" with {self.adb_ip}:{self.adb_port})'
|
||||
|
||||
|
@ -76,6 +86,25 @@ def _type_check(ins: Instance) -> CustomInstance:
|
|||
def create(exe_path: str | None, adb_ip: str, adb_port: int, adb_name: str | None, emulator_args: str = "") -> CustomInstance:
|
||||
return CustomInstance(exe_path, emulator_args=emulator_args, id='custom', name='Custom', adb_ip=adb_ip, adb_port=adb_port, adb_name=adb_name)
|
||||
|
||||
class CustomHost(HostProtocol[CustomRecipes]):
|
||||
@staticmethod
|
||||
def installed() -> bool:
|
||||
# Custom instances don't have a specific installation requirement
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
def list() -> list[Instance]:
|
||||
# Custom instances are created manually, not discovered
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def query(*, id: str) -> Instance | None:
|
||||
# Custom instances are created manually, not discovered
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def recipes() -> 'list[CustomRecipes]':
|
||||
return ['adb', 'adb_raw', 'uiautomator2']
|
||||
|
||||
if __name__ == '__main__':
|
||||
ins = create(r'C:\Program Files\BlueStacks_nxt\HD-Player.exe', '127.0.0.1', 5555, '**emulator-name**')
|
||||
|
|
|
@ -1,15 +1,17 @@
|
|||
import os
|
||||
import subprocess
|
||||
from typing import Literal
|
||||
from functools import lru_cache
|
||||
from typing_extensions import override
|
||||
|
||||
from kotonebot import logging
|
||||
from kotonebot.client import DeviceImpl, create_device
|
||||
from kotonebot.client.device import Device
|
||||
from kotonebot.client import Device
|
||||
from kotonebot.util import Countdown, Interval
|
||||
from .protocol import HostProtocol, Instance, copy_type
|
||||
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
|
||||
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
LeidianRecipes = AdbRecipes
|
||||
|
||||
if os.name == 'nt':
|
||||
from ...interop.win.reg import read_reg
|
||||
|
@ -18,7 +20,7 @@ else:
|
|||
"""Stub for read_reg on non-Windows platforms."""
|
||||
return default
|
||||
|
||||
class LeidianInstance(Instance):
|
||||
class LeidianInstance(CommonAdbCreateDeviceMixin, Instance[AdbHostConfig]):
|
||||
@copy_type(Instance.__init__)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -61,27 +63,23 @@ class LeidianInstance(Instance):
|
|||
it = Interval(5)
|
||||
while not cd.expired() and not self.running():
|
||||
it.wait()
|
||||
self.refresh()
|
||||
if not self.running():
|
||||
raise TimeoutError(f'Leidian instance "{self.name}" is not available.')
|
||||
|
||||
@override
|
||||
def running(self) -> bool:
|
||||
result = LeidianHost._invoke_manager(['isrunning', '--index', str(self.index)])
|
||||
return result.strip() == 'running'
|
||||
return self.is_running
|
||||
|
||||
@override
|
||||
def create_device(self, impl: DeviceImpl, *, timeout: float = 180) -> Device:
|
||||
def create_device(self, impl: LeidianRecipes, host_config: AdbHostConfig) -> Device:
|
||||
"""为雷电模拟器实例创建 Device。"""
|
||||
if self.adb_port is None:
|
||||
raise ValueError("ADB port is not set and is required.")
|
||||
return create_device(
|
||||
addr=f'{self.adb_ip}:{self.adb_port}',
|
||||
impl=impl,
|
||||
device_serial=self.adb_name,
|
||||
connect=False,
|
||||
timeout=timeout
|
||||
)
|
||||
|
||||
class LeidianHost(HostProtocol):
|
||||
return super().create_device(impl, host_config)
|
||||
|
||||
class LeidianHost(HostProtocol[LeidianRecipes]):
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _read_install_path() -> str | None:
|
||||
|
@ -186,6 +184,10 @@ class LeidianHost(HostProtocol):
|
|||
return instance
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def recipes() -> 'list[LeidianRecipes]':
|
||||
return ['adb', 'adb_raw', 'uiautomator2']
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
print(LeidianHost._read_install_path())
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
from dataclasses import dataclass
|
||||
import os
|
||||
import json
|
||||
import subprocess
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
from typing import Any, Literal, overload
|
||||
from typing_extensions import override
|
||||
|
||||
from kotonebot import logging
|
||||
from kotonebot.client import DeviceImpl, Device
|
||||
from kotonebot.client import Device
|
||||
from kotonebot.client.device import AndroidDevice
|
||||
from kotonebot.client.implements.adb import AdbImpl
|
||||
from kotonebot.client.implements.nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
|
||||
from kotonebot.util import Countdown, Interval
|
||||
from .protocol import HostProtocol, Instance, copy_type
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
from .protocol import HostProtocol, Instance, copy_type, AdbHostConfig
|
||||
from .adb_common import AdbRecipes, CommonAdbCreateDeviceMixin, connect_adb, is_adb_recipe
|
||||
|
||||
if os.name == 'nt':
|
||||
from ...interop.win.reg import read_reg
|
||||
|
@ -19,7 +22,21 @@ else:
|
|||
"""Stub for read_reg on non-Windows platforms."""
|
||||
return default
|
||||
|
||||
class Mumu12Instance(Instance):
|
||||
logger = logging.getLogger(__name__)
|
||||
MuMu12Recipes = AdbRecipes | Literal['nemu_ipc']
|
||||
|
||||
@dataclass
|
||||
class MuMu12HostConfig(AdbHostConfig):
|
||||
"""nemu_ipc 能力的配置模型。"""
|
||||
display_id: int | None = 0
|
||||
"""目标显示器 ID,默认为 0(主显示器)。若为 None 且设置了 target_package_name,则自动获取对应的 display_id。"""
|
||||
target_package_name: str | None = None
|
||||
"""目标应用包名,用于自动获取 display_id。"""
|
||||
app_index: int = 0
|
||||
"""多开应用索引,传给 get_display_id 方法。"""
|
||||
|
||||
|
||||
class Mumu12Instance(CommonAdbCreateDeviceMixin, Instance[MuMu12HostConfig]):
|
||||
@copy_type(Instance.__init__)
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -70,14 +87,58 @@ class Mumu12Instance(Instance):
|
|||
def running(self) -> bool:
|
||||
return self.is_android_started
|
||||
|
||||
class Mumu12Host(HostProtocol):
|
||||
@overload
|
||||
def create_device(self, recipe: Literal['nemu_ipc'], host_config: MuMu12HostConfig) -> Device: ...
|
||||
@overload
|
||||
def create_device(self, recipe: AdbRecipes, host_config: AdbHostConfig) -> Device: ...
|
||||
|
||||
@override
|
||||
def create_device(self, recipe: MuMu12Recipes, host_config: MuMu12HostConfig | AdbHostConfig) -> Device:
|
||||
"""为MuMu12模拟器实例创建 Device。"""
|
||||
if self.adb_port is None:
|
||||
raise ValueError("ADB port is not set and is required.")
|
||||
|
||||
if recipe == 'nemu_ipc' and isinstance(host_config, MuMu12HostConfig):
|
||||
# NemuImpl
|
||||
nemu_path = Mumu12Host._read_install_path()
|
||||
if not nemu_path:
|
||||
raise RuntimeError("无法找到 MuMu12 的安装路径。")
|
||||
nemu_config = NemuIpcImplConfig(
|
||||
nemu_folder=nemu_path,
|
||||
instance_id=int(self.id),
|
||||
display_id=host_config.display_id,
|
||||
target_package_name=host_config.target_package_name,
|
||||
app_index=host_config.app_index
|
||||
)
|
||||
nemu_impl = NemuIpcImpl(nemu_config)
|
||||
# AdbImpl
|
||||
adb_impl = AdbImpl(connect_adb(
|
||||
self.adb_ip,
|
||||
self.adb_port,
|
||||
timeout=host_config.timeout,
|
||||
device_serial=self.adb_name
|
||||
))
|
||||
device = AndroidDevice()
|
||||
device._screenshot = nemu_impl
|
||||
device._touch = nemu_impl
|
||||
device.commands = adb_impl
|
||||
|
||||
return device
|
||||
elif isinstance(host_config, AdbHostConfig) and is_adb_recipe(recipe):
|
||||
return super().create_device(recipe, host_config)
|
||||
else:
|
||||
raise ValueError(f'Unknown recipe: {recipe}')
|
||||
|
||||
class Mumu12Host(HostProtocol[MuMu12Recipes]):
|
||||
@staticmethod
|
||||
@lru_cache(maxsize=1)
|
||||
def _read_install_path() -> str | None:
|
||||
"""
|
||||
Reads the installation path (DisplayIcon) of MuMu Player 12 from the registry.
|
||||
r"""
|
||||
从注册表中读取 MuMu Player 12 的安装路径。
|
||||
|
||||
:return: The path to the display icon if found, otherwise None.
|
||||
返回的路径为根目录。如 `F:\Apps\Netease\MuMuPlayer-12.0`。
|
||||
|
||||
:return: 若找到,则返回安装路径;否则返回 None。
|
||||
"""
|
||||
if os.name != 'nt':
|
||||
return None
|
||||
|
@ -94,6 +155,9 @@ class Mumu12Host(HostProtocol):
|
|||
icon_path = icon_path.replace('"', '')
|
||||
path = os.path.dirname(icon_path)
|
||||
logger.debug('MuMu Player 12 installation path: %s', path)
|
||||
# 返回根目录(去掉 shell 子目录)
|
||||
if os.path.basename(path).lower() == 'shell':
|
||||
path = os.path.dirname(path)
|
||||
return path
|
||||
return None
|
||||
|
||||
|
@ -108,7 +172,7 @@ class Mumu12Host(HostProtocol):
|
|||
install_path = Mumu12Host._read_install_path()
|
||||
if install_path is None:
|
||||
raise RuntimeError('MuMu Player 12 is not installed.')
|
||||
manager_path = os.path.join(install_path, 'MuMuManager.exe')
|
||||
manager_path = os.path.join(install_path, 'shell', 'MuMuManager.exe')
|
||||
logger.debug('MuMuManager execute: %s', repr(args))
|
||||
output = subprocess.run(
|
||||
[manager_path] + args,
|
||||
|
@ -162,6 +226,10 @@ class Mumu12Host(HostProtocol):
|
|||
if instance.id == id:
|
||||
return instance
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def recipes() -> 'list[MuMu12Recipes]':
|
||||
return ['adb', 'adb_raw', 'uiautomator2', 'nemu_ipc']
|
||||
|
||||
if __name__ == '__main__':
|
||||
logging.basicConfig(level=logging.DEBUG, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
|
|
|
@ -1,14 +1,15 @@
|
|||
import time
|
||||
import socket
|
||||
from abc import ABC, abstractmethod
|
||||
from typing_extensions import ParamSpec, Concatenate
|
||||
from typing import Callable, TypeVar, Generic, Protocol, runtime_checkable, Type, Any
|
||||
from typing import Callable, TypeVar, Protocol, Any, Generic
|
||||
from dataclasses import dataclass
|
||||
|
||||
from adbutils import adb, AdbTimeout, AdbError
|
||||
from adbutils._device import AdbDevice
|
||||
|
||||
from kotonebot import logging
|
||||
from kotonebot.client import Device, create_device, DeviceImpl
|
||||
from kotonebot.client import Device, DeviceImpl
|
||||
|
||||
from kotonebot.util import Countdown, Interval
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -17,6 +18,28 @@ _T = TypeVar("_T")
|
|||
def copy_type(_: _T) -> Callable[[Any], _T]:
|
||||
return lambda x: x
|
||||
|
||||
# --- 定义专用的 HostConfig 数据类 ---
|
||||
@dataclass
|
||||
class AdbHostConfig:
|
||||
"""由外部为基于 ADB 的主机提供的配置。"""
|
||||
timeout: float = 180
|
||||
|
||||
@dataclass
|
||||
class WindowsHostConfig:
|
||||
"""由外部为 Windows 实现提供配置。"""
|
||||
window_title: str
|
||||
ahk_exe_path: str
|
||||
|
||||
@dataclass
|
||||
class RemoteWindowsHostConfig:
|
||||
"""由外部为远程 Windows 实现提供配置。"""
|
||||
windows_host_config: WindowsHostConfig
|
||||
host: str
|
||||
port: int
|
||||
|
||||
# --- 使用泛型改造 Instance 协议 ---
|
||||
T_HostConfig = TypeVar("T_HostConfig")
|
||||
|
||||
def tcp_ping(host: str, port: int, timeout: float = 1.0) -> bool:
|
||||
"""
|
||||
通过 TCP ping 检查主机和端口是否可达。
|
||||
|
@ -36,7 +59,11 @@ def tcp_ping(host: str, port: int, timeout: float = 1.0) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
class Instance(ABC):
|
||||
class Instance(Generic[T_HostConfig], ABC):
|
||||
"""
|
||||
代表一个可运行环境的实例(如一个模拟器)。
|
||||
使用泛型来约束 create_device 方法的配置参数类型。
|
||||
"""
|
||||
def __init__(self,
|
||||
id: str,
|
||||
name: str,
|
||||
|
@ -68,7 +95,7 @@ class Instance(ABC):
|
|||
启动模拟器实例。
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def stop(self):
|
||||
"""
|
||||
|
@ -80,21 +107,16 @@ class Instance(ABC):
|
|||
def running(self) -> bool:
|
||||
raise NotImplementedError()
|
||||
|
||||
def create_device(self, impl: DeviceImpl, *, timeout: float = 180) -> Device:
|
||||
@abstractmethod
|
||||
def create_device(self, impl: DeviceImpl, host_config: T_HostConfig) -> Device:
|
||||
"""
|
||||
创建 Device 实例,可用于控制模拟器系统。
|
||||
|
||||
:return: Device 实例
|
||||
根据实现名称和类型化的主机配置创建设备。
|
||||
|
||||
:param impl: 设备实现的名称。
|
||||
:param host_config: 一个类型化的数据对象,包含创建所需的所有外部配置。
|
||||
:return: 配置好的 Device 实例。
|
||||
"""
|
||||
if self.adb_port is None:
|
||||
raise ValueError("ADB port is not set and is required.")
|
||||
return create_device(
|
||||
addr=f'{self.adb_ip}:{self.adb_port}',
|
||||
impl=impl,
|
||||
device_serial=self.adb_name,
|
||||
connect=True,
|
||||
timeout=timeout
|
||||
)
|
||||
raise NotImplementedError()
|
||||
|
||||
def wait_available(self, timeout: float = 180):
|
||||
logger.info('Starting to wait for emulator %s(127.0.0.1:%d) to be available...', self.name, self.adb_port)
|
||||
|
@ -173,7 +195,8 @@ class Instance(ABC):
|
|||
def __repr__(self) -> str:
|
||||
return f'{self.__class__.__name__}(name="{self.name}", id="{self.id}", adb="{self.adb_ip}:{self.adb_port}"({self.adb_name}))'
|
||||
|
||||
class HostProtocol(Protocol):
|
||||
Recipe = TypeVar('Recipe', bound=str)
|
||||
class HostProtocol(Generic[Recipe], Protocol):
|
||||
@staticmethod
|
||||
def installed() -> bool: ...
|
||||
|
||||
|
@ -183,6 +206,8 @@ class HostProtocol(Protocol):
|
|||
@staticmethod
|
||||
def query(*, id: str) -> Instance | None: ...
|
||||
|
||||
@staticmethod
|
||||
def recipes() -> 'list[Recipe]': ...
|
||||
|
||||
if __name__ == '__main__':
|
||||
pass
|
||||
|
|
|
@ -0,0 +1,55 @@
|
|||
from abc import ABC
|
||||
from typing import Literal
|
||||
from typing_extensions import assert_never
|
||||
|
||||
from kotonebot import logging
|
||||
from kotonebot.client.device import WindowsDevice
|
||||
from .protocol import Device, WindowsHostConfig, RemoteWindowsHostConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
WindowsRecipes = Literal['windows', 'remote_windows']
|
||||
|
||||
# Windows 相关的配置类型联合
|
||||
WindowsHostConfigs = WindowsHostConfig | RemoteWindowsHostConfig
|
||||
|
||||
class CommonWindowsCreateDeviceMixin(ABC):
|
||||
"""
|
||||
通用 Windows 创建设备的 Mixin。
|
||||
该 Mixin 定义了创建 Windows 设备的通用接口。
|
||||
"""
|
||||
def __init__(self, *args, **kwargs) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def create_device(self, recipe: WindowsRecipes, config: WindowsHostConfigs) -> Device:
|
||||
"""
|
||||
创建 Windows 设备。
|
||||
"""
|
||||
match recipe:
|
||||
case 'windows':
|
||||
if not isinstance(config, WindowsHostConfig):
|
||||
raise ValueError(f"Expected WindowsHostConfig for 'windows' recipe, got {type(config)}")
|
||||
from kotonebot.client.implements.windows import WindowsImpl
|
||||
d = WindowsDevice()
|
||||
impl = WindowsImpl(
|
||||
device=d,
|
||||
window_title=config.window_title,
|
||||
ahk_exe_path=config.ahk_exe_path
|
||||
)
|
||||
d._screenshot = impl
|
||||
d._touch = impl
|
||||
return d
|
||||
case 'remote_windows':
|
||||
if not isinstance(config, RemoteWindowsHostConfig):
|
||||
raise ValueError(f"Expected RemoteWindowsHostConfig for 'remote_windows' recipe, got {type(config)}")
|
||||
from kotonebot.client.implements.remote_windows import RemoteWindowsImpl
|
||||
d = WindowsDevice()
|
||||
impl = RemoteWindowsImpl(
|
||||
device=d,
|
||||
host=config.host,
|
||||
port=config.port
|
||||
)
|
||||
d._screenshot = impl
|
||||
d._touch = impl
|
||||
return d
|
||||
case _:
|
||||
assert_never(f'Unsupported Windows recipe: {recipe}')
|
|
@ -0,0 +1,7 @@
|
|||
# 导入所有内置实现,以触发它们的 @register_impl 装饰器
|
||||
from . import adb # noqa: F401
|
||||
from . import adb_raw # noqa: F401
|
||||
from . import remote_windows # noqa: F401
|
||||
from . import uiautomator2 # noqa: F401
|
||||
from . import windows # noqa: F401
|
||||
from . import nemu_ipc # noqa: F401
|
|
@ -5,16 +5,27 @@ from typing_extensions import override
|
|||
import cv2
|
||||
import numpy as np
|
||||
from cv2.typing import MatLike
|
||||
from adbutils._device import AdbDevice as AdbUtilsDevice
|
||||
|
||||
from ..device import Device
|
||||
from ..protocol import Commandable, Touchable, Screenshotable
|
||||
from ..device import AndroidDevice
|
||||
from ..protocol import AndroidCommandable, Touchable, Screenshotable
|
||||
from ..registration import ImplConfig
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AdbImpl(Commandable, Touchable, Screenshotable):
|
||||
def __init__(self, device: Device):
|
||||
self.device = device
|
||||
self.adb = device.adb
|
||||
# 定义配置模型
|
||||
@dataclass
|
||||
class AdbImplConfig(ImplConfig):
|
||||
addr: str
|
||||
connect: bool = True
|
||||
disconnect: bool = True
|
||||
device_serial: str | None = None
|
||||
timeout: float = 180
|
||||
|
||||
class AdbImpl(AndroidCommandable, Touchable, Screenshotable):
|
||||
def __init__(self, adb_connection: AdbUtilsDevice):
|
||||
self.adb = adb_connection
|
||||
|
||||
@override
|
||||
def launch_app(self, package_name: str) -> None:
|
||||
|
@ -36,6 +47,10 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
|
|||
package = activity.split('/')[0]
|
||||
return package
|
||||
|
||||
def adb_shell(self, cmd: str) -> str:
|
||||
"""执行 ADB shell 命令"""
|
||||
return cast(str, self.adb.shell(cmd))
|
||||
|
||||
@override
|
||||
def detect_orientation(self):
|
||||
# 判断方向:https://stackoverflow.com/questions/10040624/check-if-device-is-landscape-via-adb
|
||||
|
@ -50,7 +65,9 @@ class AdbImpl(Commandable, Touchable, Screenshotable):
|
|||
def screen_size(self) -> tuple[int, int]:
|
||||
ret = cast(str, self.adb.shell("wm size")).strip('Physical size: ')
|
||||
spiltted = tuple(map(int, ret.split("x")))
|
||||
landscape = self.device.orientation == 'landscape'
|
||||
# 检测当前方向
|
||||
orientation = self.detect_orientation()
|
||||
landscape = orientation == 'landscape'
|
||||
spiltted = tuple(sorted(spiltted, reverse=landscape))
|
||||
if len(spiltted) != 2:
|
||||
raise ValueError(f"Invalid screen size: {ret}")
|
||||
|
|
|
@ -12,7 +12,7 @@ from cv2.typing import MatLike
|
|||
from adbutils._utils import adb_path
|
||||
|
||||
from .adb import AdbImpl
|
||||
from ..device import Device
|
||||
from adbutils._device import AdbDevice as AdbUtilsDevice
|
||||
from kotonebot import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
@ -27,8 +27,8 @@ done
|
|||
"""
|
||||
|
||||
class AdbRawImpl(AdbImpl):
|
||||
def __init__(self, device: Device):
|
||||
super().__init__(device)
|
||||
def __init__(self, adb_connection: AdbUtilsDevice):
|
||||
super().__init__(adb_connection)
|
||||
self.__worker: Thread | None = None
|
||||
self.__process: subprocess.Popen | None = None
|
||||
self.__data: MatLike | None = None
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
from .external_renderer_ipc import ExternalRendererIpc
|
||||
from .nemu_ipc import NemuIpcImpl, NemuIpcImplConfig
|
||||
|
||||
__all__ = [
|
||||
"ExternalRendererIpc",
|
||||
"NemuIpcImpl",
|
||||
"NemuIpcImplConfig",
|
||||
]
|
|
@ -0,0 +1,280 @@
|
|||
import ctypes
|
||||
import logging
|
||||
import os
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NemuIpcIncompatible(RuntimeError):
|
||||
"""MuMu12 IPC 环境不兼容或 DLL 加载失败"""
|
||||
|
||||
|
||||
class ExternalRendererIpc:
|
||||
r"""对 `external_renderer_ipc.dll` 的轻量封装。
|
||||
|
||||
该类仅处理 DLL 加载与原型声明,并提供带有类型提示的薄包装方法,
|
||||
方便在其他模块中调用且保持类型安全。
|
||||
传入参数为 MuMu 根目录(如 F:\Apps\Netease\MuMuPlayer-12.0)。
|
||||
"""
|
||||
|
||||
def __init__(self, mumu_root_folder: str):
|
||||
if os.name != "nt":
|
||||
raise NemuIpcIncompatible("ExternalRendererIpc only supports Windows.")
|
||||
|
||||
self.lib = self.__load_dll(mumu_root_folder)
|
||||
self.raise_on_error: bool = True
|
||||
"""是否在调用 DLL 函数失败时抛出异常。"""
|
||||
self.__declare_prototypes()
|
||||
|
||||
def connect(self, nemu_folder: str, instance_id: int) -> int:
|
||||
"""
|
||||
建立连接。
|
||||
|
||||
API 原型:
|
||||
`int nemu_connect(const wchar_t* path, int index)`
|
||||
|
||||
:param nemu_folder: 模拟器安装路径。
|
||||
:param instance_id: 模拟器实例 ID。
|
||||
:return: 成功返回连接 ID,失败返回 0。
|
||||
"""
|
||||
return self.lib.nemu_connect(nemu_folder, instance_id)
|
||||
|
||||
def disconnect(self, connect_id: int) -> None:
|
||||
"""
|
||||
断开连接。
|
||||
|
||||
API 原型:
|
||||
`void nemu_disconnect(int handle)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:return: 无返回值。
|
||||
"""
|
||||
return self.lib.nemu_disconnect(connect_id)
|
||||
|
||||
def get_display_id(self, connect_id: int, pkg: str, app_index: int) -> int:
|
||||
"""
|
||||
获取指定包的 display id。
|
||||
|
||||
API 原型:
|
||||
`int nemu_get_display_id(int handle, const char* pkg, int appIndex)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param pkg: 包名。
|
||||
:param app_index: 多开应用索引。
|
||||
:return: <0 表示失败,>=0 表示有效 display id。
|
||||
"""
|
||||
return self.lib.nemu_get_display_id(connect_id, pkg.encode('utf-8'), app_index)
|
||||
|
||||
def capture_display(
|
||||
self,
|
||||
connect_id: int,
|
||||
display_id: int,
|
||||
buf_len: int,
|
||||
width_ptr: ctypes.c_void_p,
|
||||
height_ptr: ctypes.c_void_p,
|
||||
buffer_ptr: ctypes.c_void_p,
|
||||
) -> int:
|
||||
"""
|
||||
截取指定显示屏内容。
|
||||
|
||||
API 原型:
|
||||
`int nemu_capture_display(int handle, unsigned int displayid, int buffer_size, int *width, int *height, unsigned char* pixels)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:param buf_len: 缓冲区长度(字节)。
|
||||
:param width_ptr: 用于接收宽度的指针(ctypes.c_void_p/int 指针)。
|
||||
:param height_ptr: 用于接收高度的指针(ctypes.c_void_p/int 指针)。
|
||||
:param buffer_ptr: 用于接收像素数据的指针(ctypes.c_void_p/unsigned char* 指针)。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_capture_display(
|
||||
connect_id,
|
||||
display_id,
|
||||
buf_len,
|
||||
width_ptr,
|
||||
height_ptr,
|
||||
buffer_ptr,
|
||||
)
|
||||
|
||||
def input_text(self, connect_id: int, text: str) -> int:
|
||||
"""
|
||||
输入文本。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_text(int handle, int size, const char* buf)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param text: 输入文本(utf-8)。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
buf = text.encode('utf-8')
|
||||
return self.lib.nemu_input_text(connect_id, len(buf), buf)
|
||||
|
||||
def input_touch_down(self, connect_id: int, display_id: int, x: int, y: int) -> int:
|
||||
"""
|
||||
发送触摸按下事件。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_event_touch_down(int handle, int displayid, int x_point, int y_point)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:param x: 触摸点 X 坐标。
|
||||
:param y: 触摸点 Y 坐标。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_input_event_touch_down(connect_id, display_id, x, y)
|
||||
|
||||
def input_touch_up(self, connect_id: int, display_id: int) -> int:
|
||||
"""
|
||||
发送触摸抬起事件。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_event_touch_up(int handle, int displayid)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_input_event_touch_up(connect_id, display_id)
|
||||
|
||||
def input_key_down(self, connect_id: int, display_id: int, key_code: int) -> int:
|
||||
"""
|
||||
发送按键按下事件。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_event_key_down(int handle, int displayid, int key_code)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:param key_code: 按键码。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_input_event_key_down(connect_id, display_id, key_code)
|
||||
|
||||
def input_key_up(self, connect_id: int, display_id: int, key_code: int) -> int:
|
||||
"""
|
||||
发送按键抬起事件。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_event_key_up(int handle, int displayid, int key_code)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:param key_code: 按键码。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_input_event_key_up(connect_id, display_id, key_code)
|
||||
|
||||
def input_finger_touch_down(self, connect_id: int, display_id: int, finger_id: int, x: int, y: int) -> int:
|
||||
"""
|
||||
多指触摸按下。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_event_finger_touch_down(int handle, int displayid, int finger_id, int x_point, int y_point)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:param finger_id: 手指编号(1-10)。
|
||||
:param x: 触摸点 X 坐标。
|
||||
:param y: 触摸点 Y 坐标。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_input_event_finger_touch_down(connect_id, display_id, finger_id, x, y)
|
||||
|
||||
def input_finger_touch_up(self, connect_id: int, display_id: int, finger_id: int) -> int:
|
||||
"""
|
||||
多指触摸抬起。
|
||||
|
||||
API 原型:
|
||||
`int nemu_input_event_finger_touch_up(int handle, int displayid, int slot_id)`
|
||||
|
||||
:param connect_id: 连接 ID。
|
||||
:param display_id: 显示屏 ID。
|
||||
:param finger_id: 手指编号(1-10)。
|
||||
:return: 0 表示成功,>0 表示失败。
|
||||
"""
|
||||
return self.lib.nemu_input_event_finger_touch_up(connect_id, display_id, finger_id)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 内部工具
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def __load_dll(self, mumu_root_folder: str) -> ctypes.CDLL:
|
||||
"""尝试多条路径加载 DLL。传入为 MuMu 根目录。"""
|
||||
candidate_paths = [
|
||||
os.path.join(mumu_root_folder, "shell", "sdk", "external_renderer_ipc.dll"),
|
||||
os.path.join(
|
||||
mumu_root_folder,
|
||||
"shell",
|
||||
"nx_device",
|
||||
"12.0",
|
||||
"sdk",
|
||||
"external_renderer_ipc.dll",
|
||||
),
|
||||
]
|
||||
for p in candidate_paths:
|
||||
if not os.path.exists(p):
|
||||
continue
|
||||
try:
|
||||
return ctypes.CDLL(p)
|
||||
except OSError as e: # pragma: no cover
|
||||
logger.warning("Failed to load DLL (%s): %s", p, e)
|
||||
raise NemuIpcIncompatible("external_renderer_ipc.dll not found or failed to load.")
|
||||
|
||||
def __declare_prototypes(self) -> None:
|
||||
"""声明 DLL 函数原型,确保 ctypes 类型安全。"""
|
||||
# 连接 / 断开
|
||||
self.lib.nemu_connect.argtypes = [ctypes.c_wchar_p, ctypes.c_int]
|
||||
self.lib.nemu_connect.restype = ctypes.c_int
|
||||
|
||||
self.lib.nemu_disconnect.argtypes = [ctypes.c_int]
|
||||
self.lib.nemu_disconnect.restype = None
|
||||
|
||||
# 获取 display id
|
||||
self.lib.nemu_get_display_id.argtypes = [ctypes.c_int, ctypes.c_char_p, ctypes.c_int]
|
||||
self.lib.nemu_get_display_id.restype = ctypes.c_int
|
||||
|
||||
# 截图
|
||||
self.lib.nemu_capture_display.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_void_p,
|
||||
]
|
||||
self.lib.nemu_capture_display.restype = ctypes.c_int
|
||||
|
||||
# 输入文本
|
||||
self.lib.nemu_input_text.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_char_p]
|
||||
self.lib.nemu_input_text.restype = ctypes.c_int
|
||||
|
||||
# 触摸
|
||||
self.lib.nemu_input_event_touch_down.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
ctypes.c_int,
|
||||
]
|
||||
self.lib.nemu_input_event_touch_down.restype = ctypes.c_int
|
||||
|
||||
self.lib.nemu_input_event_touch_up.argtypes = [ctypes.c_int, ctypes.c_int]
|
||||
self.lib.nemu_input_event_touch_up.restype = ctypes.c_int
|
||||
|
||||
# 按键
|
||||
self.lib.nemu_input_event_key_down.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.nemu_input_event_key_down.restype = ctypes.c_int
|
||||
|
||||
self.lib.nemu_input_event_key_up.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.nemu_input_event_key_up.restype = ctypes.c_int
|
||||
|
||||
# 多指触摸
|
||||
self.lib.nemu_input_event_finger_touch_down.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.nemu_input_event_finger_touch_down.restype = ctypes.c_int
|
||||
|
||||
self.lib.nemu_input_event_finger_touch_up.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_int]
|
||||
self.lib.nemu_input_event_finger_touch_up.restype = ctypes.c_int
|
||||
|
||||
logger.debug("DLL function prototypes declared")
|
|
@ -0,0 +1,327 @@
|
|||
import os
|
||||
import ctypes
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from time import sleep
|
||||
from typing import Literal
|
||||
from typing_extensions import override
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from ...device import AndroidDevice, Device
|
||||
from ...protocol import Touchable, Screenshotable
|
||||
from ...registration import ImplConfig
|
||||
from .external_renderer_ipc import ExternalRendererIpc
|
||||
from kotonebot.errors import KotonebotError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NemuIpcIncompatible(Exception):
|
||||
"""MuMu12 版本过低或 dll 不兼容"""
|
||||
pass
|
||||
|
||||
|
||||
class NemuIpcError(KotonebotError):
|
||||
"""调用 IPC 过程中发生错误"""
|
||||
pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class NemuIpcImplConfig(ImplConfig):
|
||||
"""nemu_ipc 能力的配置模型。"""
|
||||
nemu_folder: str
|
||||
r"""MuMu12 根目录(如 F:\Apps\Netease\MuMuPlayer-12.0)。"""
|
||||
instance_id: int
|
||||
"""模拟器实例 ID。"""
|
||||
display_id: int | None = 0
|
||||
"""目标显示器 ID,默认为 0(主显示器)。若为 None 且设置了 target_package_name,则自动获取对应的 display_id。"""
|
||||
target_package_name: str | None = None
|
||||
"""目标应用包名,用于自动获取 display_id。"""
|
||||
app_index: int = 0
|
||||
"""多开应用索引,传给 get_display_id 方法。"""
|
||||
wait_package_timeout: float = 60 # 单位秒,-1 表示永远等待,0 表示不等待,立即抛出异常
|
||||
wait_package_interval: float = 0.1 # 单位秒
|
||||
|
||||
|
||||
class NemuIpcImpl(Touchable, Screenshotable):
|
||||
"""
|
||||
利用 MuMu12 提供的 external_renderer_ipc.dll 进行截图与触摸控制。
|
||||
"""
|
||||
|
||||
def __init__(self, config: NemuIpcImplConfig):
|
||||
self.config = config
|
||||
self.__width: int = 0
|
||||
self.__height: int = 0
|
||||
self.__connected: bool = False
|
||||
self._connect_id: int = 0
|
||||
self.nemu_folder = config.nemu_folder
|
||||
|
||||
# --------------------------- DLL 封装 ---------------------------
|
||||
self._ipc = ExternalRendererIpc(config.nemu_folder)
|
||||
logger.info("ExternalRendererIpc initialized and DLL loaded")
|
||||
|
||||
@property
|
||||
def width(self) -> int:
|
||||
"""
|
||||
屏幕宽度。
|
||||
|
||||
若为 0,表示未连接或未获取到分辨率。
|
||||
"""
|
||||
return self.__width
|
||||
|
||||
@property
|
||||
def height(self) -> int:
|
||||
"""
|
||||
屏幕高度。
|
||||
|
||||
若为 0,表示未连接或未获取到分辨率。
|
||||
"""
|
||||
return self.__height
|
||||
|
||||
@property
|
||||
def connected(self) -> bool:
|
||||
"""是否已连接。"""
|
||||
return self.__connected
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 基础控制
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_connected(self) -> None:
|
||||
if not self.__connected:
|
||||
self.connect()
|
||||
|
||||
def _get_display_id(self) -> int:
|
||||
"""获取有效的 display_id。"""
|
||||
# 如果配置中直接指定了 display_id,直接返回
|
||||
if self.config.display_id is not None:
|
||||
return self.config.display_id
|
||||
|
||||
# 如果设置了 target_package_name,实时获取 display_id
|
||||
if self.config.target_package_name:
|
||||
self._ensure_connected()
|
||||
|
||||
timeout = self.config.wait_package_timeout
|
||||
interval = self.config.wait_package_interval
|
||||
if timeout == -1:
|
||||
timeout = float('inf')
|
||||
start_time = time.time()
|
||||
while True:
|
||||
display_id = self._ipc.get_display_id(
|
||||
self._connect_id,
|
||||
self.config.target_package_name,
|
||||
self.config.app_index
|
||||
)
|
||||
if display_id >= 0:
|
||||
return display_id
|
||||
elif display_id == -1:
|
||||
# 可以继续等
|
||||
pass
|
||||
else:
|
||||
# 未知错误
|
||||
raise NemuIpcError(f"Failed to get display_id for package '{self.config.target_package_name}', error code={display_id}")
|
||||
if time.time() - start_time > timeout:
|
||||
break
|
||||
sleep(interval)
|
||||
|
||||
raise NemuIpcError(f"Failed to get display_id for package '{self.config.target_package_name}' within {timeout}s")
|
||||
|
||||
# 如果都没有设置,抛出错误
|
||||
raise NemuIpcError("display_id is None and target_package_name is not set. Please set display_id or target_package_name in config.")
|
||||
|
||||
def connect(self) -> None:
|
||||
"""连接模拟器。"""
|
||||
if self.__connected:
|
||||
return
|
||||
|
||||
connect_id = self._ipc.connect(self.nemu_folder, self.config.instance_id)
|
||||
if connect_id == 0:
|
||||
raise NemuIpcError("nemu_connect failed, please check if the emulator is running and the instance ID is correct.")
|
||||
|
||||
self._connect_id = connect_id
|
||||
self.__connected = True
|
||||
logger.debug("NemuIpc connected, connect_id=%d", connect_id)
|
||||
|
||||
def disconnect(self) -> None:
|
||||
"""断开连接。"""
|
||||
if not self.__connected:
|
||||
return
|
||||
self._ipc.disconnect(self._connect_id)
|
||||
self.__connected = False
|
||||
self._connect_id = 0
|
||||
logger.debug("NemuIpc disconnected.")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Screenshotable 接口实现
|
||||
# ------------------------------------------------------------------
|
||||
@property
|
||||
def screen_size(self) -> tuple[int, int]:
|
||||
"""获取屏幕分辨率。"""
|
||||
if self.__width == 0 or self.__height == 0:
|
||||
self._refresh_resolution()
|
||||
if self.__width == 0 or self.__height == 0:
|
||||
raise NemuIpcError("Screen resolution not obtained, please connect to the emulator first.")
|
||||
return self.__width, self.__height
|
||||
|
||||
@override
|
||||
def detect_orientation(self):
|
||||
return self.get_display_orientation(self._get_display_id())
|
||||
|
||||
def get_display_orientation(self, display_id: int = 0) -> Literal['portrait', 'landscape'] | None:
|
||||
"""获取指定显示屏的方向。"""
|
||||
width, height = self.query_resolution(display_id)
|
||||
if width > height:
|
||||
return "landscape"
|
||||
if height > width:
|
||||
return "portrait"
|
||||
return None
|
||||
|
||||
@override
|
||||
def screenshot(self) -> MatLike:
|
||||
self._ensure_connected()
|
||||
|
||||
# 必须每次都更新分辨率,因为屏幕可能会旋转
|
||||
self._refresh_resolution()
|
||||
|
||||
length = self.__width * self.__height * 4 # RGBA
|
||||
buf_type = ctypes.c_ubyte * length
|
||||
buffer = buf_type()
|
||||
|
||||
w_ptr = ctypes.pointer(ctypes.c_int(self.__width))
|
||||
h_ptr = ctypes.pointer(ctypes.c_int(self.__height))
|
||||
|
||||
ret = self._ipc.capture_display(
|
||||
self._connect_id,
|
||||
self._get_display_id(),
|
||||
length,
|
||||
ctypes.cast(w_ptr, ctypes.c_void_p),
|
||||
ctypes.cast(h_ptr, ctypes.c_void_p),
|
||||
ctypes.cast(buffer, ctypes.c_void_p),
|
||||
)
|
||||
if ret != 0:
|
||||
raise NemuIpcError(f"nemu_capture_display screenshot failed, error code={ret}")
|
||||
|
||||
# 读入并转换数据
|
||||
img = np.ctypeslib.as_array(buffer).reshape((self.__height, self.__width, 4))
|
||||
# RGBA -> BGR
|
||||
img = cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
|
||||
cv2.flip(img, 0, dst=img)
|
||||
return img
|
||||
|
||||
# --------------------------- 内部工具 -----------------------------
|
||||
|
||||
def _refresh_resolution(self) -> None:
|
||||
"""刷新分辨率信息。"""
|
||||
display_id = self._get_display_id()
|
||||
self.__width, self.__height = self.query_resolution(display_id)
|
||||
|
||||
def query_resolution(self, display_id: int = 0) -> tuple[int, int]:
|
||||
"""
|
||||
查询指定显示屏的分辨率。
|
||||
|
||||
:param display_id: 显示屏 ID。
|
||||
:return: 分辨率 (width, height)。
|
||||
:raise NemuIpcError: 查询失败。
|
||||
"""
|
||||
self._ensure_connected()
|
||||
|
||||
w_ptr = ctypes.pointer(ctypes.c_int(0))
|
||||
h_ptr = ctypes.pointer(ctypes.c_int(0))
|
||||
ret = self._ipc.capture_display(
|
||||
self._connect_id,
|
||||
display_id,
|
||||
0,
|
||||
ctypes.cast(w_ptr, ctypes.c_void_p),
|
||||
ctypes.cast(h_ptr, ctypes.c_void_p),
|
||||
ctypes.c_void_p(),
|
||||
)
|
||||
if ret != 0:
|
||||
raise NemuIpcError(f"Call nemu_capture_display failed. Return value={ret}")
|
||||
|
||||
return w_ptr.contents.value, h_ptr.contents.value
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Touchable 接口实现
|
||||
# ------------------------------------------------------------------
|
||||
def __convert_pos(self, x: int, y: int) -> tuple[int, int]:
|
||||
# Android 显示屏有两套坐标:逻辑坐标与物理坐标。
|
||||
# 逻辑坐标原点始终是画面左上角,而物理坐标原点则始终是显示屏的左上角。
|
||||
# 如果屏幕画面旋转,会导致两个坐标的原点不同,坐标也不同。
|
||||
# ========
|
||||
# 这里传给 MuMu 的是逻辑坐标,ExternalRendererIpc DLL 内部会
|
||||
# 自动判断旋转,并转换为物理坐标。但是这部分有个 bug:
|
||||
# 旋转没有考虑到多显示器,只是以主显示器为准,若两个显示器旋转不一致,
|
||||
# 会导致错误地转换坐标。因此需要在 Python 层面 workaround 这个问题。
|
||||
# 通过判断主显示器与当前显示器的旋转,将坐标进行预转换,抵消 DLL 层的错误转换。
|
||||
display_id = self._get_display_id()
|
||||
if display_id == 0:
|
||||
return x, y
|
||||
else:
|
||||
primary = self.get_display_orientation(0)
|
||||
primary_size = self.query_resolution(0)
|
||||
current = self.get_display_orientation(display_id)
|
||||
if primary == current:
|
||||
return x, y
|
||||
else:
|
||||
# 如果旋转不一致,视为顺时针旋转了 90°
|
||||
# 因此我们要提前逆时针旋转 90°
|
||||
self._refresh_resolution()
|
||||
x, y = y, primary_size[1] - x
|
||||
return x, y
|
||||
|
||||
@override
|
||||
def click(self, x: int, y: int) -> None:
|
||||
self._ensure_connected()
|
||||
display_id = self._get_display_id()
|
||||
x, y = self.__convert_pos(x, y)
|
||||
self._ipc.input_touch_down(self._connect_id, display_id, x, y)
|
||||
sleep(0.01)
|
||||
self._ipc.input_touch_up(self._connect_id, display_id)
|
||||
|
||||
@override
|
||||
def swipe(
|
||||
self,
|
||||
x1: int,
|
||||
y1: int,
|
||||
x2: int,
|
||||
y2: int,
|
||||
duration: float | None = None,
|
||||
) -> None:
|
||||
self._ensure_connected()
|
||||
|
||||
duration = duration or 0.3
|
||||
steps = max(int(duration / 0.01), 2)
|
||||
display_id = self._get_display_id()
|
||||
x1, y1 = self.__convert_pos(x1, y1)
|
||||
x2, y2 = self.__convert_pos(x2, y2)
|
||||
|
||||
xs = np.linspace(x1, x2, steps, dtype=int)
|
||||
ys = np.linspace(y1, y2, steps, dtype=int)
|
||||
|
||||
# 按下第一点
|
||||
self._ipc.input_touch_down(self._connect_id, display_id, xs[0], ys[0])
|
||||
sleep(0.01)
|
||||
# 中间移动
|
||||
for px, py in zip(xs[1:-1], ys[1:-1]):
|
||||
self._ipc.input_touch_down(self._connect_id, display_id, px, py)
|
||||
sleep(0.01)
|
||||
|
||||
# 最终抬起
|
||||
self._ipc.input_touch_up(self._connect_id, display_id)
|
||||
sleep(0.01)
|
||||
|
||||
if __name__ == '__main__':
|
||||
nemu = NemuIpcImpl(NemuIpcImplConfig(
|
||||
r'F:\Apps\Netease\MuMuPlayer-12.0', 0, None,
|
||||
target_package_name='com.android.chrome',
|
||||
))
|
||||
nemu.connect()
|
||||
# while True:
|
||||
# nemu.click(0, 0)
|
||||
nemu.click(100, 100)
|
||||
nemu.click(100*3, 100)
|
||||
nemu.click(100*3, 100*3)
|
|
@ -14,6 +14,7 @@ import xmlrpc.server
|
|||
from typing import Literal, cast, Any, Tuple
|
||||
from functools import cached_property
|
||||
from threading import Thread
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
@ -22,10 +23,18 @@ from cv2.typing import MatLike
|
|||
from kotonebot import logging
|
||||
from ..device import Device, WindowsDevice
|
||||
from ..protocol import Touchable, Screenshotable
|
||||
from .windows import WindowsImpl
|
||||
from ..registration import ImplConfig
|
||||
from .windows import WindowsImpl, WindowsImplConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 定义配置模型
|
||||
@dataclass
|
||||
class RemoteWindowsImplConfig(ImplConfig):
|
||||
windows_impl_config: WindowsImplConfig
|
||||
host: str = "localhost"
|
||||
port: int = 8000
|
||||
|
||||
def _encode_image(image: MatLike) -> str:
|
||||
"""Encode an image as a base64 string."""
|
||||
success, buffer = cv2.imencode('.png', image)
|
||||
|
@ -48,13 +57,17 @@ class RemoteWindowsServer:
|
|||
This class wraps a WindowsImpl instance and exposes its methods via XML-RPC.
|
||||
"""
|
||||
|
||||
def __init__(self, host="localhost", port=8000):
|
||||
def __init__(self, windows_impl_config: WindowsImplConfig, host="localhost", port=8000):
|
||||
"""Initialize the server with the given host and port."""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.server = None
|
||||
self.device = WindowsDevice()
|
||||
self.impl = WindowsImpl(self.device)
|
||||
self.impl = WindowsImpl(
|
||||
WindowsDevice(),
|
||||
ahk_exe_path=windows_impl_config.ahk_exe_path,
|
||||
window_title=windows_impl_config.window_title
|
||||
)
|
||||
self.device._screenshot = self.impl
|
||||
self.device._touch = self.impl
|
||||
|
||||
|
@ -177,21 +190,4 @@ class RemoteWindowsImpl(Touchable, Screenshotable):
|
|||
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
|
||||
"""Swipe from (x1, y1) to (x2, y2) on the remote server."""
|
||||
if not self.proxy.swipe(x1, y1, x2, y2, duration):
|
||||
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Remote Windows XML-RPC Server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
|
||||
server = RemoteWindowsServer(args.host, args.port)
|
||||
try:
|
||||
server.start()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Server stopped by user")
|
||||
raise RuntimeError(f"Failed to swipe from ({x1}, {y1}) to ({x2}, {y2})")
|
|
@ -4,6 +4,7 @@ from typing import Literal
|
|||
import numpy as np
|
||||
import uiautomator2 as u2
|
||||
from cv2.typing import MatLike
|
||||
from adbutils._device import AdbDevice as AdbUtilsDevice
|
||||
|
||||
from kotonebot import logging
|
||||
from ..device import Device
|
||||
|
@ -14,9 +15,8 @@ logger = logging.getLogger(__name__)
|
|||
SCREENSHOT_INTERVAL = 0.2
|
||||
|
||||
class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
|
||||
def __init__(self, device: Device):
|
||||
self.device = device
|
||||
self.u2_client = u2.Device(device.adb.serial)
|
||||
def __init__(self, adb_connection: AdbUtilsDevice):
|
||||
self.u2_client = u2.Device(adb_connection.serial)
|
||||
self.__last_screenshot_time = 0
|
||||
|
||||
def screenshot(self) -> MatLike:
|
||||
|
@ -38,10 +38,7 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
|
|||
def screen_size(self) -> tuple[int, int]:
|
||||
info = self.u2_client.info
|
||||
sizes = info['displayWidth'], info['displayHeight']
|
||||
if self.device.orientation == 'landscape':
|
||||
return (max(sizes), min(sizes))
|
||||
else:
|
||||
return (min(sizes), max(sizes))
|
||||
return sizes
|
||||
|
||||
def detect_orientation(self) -> Literal['portrait', 'landscape'] | None:
|
||||
"""
|
||||
|
@ -82,4 +79,4 @@ class UiAutomator2Impl(Screenshotable, Commandable, Touchable):
|
|||
"""
|
||||
滑动屏幕
|
||||
"""
|
||||
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)
|
||||
self.u2_client.swipe(x1, y1, x2, y2, duration=duration or 0.1)
|
|
@ -2,6 +2,7 @@ from ctypes import windll
|
|||
from typing import Literal
|
||||
from importlib import resources
|
||||
from functools import cached_property
|
||||
from dataclasses import dataclass
|
||||
|
||||
import cv2
|
||||
import win32ui
|
||||
|
@ -10,14 +11,21 @@ import numpy as np
|
|||
from ahk import AHK, MsgBoxIcon
|
||||
from cv2.typing import MatLike
|
||||
|
||||
from ..device import Device
|
||||
from ..device import Device, WindowsDevice
|
||||
from ..protocol import Commandable, Touchable, Screenshotable
|
||||
from ..registration import ImplConfig
|
||||
|
||||
# 1. 定义配置模型
|
||||
@dataclass
|
||||
class WindowsImplConfig(ImplConfig):
|
||||
window_title: str
|
||||
ahk_exe_path: str
|
||||
|
||||
class WindowsImpl(Touchable, Screenshotable):
|
||||
def __init__(self, device: Device):
|
||||
def __init__(self, device: Device, window_title: str, ahk_exe_path: str):
|
||||
self.__hwnd: int | None = None
|
||||
# TODO: 硬编码路径
|
||||
self.ahk = AHK(executable_path=str(resources.files('kaa.res.bin') / 'AutoHotkey.exe'))
|
||||
self.window_title = window_title
|
||||
self.ahk = AHK(executable_path=ahk_exe_path)
|
||||
self.device = device
|
||||
|
||||
# 设置 DPI aware,否则高缩放显示器上返回的坐标会错误
|
||||
|
@ -43,21 +51,12 @@ class WindowsImpl(Touchable, Screenshotable):
|
|||
# 将点击坐标设置为相对 Client
|
||||
self.ahk.set_coord_mode('Mouse', 'Client')
|
||||
|
||||
@cached_property
|
||||
def scale_ratio(self) -> float:
|
||||
"""
|
||||
缩放比例。截图与模拟输入前都会根据这个比例缩放。
|
||||
"""
|
||||
left, _, right, _ = self.__client_rect()
|
||||
w = right - left
|
||||
return 720 / w
|
||||
|
||||
@property
|
||||
def hwnd(self) -> int:
|
||||
if self.__hwnd is None:
|
||||
self.__hwnd = win32gui.FindWindow(None, 'gakumas')
|
||||
self.__hwnd = win32gui.FindWindow(None, self.window_title)
|
||||
if self.__hwnd is None or self.__hwnd == 0:
|
||||
raise RuntimeError('Failed to find window')
|
||||
raise RuntimeError(f'Failed to find window: {self.window_title}')
|
||||
return self.__hwnd
|
||||
|
||||
def __client_rect(self) -> tuple[int, int, int, int]:
|
||||
|
@ -73,8 +72,8 @@ class WindowsImpl(Touchable, Screenshotable):
|
|||
return win32gui.ClientToScreen(hwnd, (x, y))
|
||||
|
||||
def screenshot(self) -> MatLike:
|
||||
if not self.ahk.win_is_active('gakumas'):
|
||||
self.ahk.win_activate('gakumas')
|
||||
if not self.ahk.win_is_active(self.window_title):
|
||||
self.ahk.win_activate(self.window_title)
|
||||
hwnd = self.hwnd
|
||||
|
||||
# TODO: 需要检查下面这些 WinAPI 的返回结果
|
||||
|
@ -116,21 +115,17 @@ class WindowsImpl(Touchable, Screenshotable):
|
|||
|
||||
# 将 RGBA 转换为 RGB
|
||||
cropped_im = cv2.cvtColor(cropped_im, cv2.COLOR_RGBA2RGB)
|
||||
# 缩放
|
||||
cropped_im = cv2.resize(cropped_im, None, fx=self.scale_ratio, fy=self.scale_ratio)
|
||||
return cropped_im
|
||||
|
||||
@property
|
||||
def screen_size(self) -> tuple[int, int]:
|
||||
# 因为截图和点击的坐标都被缩放了,
|
||||
# 因此这里只要返回固定值即可
|
||||
if self.device.orientation == 'landscape':
|
||||
return 1280, 720
|
||||
else:
|
||||
return 720, 1280
|
||||
left, top, right, bot = self.__client_rect()
|
||||
w = right - left
|
||||
h = bot - top
|
||||
return w, h
|
||||
|
||||
def detect_orientation(self) -> None | Literal['portrait'] | Literal['landscape']:
|
||||
pos = self.ahk.win_get_position('gakumas')
|
||||
pos = self.ahk.win_get_position(self.window_title)
|
||||
if pos is None:
|
||||
return None
|
||||
w, h = pos.width, pos.height
|
||||
|
@ -146,25 +141,22 @@ class WindowsImpl(Touchable, Screenshotable):
|
|||
x = 2
|
||||
if y == 0:
|
||||
y = 2
|
||||
x, y = int(x / self.scale_ratio), int(y / self.scale_ratio)
|
||||
if not self.ahk.win_is_active('gakumas'):
|
||||
self.ahk.win_activate('gakumas')
|
||||
if not self.ahk.win_is_active(self.window_title):
|
||||
self.ahk.win_activate(self.window_title)
|
||||
self.ahk.click(x, y)
|
||||
|
||||
def swipe(self, x1: int, y1: int, x2: int, y2: int, duration: float | None = None) -> None:
|
||||
if not self.ahk.win_is_active('gakumas'):
|
||||
self.ahk.win_activate('gakumas')
|
||||
x1, y1 = int(x1 / self.scale_ratio), int(y1 / self.scale_ratio)
|
||||
x2, y2 = int(x2 / self.scale_ratio), int(y2 / self.scale_ratio)
|
||||
if not self.ahk.win_is_active(self.window_title):
|
||||
self.ahk.win_activate(self.window_title)
|
||||
# TODO: 这个 speed 的单位是什么?
|
||||
self.ahk.mouse_drag(x2, y2, from_position=(x1, y1), coord_mode='Client', speed=10)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from ..device import Device
|
||||
from time import sleep
|
||||
device = Device()
|
||||
impl = WindowsImpl(device)
|
||||
# 在测试环境中直接使用默认路径
|
||||
ahk_path = str(resources.files('kaa.res.bin') / 'AutoHotkey.exe')
|
||||
impl = WindowsImpl(device, window_title='gakumas', ahk_exe_path=ahk_path)
|
||||
device._screenshot = impl
|
||||
device._touch = impl
|
||||
device.swipe_scaled(0.5, 0.8, 0.5, 0.2)
|
||||
|
|
|
@ -28,6 +28,19 @@ class Commandable(Protocol):
|
|||
def launch_app(self, package_name: str) -> None: ...
|
||||
def current_package(self) -> str | None: ...
|
||||
|
||||
@runtime_checkable
|
||||
class AndroidCommandable(Protocol):
|
||||
"""定义 Android 平台的特定命令"""
|
||||
def launch_app(self, package_name: str) -> None: ...
|
||||
def current_package(self) -> str | None: ...
|
||||
def adb_shell(self, cmd: str) -> str: ...
|
||||
|
||||
@runtime_checkable
|
||||
class WindowsCommandable(Protocol):
|
||||
"""定义 Windows 平台的特定命令"""
|
||||
def get_foreground_window(self) -> tuple[int, str]: ...
|
||||
def exec_command(self, command: str) -> tuple[int, str, str]: ...
|
||||
|
||||
@runtime_checkable
|
||||
class Screenshotable(Protocol):
|
||||
def __init__(self, device: 'Device'): ...
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
from dataclasses import dataclass
|
||||
from typing import TypeVar, Callable, Dict, Type, Any, overload, Literal, cast, TYPE_CHECKING
|
||||
|
||||
from ..errors import KotonebotError
|
||||
from .device import Device
|
||||
if TYPE_CHECKING:
|
||||
from .implements.adb import AdbImplConfig
|
||||
from .implements.remote_windows import RemoteWindowsImplConfig
|
||||
from .implements.windows import WindowsImplConfig
|
||||
from .implements.nemu_ipc import NemuIpcImplConfig
|
||||
|
||||
AdbBasedImpl = Literal['adb', 'adb_raw', 'uiautomator2']
|
||||
DeviceImpl = str | AdbBasedImpl | Literal['windows', 'remote_windows', 'nemu_ipc']
|
||||
|
||||
# --- 核心类型定义 ---
|
||||
|
||||
class ImplRegistrationError(KotonebotError):
|
||||
"""与 impl 注册相关的错误"""
|
||||
pass
|
||||
|
||||
@dataclass
|
||||
class ImplConfig:
|
||||
"""所有设备实现配置模型的名义上的基类,便于类型约束。"""
|
||||
pass
|
|
@ -3,10 +3,10 @@ from typing import Generic, TypeVar, Literal
|
|||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
from kotonebot.client.factory import DeviceImpl
|
||||
|
||||
T = TypeVar('T')
|
||||
BackendType = Literal['custom', 'mumu12', 'leidian', 'dmm']
|
||||
DeviceRecipes = Literal['adb', 'adb_raw', 'uiautomator2', 'windows', 'remote_windows', 'nemu_ipc']
|
||||
|
||||
class ConfigBaseModel(BaseModel):
|
||||
model_config = ConfigDict(use_attribute_docstrings=True)
|
||||
|
@ -27,7 +27,7 @@ class BackendConfig(ConfigBaseModel):
|
|||
雷电模拟器需要设置正确的模拟器名,否则 自动启动模拟器 功能将无法正常工作。
|
||||
其他功能不受影响。
|
||||
"""
|
||||
screenshot_impl: DeviceImpl = 'adb'
|
||||
screenshot_impl: DeviceRecipes = 'adb'
|
||||
"""
|
||||
截图方法。暂时推荐使用【adb】截图方式。
|
||||
|
||||
|
@ -44,6 +44,14 @@ class BackendConfig(ConfigBaseModel):
|
|||
"""模拟器 exe 文件路径"""
|
||||
emulator_args: str = ""
|
||||
"""模拟器启动时的命令行参数"""
|
||||
windows_window_title: str = 'gakumas'
|
||||
"""Windows 截图方式的窗口标题"""
|
||||
windows_ahk_path: str | None = None
|
||||
"""Windows 截图方式的 AutoHotkey 可执行文件路径,为 None 时使用默认路径"""
|
||||
mumu_background_mode: bool = False
|
||||
"""MuMu12 模拟器后台保活模式"""
|
||||
target_screenshot_interval: float | None = None
|
||||
"""最小截图间隔,单位为秒。为 None 时不限制截图速度。"""
|
||||
|
||||
class PushConfig(ConfigBaseModel):
|
||||
"""推送配置。"""
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import sys
|
||||
sys.path.append('./projects')
|
||||
import runpy
|
||||
import logging
|
||||
import argparse
|
||||
|
@ -12,15 +14,20 @@ def run_script(script_path: str) -> None:
|
|||
Args:
|
||||
script_path: Python 脚本的路径
|
||||
"""
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
# 获取模块名
|
||||
module_name = script_path.strip('.py').replace('\\', '/').strip('/').replace('/', '.')
|
||||
module_name = script_path.strip('.py').lstrip('projects/').replace('\\', '/').strip('/').replace('/', '.')
|
||||
|
||||
print(f"正在运行脚本: {script_path}")
|
||||
# 运行脚本
|
||||
from kotonebot.backend.context import init_context
|
||||
logging.basicConfig(level=logging.INFO, format='[%(asctime)s] [%(levelname)s] [%(name)s] [%(funcName)s] [%(lineno)d] %(message)s')
|
||||
from kotonebot.backend.context import init_context, manual_context
|
||||
from kotonebot.kaa.main.kaa import Kaa
|
||||
logging.getLogger('kotonebot').setLevel(logging.DEBUG)
|
||||
init_context(config_type=BaseConfig)
|
||||
config_path = './config.json'
|
||||
kaa_instance = Kaa(config_path)
|
||||
init_context(config_type=BaseConfig, target_device=kaa_instance._on_create_device())
|
||||
kaa_instance._on_after_init_context()
|
||||
manual_context().begin()
|
||||
runpy.run_module(module_name, run_name="__main__")
|
||||
|
||||
def main():
|
||||
|
|
|
@ -24,3 +24,14 @@ class TaskNotFoundError(KotonebotError):
|
|||
def __init__(self, task_id: str):
|
||||
self.task_id = task_id
|
||||
super().__init__(f'Task "{task_id}" not found.')
|
||||
|
||||
class UnscalableResolutionError(KotonebotError):
|
||||
def __init__(self, target_resolution: tuple[int, int], screen_size: tuple[int, int]):
|
||||
self.target_resolution = target_resolution
|
||||
self.screen_size = screen_size
|
||||
super().__init__(f'Cannot scale to target resolution {target_resolution}. '
|
||||
f'Screen size: {screen_size}')
|
||||
|
||||
class ContextNotInitializedError(KotonebotError):
|
||||
def __init__(self, msg: str = 'Context not initialized'):
|
||||
super().__init__(msg)
|
|
@ -11,960 +11,12 @@ from pydantic import BaseModel, ConfigDict
|
|||
# TODO: from kotonebot import config (context) 会和 kotonebot.config 冲突
|
||||
from kotonebot import logging
|
||||
from kotonebot.backend.context import config
|
||||
from kotonebot.kaa.config.schema import BaseConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar('T')
|
||||
class ConfigEnum(Enum):
|
||||
def display(self) -> str:
|
||||
return self.value[1]
|
||||
|
||||
class Priority(IntEnum):
|
||||
"""
|
||||
任务优先级。数字越大,优先级越高,越先执行。
|
||||
"""
|
||||
START_GAME = 1
|
||||
DEFAULT = 0
|
||||
CLAIM_MISSION_REWARD = -1
|
||||
END_GAME = -2
|
||||
|
||||
class APShopItems(IntEnum):
|
||||
PRODUCE_PT_UP = 0
|
||||
"""获取支援强化 Pt 提升"""
|
||||
PRODUCE_NOTE_UP = 1
|
||||
"""获取笔记数提升"""
|
||||
RECHALLENGE = 2
|
||||
"""再挑战券"""
|
||||
REGENERATE_MEMORY = 3
|
||||
"""回忆再生成券"""
|
||||
|
||||
class DailyMoneyShopItems(IntEnum):
|
||||
"""日常商店物品"""
|
||||
Recommendations = -1
|
||||
"""所有推荐商品"""
|
||||
LessonNote = 0
|
||||
"""レッスンノート"""
|
||||
VeteranNote = 1
|
||||
"""ベテランノート"""
|
||||
SupportEnhancementPt = 2
|
||||
"""サポート強化Pt 支援强化Pt"""
|
||||
SenseNoteVocal = 3
|
||||
"""センスノート(ボーカル)感性笔记(声乐)"""
|
||||
SenseNoteDance = 4
|
||||
"""センスノート(ダンス)感性笔记(舞蹈)"""
|
||||
SenseNoteVisual = 5
|
||||
"""センスノート(ビジュアル)感性笔记(形象)"""
|
||||
LogicNoteVocal = 6
|
||||
"""ロジックノート(ボーカル)理性笔记(声乐)"""
|
||||
LogicNoteDance = 7
|
||||
"""ロジックノート(ダンス)理性笔记(舞蹈)"""
|
||||
LogicNoteVisual = 8
|
||||
"""ロジックノート(ビジュアル)理性笔记(形象)"""
|
||||
AnomalyNoteVocal = 9
|
||||
"""アノマリーノート(ボーカル)非凡笔记(声乐)"""
|
||||
AnomalyNoteDance = 10
|
||||
"""アノマリーノート(ダンス)非凡笔记(舞蹈)"""
|
||||
AnomalyNoteVisual = 11
|
||||
"""アノマリーノート(ビジュアル)非凡笔记(形象)"""
|
||||
RechallengeTicket = 12
|
||||
"""再挑戦チケット 重新挑战券"""
|
||||
RecordKey = 13
|
||||
"""記録の鍵 解锁交流的物品"""
|
||||
|
||||
# 碎片
|
||||
IdolPiece_倉本千奈_WonderScale = 14
|
||||
"""倉本千奈 WonderScale 碎片"""
|
||||
IdolPiece_篠泽广_光景 = 15
|
||||
"""篠泽广 光景 碎片"""
|
||||
IdolPiece_紫云清夏_TameLieOneStep = 16
|
||||
"""紫云清夏 Tame-Lie-One-Step 碎片"""
|
||||
IdolPiece_葛城リーリヤ_白線 = 17
|
||||
"""葛城リーリヤ 白線 碎片"""
|
||||
IdolPiece_姫崎薪波_cIclumsy_trick = 18
|
||||
"""姫崎薪波 cIclumsy trick 碎片"""
|
||||
IdolPiece_花海咲季_FightingMyWay = 19
|
||||
"""花海咲季 FightingMyWay 碎片"""
|
||||
IdolPiece_藤田ことね_世界一可愛い私 = 20
|
||||
"""藤田ことね 世界一可愛い私 碎片"""
|
||||
IdolPiece_花海佑芽_TheRollingRiceball = 21
|
||||
"""花海佑芽 The Rolling Riceball 碎片"""
|
||||
IdolPiece_月村手毬_LunaSayMaybe = 22
|
||||
"""月村手毬 Luna say maybe 碎片"""
|
||||
|
||||
@classmethod
|
||||
def to_ui_text(cls, item: "DailyMoneyShopItems") -> str:
|
||||
"""获取枚举值对应的UI显示文本"""
|
||||
match item:
|
||||
case cls.Recommendations:
|
||||
return "所有推荐商品"
|
||||
case cls.LessonNote:
|
||||
return "课程笔记"
|
||||
case cls.VeteranNote:
|
||||
return "老手笔记"
|
||||
case cls.SupportEnhancementPt:
|
||||
return "支援强化点数"
|
||||
case cls.SenseNoteVocal:
|
||||
return "感性笔记(声乐)"
|
||||
case cls.SenseNoteDance:
|
||||
return "感性笔记(舞蹈)"
|
||||
case cls.SenseNoteVisual:
|
||||
return "感性笔记(形象)"
|
||||
case cls.LogicNoteVocal:
|
||||
return "理性笔记(声乐)"
|
||||
case cls.LogicNoteDance:
|
||||
return "理性笔记(舞蹈)"
|
||||
case cls.LogicNoteVisual:
|
||||
return "理性笔记(形象)"
|
||||
case cls.AnomalyNoteVocal:
|
||||
return "非凡笔记(声乐)"
|
||||
case cls.AnomalyNoteDance:
|
||||
return "非凡笔记(舞蹈)"
|
||||
case cls.AnomalyNoteVisual:
|
||||
return "非凡笔记(形象)"
|
||||
case cls.RechallengeTicket:
|
||||
return "重新挑战券"
|
||||
case cls.RecordKey:
|
||||
return "记录钥匙"
|
||||
case cls.IdolPiece_倉本千奈_WonderScale:
|
||||
return "倉本千奈 WonderScale 碎片"
|
||||
case cls.IdolPiece_篠泽广_光景:
|
||||
return "篠泽广 光景 碎片"
|
||||
case cls.IdolPiece_紫云清夏_TameLieOneStep:
|
||||
return "紫云清夏 Tame-Lie-One-Step 碎片"
|
||||
case cls.IdolPiece_葛城リーリヤ_白線:
|
||||
return "葛城リーリヤ 白線 碎片"
|
||||
case cls.IdolPiece_姫崎薪波_cIclumsy_trick:
|
||||
return "姫崎薪波 cIclumsy trick 碎片"
|
||||
case cls.IdolPiece_花海咲季_FightingMyWay:
|
||||
return "花海咲季 FightingMyWay 碎片"
|
||||
case cls.IdolPiece_藤田ことね_世界一可愛い私:
|
||||
return "藤田ことね 世界一可愛い私 碎片"
|
||||
case cls.IdolPiece_花海佑芽_TheRollingRiceball:
|
||||
return "花海佑芽 The Rolling Riceball 碎片"
|
||||
case cls.IdolPiece_月村手毬_LunaSayMaybe:
|
||||
return "月村手毬 Luna say maybe 碎片"
|
||||
case _:
|
||||
assert_never(item)
|
||||
|
||||
@classmethod
|
||||
def all(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
|
||||
"""获取所有枚举值及其对应的UI显示文本"""
|
||||
return [(cls.to_ui_text(item), item) for item in cls]
|
||||
|
||||
@classmethod
|
||||
def _is_note(cls, item: 'DailyMoneyShopItems') -> bool:
|
||||
"""判断是否为笔记"""
|
||||
return 'Note' in item.name and not item.name.startswith('Note') and not item.name.endswith('Note')
|
||||
|
||||
@classmethod
|
||||
def note_items(cls) -> list[tuple[str, 'DailyMoneyShopItems']]:
|
||||
"""获取所有枚举值及其对应的UI显示文本"""
|
||||
return [(cls.to_ui_text(item), item) for item in cls if cls._is_note(item)]
|
||||
|
||||
def to_resource(self):
|
||||
from kotonebot.kaa.tasks import R
|
||||
match self:
|
||||
case DailyMoneyShopItems.Recommendations:
|
||||
return R.Daily.TextShopRecommended
|
||||
case DailyMoneyShopItems.LessonNote:
|
||||
return R.Shop.ItemLessonNote
|
||||
case DailyMoneyShopItems.VeteranNote:
|
||||
return R.Shop.ItemVeteranNote
|
||||
case DailyMoneyShopItems.SupportEnhancementPt:
|
||||
return R.Shop.ItemSupportEnhancementPt
|
||||
case DailyMoneyShopItems.SenseNoteVocal:
|
||||
return R.Shop.ItemSenseNoteVocal
|
||||
case DailyMoneyShopItems.SenseNoteDance:
|
||||
return R.Shop.ItemSenseNoteDance
|
||||
case DailyMoneyShopItems.SenseNoteVisual:
|
||||
return R.Shop.ItemSenseNoteVisual
|
||||
case DailyMoneyShopItems.LogicNoteVocal:
|
||||
return R.Shop.ItemLogicNoteVocal
|
||||
case DailyMoneyShopItems.LogicNoteDance:
|
||||
return R.Shop.ItemLogicNoteDance
|
||||
case DailyMoneyShopItems.LogicNoteVisual:
|
||||
return R.Shop.ItemLogicNoteVisual
|
||||
case DailyMoneyShopItems.AnomalyNoteVocal:
|
||||
return R.Shop.ItemAnomalyNoteVocal
|
||||
case DailyMoneyShopItems.AnomalyNoteDance:
|
||||
return R.Shop.ItemAnomalyNoteDance
|
||||
case DailyMoneyShopItems.AnomalyNoteVisual:
|
||||
return R.Shop.ItemAnomalyNoteVisual
|
||||
case DailyMoneyShopItems.RechallengeTicket:
|
||||
return R.Shop.ItemRechallengeTicket
|
||||
case DailyMoneyShopItems.RecordKey:
|
||||
return R.Shop.ItemRecordKey
|
||||
case DailyMoneyShopItems.IdolPiece_倉本千奈_WonderScale:
|
||||
return R.Shop.IdolPiece.倉本千奈_WonderScale
|
||||
case DailyMoneyShopItems.IdolPiece_篠泽广_光景:
|
||||
return R.Shop.IdolPiece.篠泽广_光景
|
||||
case DailyMoneyShopItems.IdolPiece_紫云清夏_TameLieOneStep:
|
||||
return R.Shop.IdolPiece.紫云清夏_TameLieOneStep
|
||||
case DailyMoneyShopItems.IdolPiece_葛城リーリヤ_白線:
|
||||
return R.Shop.IdolPiece.葛城リーリヤ_白線
|
||||
case DailyMoneyShopItems.IdolPiece_姫崎薪波_cIclumsy_trick:
|
||||
return R.Shop.IdolPiece.姫崎薪波_cIclumsy_trick
|
||||
case DailyMoneyShopItems.IdolPiece_花海咲季_FightingMyWay:
|
||||
return R.Shop.IdolPiece.花海咲季_FightingMyWay
|
||||
case DailyMoneyShopItems.IdolPiece_藤田ことね_世界一可愛い私:
|
||||
return R.Shop.IdolPiece.藤田ことね_世界一可愛い私
|
||||
case DailyMoneyShopItems.IdolPiece_花海佑芽_TheRollingRiceball:
|
||||
return R.Shop.IdolPiece.花海佑芽_TheRollingRiceball
|
||||
case DailyMoneyShopItems.IdolPiece_月村手毬_LunaSayMaybe:
|
||||
return R.Shop.IdolPiece.月村手毬_LunaSayMaybe
|
||||
case _:
|
||||
assert_never(self)
|
||||
|
||||
class ConfigBaseModel(BaseModel):
|
||||
model_config = ConfigDict(use_attribute_docstrings=True)
|
||||
|
||||
class PurchaseConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用商店购买"""
|
||||
money_enabled: bool = False
|
||||
"""是否启用金币购买"""
|
||||
money_items: list[DailyMoneyShopItems] = []
|
||||
"""金币商店要购买的物品"""
|
||||
money_refresh_on: Literal['never', 'not_found', 'always'] = 'never'
|
||||
"""
|
||||
金币商店刷新逻辑。
|
||||
|
||||
* never: 从不刷新。
|
||||
* not_found: 仅当要购买的物品不存在时刷新。
|
||||
* always: 总是刷新。
|
||||
"""
|
||||
ap_enabled: bool = False
|
||||
"""是否启用AP购买"""
|
||||
ap_items: Sequence[Literal[0, 1, 2, 3]] = []
|
||||
"""AP商店要购买的物品"""
|
||||
|
||||
|
||||
class ActivityFundsConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用收取活动费"""
|
||||
|
||||
|
||||
class PresentsConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用收取礼物"""
|
||||
|
||||
|
||||
class AssignmentConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用工作"""
|
||||
|
||||
mini_live_reassign_enabled: bool = False
|
||||
"""是否启用重新分配 MiniLive"""
|
||||
mini_live_duration: Literal[4, 6, 12] = 12
|
||||
"""MiniLive 工作时长"""
|
||||
|
||||
online_live_reassign_enabled: bool = False
|
||||
"""是否启用重新分配 OnlineLive"""
|
||||
online_live_duration: Literal[4, 6, 12] = 12
|
||||
"""OnlineLive 工作时长"""
|
||||
|
||||
|
||||
class ContestConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用竞赛"""
|
||||
|
||||
select_which_contestant: Literal[1, 2, 3] = 1
|
||||
"""选择第几个挑战者"""
|
||||
|
||||
class ProduceAction(Enum):
|
||||
RECOMMENDED = 'recommended'
|
||||
VISUAL = 'visual'
|
||||
VOCAL = 'vocal'
|
||||
DANCE = 'dance'
|
||||
# VISUAL_SP = 'visual_sp'
|
||||
# VOCAL_SP = 'vocal_sp'
|
||||
# DANCE_SP = 'dance_sp'
|
||||
OUTING = 'outing'
|
||||
STUDY = 'study'
|
||||
ALLOWANCE = 'allowance'
|
||||
REST = 'rest'
|
||||
CONSULT = 'consult'
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
MAP = {
|
||||
ProduceAction.RECOMMENDED: '推荐行动',
|
||||
ProduceAction.VISUAL: '形象课程',
|
||||
ProduceAction.VOCAL: '声乐课程',
|
||||
ProduceAction.DANCE: '舞蹈课程',
|
||||
ProduceAction.OUTING: '外出(おでかけ)',
|
||||
ProduceAction.STUDY: '文化课(授業)',
|
||||
ProduceAction.ALLOWANCE: '活动支给(活動支給)',
|
||||
ProduceAction.REST: '休息',
|
||||
ProduceAction.CONSULT: '咨询(相談)',
|
||||
}
|
||||
return MAP[self]
|
||||
|
||||
class RecommendCardDetectionMode(Enum):
|
||||
NORMAL = 'normal'
|
||||
STRICT = 'strict'
|
||||
|
||||
@property
|
||||
def display_name(self):
|
||||
MAP = {
|
||||
RecommendCardDetectionMode.NORMAL: '正常模式',
|
||||
RecommendCardDetectionMode.STRICT: '严格模式',
|
||||
}
|
||||
return MAP[self]
|
||||
|
||||
class ProduceConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用培育"""
|
||||
mode: Literal['regular', 'pro', 'master'] = 'regular'
|
||||
"""
|
||||
培育模式。
|
||||
进行一次 REGULAR 培育需要 ~30min,进行一次 PRO 培育需要 ~1h(具体视设备性能而定)。
|
||||
"""
|
||||
produce_count: int = 1
|
||||
"""培育的次数。"""
|
||||
idols: list[str] = []
|
||||
"""
|
||||
要培育偶像的 IdolCardSkin.id。将会按顺序循环选择培育。
|
||||
"""
|
||||
memory_sets: list[int] = []
|
||||
"""要使用的回忆编成编号,从 1 开始。将会按顺序循环选择使用。"""
|
||||
support_card_sets: list[int] = []
|
||||
"""要使用的支援卡编成编号,从 1 开始。将会按顺序循环选择使用。"""
|
||||
auto_set_memory: bool = False
|
||||
"""是否自动编成回忆。此选项优先级高于回忆编成编号。"""
|
||||
auto_set_support_card: bool = False
|
||||
"""是否自动编成支援卡。此选项优先级高于支援卡编成编号。"""
|
||||
use_pt_boost: bool = False
|
||||
"""是否使用支援强化 Pt 提升。"""
|
||||
use_note_boost: bool = False
|
||||
"""是否使用笔记数提升。"""
|
||||
follow_producer: bool = False
|
||||
"""是否关注租借了支援卡的制作人。"""
|
||||
self_study_lesson: Literal['dance', 'visual', 'vocal'] = 'dance'
|
||||
"""自习课类型。"""
|
||||
prefer_lesson_ap: bool = False
|
||||
"""
|
||||
优先 SP 课程。
|
||||
|
||||
启用后,若出现 SP 课程,则会优先执行 SP 课程,而不是推荐课程。
|
||||
若出现多个 SP 课程,随机选择一个。
|
||||
"""
|
||||
actions_order: list[ProduceAction] = [
|
||||
ProduceAction.RECOMMENDED,
|
||||
ProduceAction.VISUAL,
|
||||
ProduceAction.VOCAL,
|
||||
ProduceAction.DANCE,
|
||||
ProduceAction.ALLOWANCE,
|
||||
ProduceAction.OUTING,
|
||||
ProduceAction.STUDY,
|
||||
ProduceAction.CONSULT,
|
||||
ProduceAction.REST,
|
||||
]
|
||||
"""
|
||||
行动优先级
|
||||
|
||||
每一周的行动将会按这里设置的优先级执行。
|
||||
"""
|
||||
recommend_card_detection_mode: RecommendCardDetectionMode = RecommendCardDetectionMode.NORMAL
|
||||
"""
|
||||
推荐卡检测模式
|
||||
|
||||
严格模式下,识别速度会降低,但识别准确率会提高。
|
||||
"""
|
||||
use_ap_drink: bool = False
|
||||
"""
|
||||
AP 不足时自动使用 AP 饮料
|
||||
"""
|
||||
skip_commu: bool = True
|
||||
"""检测并跳过交流"""
|
||||
|
||||
class MissionRewardConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用领取任务奖励"""
|
||||
|
||||
class ClubRewardConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用领取社团奖励"""
|
||||
|
||||
selected_note: DailyMoneyShopItems = DailyMoneyShopItems.AnomalyNoteVisual
|
||||
"""想在社团奖励中获取到的笔记"""
|
||||
|
||||
class UpgradeSupportCardConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用支援卡升级"""
|
||||
|
||||
class CapsuleToysConfig(ConfigBaseModel):
|
||||
enabled: bool = False
|
||||
"""是否启用扭蛋机"""
|
||||
|
||||
friend_capsule_toys_count: int = 0
|
||||
"""好友扭蛋机次数"""
|
||||
|
||||
sense_capsule_toys_count: int = 0
|
||||
"""感性扭蛋机次数"""
|
||||
|
||||
logic_capsule_toys_count: int = 0
|
||||
"""理性扭蛋机次数"""
|
||||
|
||||
anomaly_capsule_toys_count: int = 0
|
||||
"""非凡扭蛋机次数"""
|
||||
|
||||
class TraceConfig(ConfigBaseModel):
|
||||
recommend_card_detection: bool = False
|
||||
"""跟踪推荐卡检测"""
|
||||
|
||||
class StartGameConfig(ConfigBaseModel):
|
||||
enabled: bool = True
|
||||
"""是否启用自动启动游戏。默认为True"""
|
||||
|
||||
start_through_kuyo: bool = False
|
||||
"""是否通过Kuyo来启动游戏"""
|
||||
|
||||
game_package_name: str = 'com.bandainamcoent.idolmaster_gakuen'
|
||||
"""游戏包名"""
|
||||
|
||||
kuyo_package_name: str = 'org.kuyo.game'
|
||||
"""Kuyo包名"""
|
||||
|
||||
disable_gakumas_localify: bool = False
|
||||
"""
|
||||
自动检测并禁用 Gakumas Localify 汉化插件。
|
||||
|
||||
(目前仅对 DMM 版有效。)
|
||||
"""
|
||||
|
||||
dmm_game_path: str | None = None
|
||||
"""
|
||||
DMM 版游戏路径。若不填写,会自动检测。
|
||||
|
||||
例:`F:\\Games\\gakumas\\gakumas.exe`
|
||||
"""
|
||||
|
||||
class EndGameConfig(ConfigBaseModel):
|
||||
exit_kaa: bool = False
|
||||
"""退出 kaa"""
|
||||
kill_game: bool = False
|
||||
"""关闭游戏"""
|
||||
kill_dmm: bool = False
|
||||
"""关闭 DMMGamePlayer"""
|
||||
kill_emulator: bool = False
|
||||
"""关闭模拟器"""
|
||||
shutdown: bool = False
|
||||
"""关闭系统"""
|
||||
hibernate: bool = False
|
||||
"""休眠系统"""
|
||||
restore_gakumas_localify: bool = False
|
||||
"""
|
||||
恢复 Gakumas Localify 汉化插件状态至启动前。通常与
|
||||
`disable_gakumas_localify` 配对使用。
|
||||
|
||||
(目前仅对 DMM 版有效。)
|
||||
"""
|
||||
|
||||
class BaseConfig(ConfigBaseModel):
|
||||
purchase: PurchaseConfig = PurchaseConfig()
|
||||
"""商店购买配置"""
|
||||
|
||||
activity_funds: ActivityFundsConfig = ActivityFundsConfig()
|
||||
"""活动费配置"""
|
||||
|
||||
presents: PresentsConfig = PresentsConfig()
|
||||
"""收取礼物配置"""
|
||||
|
||||
assignment: AssignmentConfig = AssignmentConfig()
|
||||
"""工作配置"""
|
||||
|
||||
contest: ContestConfig = ContestConfig()
|
||||
"""竞赛配置"""
|
||||
|
||||
produce: ProduceConfig = ProduceConfig()
|
||||
"""培育配置"""
|
||||
|
||||
mission_reward: MissionRewardConfig = MissionRewardConfig()
|
||||
"""领取任务奖励配置"""
|
||||
|
||||
club_reward: ClubRewardConfig = ClubRewardConfig()
|
||||
"""领取社团奖励配置"""
|
||||
|
||||
upgrade_support_card: UpgradeSupportCardConfig = UpgradeSupportCardConfig()
|
||||
"""支援卡升级配置"""
|
||||
|
||||
capsule_toys: CapsuleToysConfig = CapsuleToysConfig()
|
||||
"""扭蛋机配置"""
|
||||
|
||||
trace: TraceConfig = TraceConfig()
|
||||
"""跟踪配置"""
|
||||
|
||||
start_game: StartGameConfig = StartGameConfig()
|
||||
"""启动游戏配置"""
|
||||
|
||||
end_game: EndGameConfig = EndGameConfig()
|
||||
"""关闭游戏配置"""
|
||||
|
||||
|
||||
def conf() -> BaseConfig:
|
||||
"""获取当前配置数据"""
|
||||
c = config.to(BaseConfig).current
|
||||
return c.options
|
||||
|
||||
def sprite_path(path: str) -> str:
|
||||
standalone = os.path.join('kotonebot/kaa/sprites', path)
|
||||
if os.path.exists(standalone):
|
||||
return standalone
|
||||
return str(resources.files('kotonebot.kaa.sprites') / path)
|
||||
|
||||
def upgrade_config() -> str | None:
|
||||
"""
|
||||
升级配置文件
|
||||
"""
|
||||
if not os.path.exists('config.json'):
|
||||
return None
|
||||
with open('config.json', 'r', encoding='utf-8') as f:
|
||||
root = json.load(f)
|
||||
|
||||
user_configs = root['user_configs']
|
||||
old_version = root['version']
|
||||
messages = []
|
||||
def upgrade_user_config(version: int, user_config: dict[str, Any]) -> int:
|
||||
nonlocal messages
|
||||
while True:
|
||||
match version:
|
||||
case 1:
|
||||
logger.info('Upgrading config: v1 -> v2')
|
||||
user_config, msg = upgrade_v1_to_v2(user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 2
|
||||
case 2:
|
||||
logger.info('Upgrading config: v2 -> v3')
|
||||
user_config, msg = upgrade_v2_to_v3(user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 3
|
||||
case 3:
|
||||
logger.info('Upgrading config: v3 -> v4')
|
||||
user_config, msg = upgrade_v3_to_v4(user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 4
|
||||
case 4:
|
||||
logger.info('Upgrading config: v4 -> v5')
|
||||
user_config, msg = upgrade_v4_to_v5(user_config, user_config['options'])
|
||||
messages.append(msg)
|
||||
version = 5
|
||||
case _:
|
||||
logger.info('No config upgrade needed.')
|
||||
return version
|
||||
for user_config in user_configs:
|
||||
new_version = upgrade_user_config(old_version, user_config)
|
||||
root['version'] = new_version
|
||||
|
||||
with open('config.json', 'w', encoding='utf-8') as f:
|
||||
json.dump(root, f, ensure_ascii=False, indent=4)
|
||||
return '\n'.join(messages)
|
||||
|
||||
|
||||
倉本千奈_BASE = 0
|
||||
十王星南_BASE = 100
|
||||
姫崎莉波_BASE = 200
|
||||
月村手毬_BASE = 300
|
||||
有村麻央_BASE = 400
|
||||
篠泽广_BASE = 500
|
||||
紫云清夏_BASE = 600
|
||||
花海佑芽_BASE = 700
|
||||
花海咲季_BASE = 800
|
||||
葛城リーリヤ_BASE = 900
|
||||
藤田ことね_BASE = 1000
|
||||
|
||||
class PIdol(IntEnum):
|
||||
"""
|
||||
P偶像。已废弃,仅为 upgrade_v1_to_v2()、upgrade_v2_to_v3() 而保留。
|
||||
"""
|
||||
倉本千奈_Campusmode = 倉本千奈_BASE + 0
|
||||
倉本千奈_WonderScale = 倉本千奈_BASE + 1
|
||||
倉本千奈_ようこそ初星温泉 = 倉本千奈_BASE + 2
|
||||
倉本千奈_仮装狂騒曲 = 倉本千奈_BASE + 3
|
||||
倉本千奈_初心 = 倉本千奈_BASE + 4
|
||||
倉本千奈_学園生活 = 倉本千奈_BASE + 5
|
||||
倉本千奈_日々_発見的ステップ = 倉本千奈_BASE + 6
|
||||
倉本千奈_胸を張って一歩ずつ = 倉本千奈_BASE + 7
|
||||
|
||||
十王星南_Campusmode = 十王星南_BASE + 0
|
||||
十王星南_一番星 = 十王星南_BASE + 1
|
||||
十王星南_学園生活 = 十王星南_BASE + 2
|
||||
十王星南_小さな野望 = 十王星南_BASE + 3
|
||||
|
||||
姫崎莉波_clumsytrick = 姫崎莉波_BASE + 0
|
||||
姫崎莉波_私らしさのはじまり = 姫崎莉波_BASE + 1
|
||||
姫崎莉波_キミとセミブルー = 姫崎莉波_BASE + 2
|
||||
姫崎莉波_Campusmode = 姫崎莉波_BASE + 3
|
||||
姫崎莉波_LUV = 姫崎莉波_BASE + 4
|
||||
姫崎莉波_ようこそ初星温泉 = 姫崎莉波_BASE + 5
|
||||
姫崎莉波_ハッピーミルフィーユ = 姫崎莉波_BASE + 6
|
||||
姫崎莉波_初心 = 姫崎莉波_BASE + 7
|
||||
姫崎莉波_学園生活 = 姫崎莉波_BASE + 8
|
||||
|
||||
月村手毬_Lunasaymaybe = 月村手毬_BASE + 0
|
||||
月村手毬_一匹狼 = 月村手毬_BASE + 1
|
||||
月村手毬_Campusmode = 月村手毬_BASE + 2
|
||||
月村手毬_アイヴイ = 月村手毬_BASE + 3
|
||||
月村手毬_初声 = 月村手毬_BASE + 4
|
||||
月村手毬_学園生活 = 月村手毬_BASE + 5
|
||||
月村手毬_仮装狂騒曲 = 月村手毬_BASE + 6
|
||||
|
||||
有村麻央_Fluorite = 有村麻央_BASE + 0
|
||||
有村麻央_はじまりはカッコよく = 有村麻央_BASE + 1
|
||||
有村麻央_Campusmode = 有村麻央_BASE + 2
|
||||
有村麻央_FeelJewelDream = 有村麻央_BASE + 3
|
||||
有村麻央_キミとセミブルー = 有村麻央_BASE + 4
|
||||
有村麻央_初恋 = 有村麻央_BASE + 5
|
||||
有村麻央_学園生活 = 有村麻央_BASE + 6
|
||||
|
||||
篠泽广_コントラスト = 篠泽广_BASE + 0
|
||||
篠泽广_一番向いていないこと = 篠泽广_BASE + 1
|
||||
篠泽广_光景 = 篠泽广_BASE + 2
|
||||
篠泽广_Campusmode = 篠泽广_BASE + 3
|
||||
篠泽广_仮装狂騒曲 = 篠泽广_BASE + 4
|
||||
篠泽广_ハッピーミルフィーユ = 篠泽广_BASE + 5
|
||||
篠泽广_初恋 = 篠泽广_BASE + 6
|
||||
篠泽广_学園生活 = 篠泽广_BASE + 7
|
||||
|
||||
紫云清夏_TameLieOneStep = 紫云清夏_BASE + 0
|
||||
紫云清夏_カクシタワタシ = 紫云清夏_BASE + 1
|
||||
紫云清夏_夢へのリスタート = 紫云清夏_BASE + 2
|
||||
紫云清夏_Campusmode = 紫云清夏_BASE + 3
|
||||
紫云清夏_キミとセミブルー = 紫云清夏_BASE + 4
|
||||
紫云清夏_初恋 = 紫云清夏_BASE + 5
|
||||
紫云清夏_学園生活 = 紫云清夏_BASE + 6
|
||||
|
||||
花海佑芽_WhiteNightWhiteWish = 花海佑芽_BASE + 0
|
||||
花海佑芽_学園生活 = 花海佑芽_BASE + 1
|
||||
花海佑芽_Campusmode = 花海佑芽_BASE + 2
|
||||
花海佑芽_TheRollingRiceball = 花海佑芽_BASE + 3
|
||||
花海佑芽_アイドル_はじめっ = 花海佑芽_BASE + 4
|
||||
|
||||
花海咲季_BoomBoomPow = 花海咲季_BASE + 0
|
||||
花海咲季_Campusmode = 花海咲季_BASE + 1
|
||||
花海咲季_FightingMyWay = 花海咲季_BASE + 2
|
||||
花海咲季_わたしが一番 = 花海咲季_BASE + 3
|
||||
花海咲季_冠菊 = 花海咲季_BASE + 4
|
||||
花海咲季_初声 = 花海咲季_BASE + 5
|
||||
花海咲季_古今東西ちょちょいのちょい = 花海咲季_BASE + 6
|
||||
花海咲季_学園生活 = 花海咲季_BASE + 7
|
||||
|
||||
葛城リーリヤ_一つ踏み出した先に = 葛城リーリヤ_BASE + 0
|
||||
葛城リーリヤ_白線 = 葛城リーリヤ_BASE + 1
|
||||
葛城リーリヤ_Campusmode = 葛城リーリヤ_BASE + 2
|
||||
葛城リーリヤ_WhiteNightWhiteWish = 葛城リーリヤ_BASE + 3
|
||||
葛城リーリヤ_冠菊 = 葛城リーリヤ_BASE + 4
|
||||
葛城リーリヤ_初心 = 葛城リーリヤ_BASE + 5
|
||||
葛城リーリヤ_学園生活 = 葛城リーリヤ_BASE + 6
|
||||
|
||||
藤田ことね_カワイイ_はじめました = 藤田ことね_BASE + 0
|
||||
藤田ことね_世界一可愛い私 = 藤田ことね_BASE + 1
|
||||
藤田ことね_Campusmode = 藤田ことね_BASE + 2
|
||||
藤田ことね_YellowBigBang = 藤田ことね_BASE + 3
|
||||
藤田ことね_WhiteNightWhiteWish = 藤田ことね_BASE + 4
|
||||
藤田ことね_冠菊 = 藤田ことね_BASE + 5
|
||||
藤田ことね_初声 = 藤田ことね_BASE + 6
|
||||
藤田ことね_学園生活 = 藤田ことね_BASE + 7
|
||||
|
||||
|
||||
def upgrade_v1_to_v2(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v1 -> v2 变更:
|
||||
|
||||
1. 将 PIdol 的枚举值改为整数
|
||||
"""
|
||||
msg = ''
|
||||
# 转换 PIdol 的枚举值
|
||||
def map_idol(idol: list[str]) -> PIdol | None:
|
||||
logger.debug("Converting %s", idol)
|
||||
match idol:
|
||||
case ["倉本千奈", "Campus mode!!"]:
|
||||
return PIdol.倉本千奈_Campusmode
|
||||
case ["倉本千奈", "Wonder Scale"]:
|
||||
return PIdol.倉本千奈_WonderScale
|
||||
case ["倉本千奈", "ようこそ初星温泉"]:
|
||||
return PIdol.倉本千奈_ようこそ初星温泉
|
||||
case ["倉本千奈", "仮装狂騒曲"]:
|
||||
return PIdol.倉本千奈_仮装狂騒曲
|
||||
case ["倉本千奈", "初心"]:
|
||||
return PIdol.倉本千奈_初心
|
||||
case ["倉本千奈", "学園生活"]:
|
||||
return PIdol.倉本千奈_学園生活
|
||||
case ["倉本千奈", "日々、発見的ステップ!"]:
|
||||
return PIdol.倉本千奈_日々_発見的ステップ
|
||||
case ["倉本千奈", "胸を張って一歩ずつ"]:
|
||||
return PIdol.倉本千奈_胸を張って一歩ずつ
|
||||
case ["十王星南", "Campus mode!!"]:
|
||||
return PIdol.十王星南_Campusmode
|
||||
case ["十王星南", "一番星"]:
|
||||
return PIdol.十王星南_一番星
|
||||
case ["十王星南", "学園生活"]:
|
||||
return PIdol.十王星南_学園生活
|
||||
case ["十王星南", "小さな野望"]:
|
||||
return PIdol.十王星南_小さな野望
|
||||
case ["姫崎莉波", "clumsy trick"]:
|
||||
return PIdol.姫崎莉波_clumsytrick
|
||||
case ["姫崎莉波", "『私らしさ』のはじまり"]:
|
||||
return PIdol.姫崎莉波_私らしさのはじまり
|
||||
case ["姫崎莉波", "キミとセミブルー"]:
|
||||
return PIdol.姫崎莉波_キミとセミブルー
|
||||
case ["姫崎莉波", "Campus mode!!"]:
|
||||
return PIdol.姫崎莉波_Campusmode
|
||||
case ["姫崎莉波", "L.U.V"]:
|
||||
return PIdol.姫崎莉波_LUV
|
||||
case ["姫崎莉波", "ようこそ初星温泉"]:
|
||||
return PIdol.姫崎莉波_ようこそ初星温泉
|
||||
case ["姫崎莉波", "ハッピーミルフィーユ"]:
|
||||
return PIdol.姫崎莉波_ハッピーミルフィーユ
|
||||
case ["姫崎莉波", "初心"]:
|
||||
return PIdol.姫崎莉波_初心
|
||||
case ["姫崎莉波", "学園生活"]:
|
||||
return PIdol.姫崎莉波_学園生活
|
||||
case ["月村手毬", "Luna say maybe"]:
|
||||
return PIdol.月村手毬_Lunasaymaybe
|
||||
case ["月村手毬", "一匹狼"]:
|
||||
return PIdol.月村手毬_一匹狼
|
||||
case ["月村手毬", "Campus mode!!"]:
|
||||
return PIdol.月村手毬_Campusmode
|
||||
case ["月村手毬", "アイヴイ"]:
|
||||
return PIdol.月村手毬_アイヴイ
|
||||
case ["月村手毬", "初声"]:
|
||||
return PIdol.月村手毬_初声
|
||||
case ["月村手毬", "学園生活"]:
|
||||
return PIdol.月村手毬_学園生活
|
||||
case ["月村手毬", "仮装狂騒曲"]:
|
||||
return PIdol.月村手毬_仮装狂騒曲
|
||||
case ["有村麻央", "Fluorite"]:
|
||||
return PIdol.有村麻央_Fluorite
|
||||
case ["有村麻央", "はじまりはカッコよく"]:
|
||||
return PIdol.有村麻央_はじまりはカッコよく
|
||||
case ["有村麻央", "Campus mode!!"]:
|
||||
return PIdol.有村麻央_Campusmode
|
||||
case ["有村麻央", "Feel Jewel Dream"]:
|
||||
return PIdol.有村麻央_FeelJewelDream
|
||||
case ["有村麻央", "キミとセミブルー"]:
|
||||
return PIdol.有村麻央_キミとセミブルー
|
||||
case ["有村麻央", "初恋"]:
|
||||
return PIdol.有村麻央_初恋
|
||||
case ["有村麻央", "学園生活"]:
|
||||
return PIdol.有村麻央_学園生活
|
||||
case ["篠泽广", "コントラスト"]:
|
||||
return PIdol.篠泽广_コントラスト
|
||||
case ["篠泽广", "一番向いていないこと"]:
|
||||
return PIdol.篠泽广_一番向いていないこと
|
||||
case ["篠泽广", "光景"]:
|
||||
return PIdol.篠泽广_光景
|
||||
case ["篠泽广", "Campus mode!!"]:
|
||||
return PIdol.篠泽广_Campusmode
|
||||
case ["篠泽广", "仮装狂騒曲"]:
|
||||
return PIdol.篠泽广_仮装狂騒曲
|
||||
case ["篠泽广", "ハッピーミルフィーユ"]:
|
||||
return PIdol.篠泽广_ハッピーミルフィーユ
|
||||
case ["篠泽广", "初恋"]:
|
||||
return PIdol.篠泽广_初恋
|
||||
case ["篠泽广", "学園生活"]:
|
||||
return PIdol.篠泽广_学園生活
|
||||
case ["紫云清夏", "Tame-Lie-One-Step"]:
|
||||
return PIdol.紫云清夏_TameLieOneStep
|
||||
case ["紫云清夏", "カクシタワタシ"]:
|
||||
return PIdol.紫云清夏_カクシタワタシ
|
||||
case ["紫云清夏", "夢へのリスタート"]:
|
||||
return PIdol.紫云清夏_夢へのリスタート
|
||||
case ["紫云清夏", "Campus mode!!"]:
|
||||
return PIdol.紫云清夏_Campusmode
|
||||
case ["紫云清夏", "キミとセミブルー"]:
|
||||
return PIdol.紫云清夏_キミとセミブルー
|
||||
case ["紫云清夏", "初恋"]:
|
||||
return PIdol.紫云清夏_初恋
|
||||
case ["紫云清夏", "学園生活"]:
|
||||
return PIdol.紫云清夏_学園生活
|
||||
case ["花海佑芽", "White Night! White Wish!"]:
|
||||
return PIdol.花海佑芽_WhiteNightWhiteWish
|
||||
case ["花海佑芽", "学園生活"]:
|
||||
return PIdol.花海佑芽_学園生活
|
||||
case ["花海佑芽", "Campus mode!!"]:
|
||||
return PIdol.花海佑芽_Campusmode
|
||||
case ["花海佑芽", "The Rolling Riceball"]:
|
||||
return PIdol.花海佑芽_TheRollingRiceball
|
||||
case ["花海佑芽", "アイドル、はじめっ!"]:
|
||||
return PIdol.花海佑芽_アイドル_はじめっ
|
||||
case ["花海咲季", "Boom Boom Pow"]:
|
||||
return PIdol.花海咲季_BoomBoomPow
|
||||
case ["花海咲季", "Campus mode!!"]:
|
||||
return PIdol.花海咲季_Campusmode
|
||||
case ["花海咲季", "Fighting My Way"]:
|
||||
return PIdol.花海咲季_FightingMyWay
|
||||
case ["花海咲季", "わたしが一番!"]:
|
||||
return PIdol.花海咲季_わたしが一番
|
||||
case ["花海咲季", "冠菊"]:
|
||||
return PIdol.花海咲季_冠菊
|
||||
case ["花海咲季", "初声"]:
|
||||
return PIdol.花海咲季_初声
|
||||
case ["花海咲季", "古今東西ちょちょいのちょい"]:
|
||||
return PIdol.花海咲季_古今東西ちょちょいのちょい
|
||||
case ["花海咲季", "学園生活"]:
|
||||
return PIdol.花海咲季_学園生活
|
||||
case ["葛城リーリヤ", "一つ踏み出した先に"]:
|
||||
return PIdol.葛城リーリヤ_一つ踏み出した先に
|
||||
case ["葛城リーリヤ", "白線"]:
|
||||
return PIdol.葛城リーリヤ_白線
|
||||
case ["葛城リーリヤ", "Campus mode!!"]:
|
||||
return PIdol.葛城リーリヤ_Campusmode
|
||||
case ["葛城リーリヤ", "White Night! White Wish!"]:
|
||||
return PIdol.葛城リーリヤ_WhiteNightWhiteWish
|
||||
case ["葛城リーリヤ", "冠菊"]:
|
||||
return PIdol.葛城リーリヤ_冠菊
|
||||
case ["葛城リーリヤ", "初心"]:
|
||||
return PIdol.葛城リーリヤ_初心
|
||||
case ["葛城リーリヤ", "学園生活"]:
|
||||
return PIdol.葛城リーリヤ_学園生活
|
||||
case ["藤田ことね", "カワイイ", "はじめました"]:
|
||||
return PIdol.藤田ことね_カワイイ_はじめました
|
||||
case ["藤田ことね", "世界一可愛い私"]:
|
||||
return PIdol.藤田ことね_世界一可愛い私
|
||||
case ["藤田ことね", "Campus mode!!"]:
|
||||
return PIdol.藤田ことね_Campusmode
|
||||
case ["藤田ことね", "Yellow Big Bang!"]:
|
||||
return PIdol.藤田ことね_YellowBigBang
|
||||
case ["藤田ことね", "White Night! White Wish!"]:
|
||||
return PIdol.藤田ことね_WhiteNightWhiteWish
|
||||
case ["藤田ことね", "冠菊"]:
|
||||
return PIdol.藤田ことね_冠菊
|
||||
case ["藤田ことね", "初声"]:
|
||||
return PIdol.藤田ことね_初声
|
||||
case ["藤田ことね", "学園生活"]:
|
||||
return PIdol.藤田ことね_学園生活
|
||||
case _:
|
||||
nonlocal msg
|
||||
if msg == '':
|
||||
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
|
||||
msg += f'{idol} 未找到\n'
|
||||
return None
|
||||
old_idols = options['produce']['idols']
|
||||
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
|
||||
options['produce']['idols'] = new_idols
|
||||
shutil.copy('config.json', 'config.v1.json')
|
||||
return options, msg
|
||||
|
||||
def upgrade_v2_to_v3(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v2 -> v3 变更:\n
|
||||
引入了游戏解包数据,因此 PIdol 枚举废弃,直接改用游戏内 ID。
|
||||
"""
|
||||
msg = ''
|
||||
def map_idol(idol: PIdol) -> str | None:
|
||||
match idol:
|
||||
case PIdol.倉本千奈_Campusmode: return "i_card-skin-kcna-3-007"
|
||||
case PIdol.倉本千奈_WonderScale: return "i_card-skin-kcna-3-000"
|
||||
case PIdol.倉本千奈_ようこそ初星温泉: return "i_card-skin-kcna-3-005"
|
||||
case PIdol.倉本千奈_仮装狂騒曲: return "i_card-skin-kcna-3-002"
|
||||
case PIdol.倉本千奈_初心: return "i_card-skin-kcna-1-001"
|
||||
case PIdol.倉本千奈_学園生活: return "i_card-skin-kcna-1-000"
|
||||
case PIdol.倉本千奈_日々_発見的ステップ: return "i_card-skin-kcna-3-001"
|
||||
case PIdol.倉本千奈_胸を張って一歩ずつ: return "i_card-skin-kcna-2-000"
|
||||
case PIdol.十王星南_Campusmode: return "i_card-skin-jsna-3-002"
|
||||
case PIdol.十王星南_一番星: return "i_card-skin-jsna-2-000"
|
||||
case PIdol.十王星南_学園生活: return "i_card-skin-jsna-1-000"
|
||||
case PIdol.十王星南_小さな野望: return "i_card-skin-jsna-3-000"
|
||||
case PIdol.姫崎莉波_clumsytrick: return "i_card-skin-hrnm-3-000"
|
||||
case PIdol.姫崎莉波_私らしさのはじまり: return "i_card-skin-hrnm-2-000"
|
||||
case PIdol.姫崎莉波_キミとセミブルー: return "i_card-skin-hrnm-3-001"
|
||||
case PIdol.姫崎莉波_Campusmode: return "i_card-skin-hrnm-3-007"
|
||||
case PIdol.姫崎莉波_LUV: return "i_card-skin-hrnm-3-002"
|
||||
case PIdol.姫崎莉波_ようこそ初星温泉: return "i_card-skin-hrnm-3-004"
|
||||
case PIdol.姫崎莉波_ハッピーミルフィーユ: return "i_card-skin-hrnm-3-008"
|
||||
case PIdol.姫崎莉波_初心: return "i_card-skin-hrnm-1-001"
|
||||
case PIdol.姫崎莉波_学園生活: return "i_card-skin-hrnm-1-000"
|
||||
case PIdol.月村手毬_Lunasaymaybe: return "i_card-skin-ttmr-3-000"
|
||||
case PIdol.月村手毬_一匹狼: return "i_card-skin-ttmr-2-000"
|
||||
case PIdol.月村手毬_Campusmode: return "i_card-skin-ttmr-3-007"
|
||||
case PIdol.月村手毬_アイヴイ: return "i_card-skin-ttmr-3-001"
|
||||
case PIdol.月村手毬_初声: return "i_card-skin-ttmr-1-001"
|
||||
case PIdol.月村手毬_学園生活: return "i_card-skin-ttmr-1-000"
|
||||
case PIdol.月村手毬_仮装狂騒曲: return "i_card-skin-ttmr-3-002"
|
||||
case PIdol.有村麻央_Fluorite: return "i_card-skin-amao-3-000"
|
||||
case PIdol.有村麻央_はじまりはカッコよく: return "i_card-skin-amao-2-000"
|
||||
case PIdol.有村麻央_Campusmode: return "i_card-skin-amao-3-007"
|
||||
case PIdol.有村麻央_FeelJewelDream: return "i_card-skin-amao-3-002"
|
||||
case PIdol.有村麻央_キミとセミブルー: return "i_card-skin-amao-3-001"
|
||||
case PIdol.有村麻央_初恋: return "i_card-skin-amao-1-001"
|
||||
case PIdol.有村麻央_学園生活: return "i_card-skin-amao-1-000"
|
||||
case PIdol.篠泽广_コントラスト: return "i_card-skin-shro-3-001"
|
||||
case PIdol.篠泽广_一番向いていないこと: return "i_card-skin-shro-2-000"
|
||||
case PIdol.篠泽广_光景: return "i_card-skin-shro-3-000"
|
||||
case PIdol.篠泽广_Campusmode: return "i_card-skin-shro-3-007"
|
||||
case PIdol.篠泽广_仮装狂騒曲: return "i_card-skin-shro-3-002"
|
||||
case PIdol.篠泽广_ハッピーミルフィーユ: return "i_card-skin-shro-3-008"
|
||||
case PIdol.篠泽广_初恋: return "i_card-skin-shro-1-001"
|
||||
case PIdol.篠泽广_学園生活: return "i_card-skin-shro-1-000"
|
||||
case PIdol.紫云清夏_TameLieOneStep: return "i_card-skin-ssmk-3-000"
|
||||
case PIdol.紫云清夏_カクシタワタシ: return "i_card-skin-ssmk-3-002"
|
||||
case PIdol.紫云清夏_夢へのリスタート: return "i_card-skin-ssmk-2-000"
|
||||
case PIdol.紫云清夏_Campusmode: return "i_card-skin-ssmk-3-007"
|
||||
case PIdol.紫云清夏_キミとセミブルー: return "i_card-skin-ssmk-3-001"
|
||||
case PIdol.紫云清夏_初恋: return "i_card-skin-ssmk-1-001"
|
||||
case PIdol.紫云清夏_学園生活: return "i_card-skin-ssmk-1-000"
|
||||
case PIdol.花海佑芽_WhiteNightWhiteWish: return "i_card-skin-hume-3-005"
|
||||
case PIdol.花海佑芽_学園生活: return "i_card-skin-hume-1-000"
|
||||
case PIdol.花海佑芽_Campusmode: return "i_card-skin-hume-3-006"
|
||||
case PIdol.花海佑芽_TheRollingRiceball: return "i_card-skin-hume-3-000"
|
||||
case PIdol.花海佑芽_アイドル_はじめっ: return "i_card-skin-hume-2-000"
|
||||
case PIdol.花海咲季_BoomBoomPow: return "i_card-skin-hski-3-001"
|
||||
case PIdol.花海咲季_Campusmode: return "i_card-skin-hski-3-008"
|
||||
case PIdol.花海咲季_FightingMyWay: return "i_card-skin-hski-3-000"
|
||||
case PIdol.花海咲季_わたしが一番: return "i_card-skin-hski-2-000"
|
||||
case PIdol.花海咲季_冠菊: return "i_card-skin-hski-3-002"
|
||||
case PIdol.花海咲季_初声: return "i_card-skin-hski-1-001"
|
||||
case PIdol.花海咲季_古今東西ちょちょいのちょい: return "i_card-skin-hski-3-006"
|
||||
case PIdol.花海咲季_学園生活: return "i_card-skin-hski-1-000"
|
||||
case PIdol.葛城リーリヤ_一つ踏み出した先に: return "i_card-skin-kllj-2-000"
|
||||
case PIdol.葛城リーリヤ_白線: return "i_card-skin-kllj-3-000"
|
||||
case PIdol.葛城リーリヤ_Campusmode: return "i_card-skin-kllj-3-006"
|
||||
case PIdol.葛城リーリヤ_WhiteNightWhiteWish: return "i_card-skin-kllj-3-005"
|
||||
case PIdol.葛城リーリヤ_冠菊: return "i_card-skin-kllj-3-001"
|
||||
case PIdol.葛城リーリヤ_初心: return "i_card-skin-kllj-1-001"
|
||||
case PIdol.葛城リーリヤ_学園生活: return "i_card-skin-kllj-1-000"
|
||||
case PIdol.藤田ことね_カワイイ_はじめました: return "i_card-skin-fktn-2-000"
|
||||
case PIdol.藤田ことね_世界一可愛い私: return "i_card-skin-fktn-3-000"
|
||||
case PIdol.藤田ことね_Campusmode: return "i_card-skin-fktn-3-007"
|
||||
case PIdol.藤田ことね_YellowBigBang: return "i_card-skin-fktn-3-001"
|
||||
case PIdol.藤田ことね_WhiteNightWhiteWish: return "i_card-skin-fktn-3-006"
|
||||
case PIdol.藤田ことね_冠菊: return "i_card-skin-fktn-3-002"
|
||||
case PIdol.藤田ことね_初声: return "i_card-skin-fktn-1-001"
|
||||
case PIdol.藤田ことね_学園生活: return "i_card-skin-fktn-1-000"
|
||||
case _:
|
||||
nonlocal msg
|
||||
if msg == '':
|
||||
msg = '培育设置中的以下偶像升级失败。请尝试手动添加。\n'
|
||||
msg += f'{idol} 未找到\n'
|
||||
return None
|
||||
old_idols = options['produce']['idols']
|
||||
new_idols = list(filter(lambda x: x is not None, map(map_idol, old_idols)))
|
||||
options['produce']['idols'] = new_idols
|
||||
shutil.copy('config.json', 'config.v2.json')
|
||||
return options, msg
|
||||
|
||||
def upgrade_v3_to_v4(options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v3 -> v4 变更:
|
||||
自动纠正错误游戏包名
|
||||
"""
|
||||
shutil.copy('config.json', 'config.v3.json')
|
||||
if options['start_game']['game_package_name'] == 'com.bandinamcoent.idolmaster_gakuen':
|
||||
options['start_game']['game_package_name'] = 'com.bandainamcoent.idolmaster_gakuen'
|
||||
logger.info('Corrected game package name to com.bandainamcoent.idolmaster_gakuen')
|
||||
return options, ''
|
||||
|
||||
def upgrade_v4_to_v5(user_config: dict[str, Any], options: dict[str, Any]) -> tuple[dict[str, Any], str]:
|
||||
"""
|
||||
v4 -> v5 变更:
|
||||
为 windows 和 windows_remote 截图方式的 type 设置为 dmm
|
||||
"""
|
||||
shutil.copy('config.json', 'config.v4.json')
|
||||
if user_config['backend']['screenshot_impl'] in ['windows', 'remote_windows']:
|
||||
logger.info('Set backend type to dmm.')
|
||||
user_config['backend']['type'] = 'dmm'
|
||||
return options, ''
|
||||
|
||||
if __name__ == '__main__':
|
||||
print(PurchaseConfig.model_fields['money_refresh_on'].description)
|
||||
return str(resources.files('kotonebot.kaa.sprites') / path)
|
|
@ -0,0 +1,62 @@
|
|||
from .schema import (
|
||||
BaseConfig,
|
||||
PurchaseConfig,
|
||||
ActivityFundsConfig,
|
||||
PresentsConfig,
|
||||
AssignmentConfig,
|
||||
ContestConfig,
|
||||
ProduceConfig,
|
||||
MissionRewardConfig,
|
||||
ClubRewardConfig,
|
||||
UpgradeSupportCardConfig,
|
||||
CapsuleToysConfig,
|
||||
TraceConfig,
|
||||
StartGameConfig,
|
||||
EndGameConfig,
|
||||
MiscConfig,
|
||||
conf,
|
||||
)
|
||||
from .const import (
|
||||
ConfigEnum,
|
||||
Priority,
|
||||
APShopItems,
|
||||
DailyMoneyShopItems,
|
||||
ProduceAction,
|
||||
RecommendCardDetectionMode,
|
||||
)
|
||||
|
||||
# 配置升级逻辑
|
||||
from .upgrade import upgrade_config
|
||||
from .migrations import MIGRATION_REGISTRY, LATEST_VERSION
|
||||
|
||||
__all__ = [
|
||||
# schema 导出
|
||||
"BaseConfig",
|
||||
"PurchaseConfig",
|
||||
"ActivityFundsConfig",
|
||||
"PresentsConfig",
|
||||
"AssignmentConfig",
|
||||
"ContestConfig",
|
||||
"ProduceConfig",
|
||||
"MissionRewardConfig",
|
||||
"ClubRewardConfig",
|
||||
"UpgradeSupportCardConfig",
|
||||
"CapsuleToysConfig",
|
||||
"TraceConfig",
|
||||
"StartGameConfig",
|
||||
"EndGameConfig",
|
||||
"MiscConfig",
|
||||
"conf",
|
||||
# const 导出
|
||||
"ConfigEnum",
|
||||
"Priority",
|
||||
"APShopItems",
|
||||
"DailyMoneyShopItems",
|
||||
"ProduceAction",
|
||||
"RecommendCardDetectionMode",
|
||||
# upgrade 导出
|
||||
"upgrade_config",
|
||||
"migrations",
|
||||
"MIGRATION_REGISTRY",
|
||||
"LATEST_VERSION",
|
||||
]
|