From 456019b5b5f5497877f92b136fa684c6fd415a5f Mon Sep 17 00:00:00 2001 From: XcantloadX <3188996979@qq.com> Date: Wed, 2 Jul 2025 20:31:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(bootstrap):=20=E6=96=B0=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=99=A8=E7=8E=B0=E5=9C=A8=E6=94=AF=E6=8C=81=E5=AE=89=E8=A3=85?= =?UTF-8?q?=E6=8C=87=E5=AE=9A=E7=89=88=E6=9C=AC=E4=B8=8E=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E8=A1=A5=E4=B8=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .vscode/settings.json | 2 +- bootstrap/README.md | 16 ++ bootstrap/kaa-bootstrap/launcher.py | 170 +++++++++++++++---- bootstrap/kaa-wrapper/kaa-wrapper.cpp | 7 +- bootstrap/覆盖安装.bat | 40 ----- justfile | 11 +- kotonebot/kaa/main/gr.py | 225 +++++++++++++++++++++++++- 7 files changed, 388 insertions(+), 83 deletions(-) create mode 100644 bootstrap/README.md delete mode 100644 bootstrap/覆盖安装.bat diff --git a/.vscode/settings.json b/.vscode/settings.json index 0f6fd74..d94b96a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -20,5 +20,5 @@ "venv", "**/node_modules" ], - "python.analysis.diagnosticMode": "workspace" + // "python.analysis.diagnosticMode": "workspace" } \ No newline at end of file diff --git a/bootstrap/README.md b/bootstrap/README.md new file mode 100644 index 0000000..852c642 --- /dev/null +++ b/bootstrap/README.md @@ -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 \ No newline at end of file diff --git a/bootstrap/kaa-bootstrap/launcher.py b/bootstrap/kaa-bootstrap/launcher.py index 927cc6d..cc52fa7 100644 --- a/bootstrap/kaa-bootstrap/launcher.py +++ b/bootstrap/kaa-bootstrap/launcher.py @@ -7,6 +7,10 @@ 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 @@ -44,7 +48,8 @@ class Config(TypedDict, total=False): user_configs: List[UserConfig] # 获取当前Python解释器路径 -python_executable = sys.executable +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(): """ @@ -386,10 +391,77 @@ def print_update_notice(current_version: str, latest_version: str): 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: 是否检查更新 @@ -400,17 +472,15 @@ def install_pip_and_ksaa(pip_server: str, check_update: bool = True, install_upd :rtype: bool """ print_header("安装与更新小助手", color=Color.BLUE) - - # 定义信任的主机列表 - trusted_hosts = "pypi.org files.pythonhosted.org pypi.python.org mirrors.aliyun.com mirrors.cloud.tencent.com mirrors.tuna.tsinghua.edu.cn" - + # 升级pip print_status("更新 pip", status='info') - upgrade_pip_command = f'"{python_executable}" -m pip install -i {pip_server} --trusted-host "{trusted_hosts}" --upgrade pip' + 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' + + # 默认安装逻辑 + 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: @@ -440,7 +510,7 @@ def load_config() -> Optional[Config]: """ config_path = Path("./config.json") if not config_path.exists(): - msg = f"配置文件 config.json 不存在,跳过配置加载" + msg = "配置文件 config.json 不存在,跳过配置加载" print_status(msg, status='warning') logging.warning(msg) return None @@ -503,7 +573,7 @@ def restart_as_admin() -> None: try: # 使用 ShellExecute 以管理员身份启动程序 ret = ctypes.windll.shell32.ShellExecuteW( - None, "runas", python_executable, f'"{script}" {params}', None, 1 + None, "runas", PYTHON_EXECUTABLE, f'"{script}" {params}', None, 1 ) if ret > 32: # 返回值大于32表示成功 msg = "正在以管理员身份重启程序..." @@ -567,65 +637,103 @@ def check_admin(config: Config) -> bool: def run_kaa() -> bool: """ - 运行KAA程序。 + 运行琴音小助手。 :return: 运行是否成功 :rtype: bool """ - print_header("运行 KAA", color=Color.GREEN) + 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): + 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. 根据配置决定是否检查更新 + + # 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服务器,请检查网络连接。") - - # 4. 安装和更新pip以及ksaa包 - if not install_pip_and_ksaa(pip_server, check_update, auto_install_update): - raise RuntimeError("依赖安装失败,请检查上面的错误日志。") - - # 5. 检查Windows截图权限 + + # 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("权限检查失败。") - - # 6. 运行KAA + + # 7. 运行琴音小助手 if not run_kaa(): - raise RuntimeError("KAA 主程序运行失败。") - - msg = "KAA 已成功运行并退出。" + raise RuntimeError("琴音小助手主程序运行失败。") + + msg = "琴音小助手已退出。" print_status(msg, status='success') logging.info(msg) @@ -634,7 +742,7 @@ def main_launch(): print_status(msg, status='error') print_status("压缩 kaa 目录下的 logs 文件夹并给此窗口截图后一并发送给开发者", status='error') logging.critical(msg, exc_info=True) - + finally: logging.info("启动器运行结束。") wait_key("\n按任意键退出...") @@ -643,4 +751,4 @@ if __name__ == "__main__": try: main_launch() except KeyboardInterrupt: - print_status("运行结束", status='info') \ No newline at end of file + print_status("运行结束。现在可以安全关闭此窗口。", status='info') \ No newline at end of file diff --git a/bootstrap/kaa-wrapper/kaa-wrapper.cpp b/bootstrap/kaa-wrapper/kaa-wrapper.cpp index b2f039c..3c6e313 100644 --- a/bootstrap/kaa-wrapper/kaa-wrapper.cpp +++ b/bootstrap/kaa-wrapper/kaa-wrapper.cpp @@ -12,7 +12,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, { UNREFERENCED_PARAMETER(hInstance); UNREFERENCED_PARAMETER(hPrevInstance); - UNREFERENCED_PARAMETER(lpCmdLine); UNREFERENCED_PARAMETER(nCmdShow); // 设置当前目录为程序所在目录 @@ -51,6 +50,12 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance, // 构建命令行 std::wstring cmd = pythonPath + L" " + bootstrapPath; + + // 如果有命令行参数,将其传递给 bootstrap + if (lpCmdLine && wcslen(lpCmdLine) > 0) { + cmd += L" "; + cmd += lpCmdLine; + } // 启动信息 STARTUPINFOW si = { sizeof(si) }; diff --git a/bootstrap/覆盖安装.bat b/bootstrap/覆盖安装.bat deleted file mode 100644 index 90cc79d..0000000 --- a/bootstrap/覆盖安装.bat +++ /dev/null @@ -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 \ No newline at end of file diff --git a/justfile b/justfile index d846068..a221a03 100644 --- a/justfile +++ b/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}} # 更新日志 @@ -125,7 +121,7 @@ publish-test: package # build-bootstrap: #!{{shebang_pwsh}} - echo "Building bootstrap"... + echo "Building bootstrap..." # 构建 Python cd bootstrap python -m zipapp kaa-bootstrap @@ -138,4 +134,7 @@ build-bootstrap: 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." - } \ No newline at end of file + } + +# Build kaa and bootstrap +build: package build-bootstrap diff --git a/kotonebot/kaa/main/gr.py b/kotonebot/kaa/main/gr.py index 7e87d5f..3fefac1 100644 --- a/kotonebot/kaa/main/gr.py +++ b/kotonebot/kaa/main/gr.py @@ -3,6 +3,9 @@ import traceback import zipfile import logging import copy +import sys +import subprocess +import json from functools import partial from itertools import chain from datetime import datetime, timedelta @@ -1607,10 +1610,224 @@ class KotoneBotUI: ) def _create_whats_new_tab(self) -> None: - """创建更新日志标签页,并显示最新版本更新内容""" - with gr.Tab("更新日志"): - from kotonebot.kaa.metadata import WHATS_NEW - gr.Markdown(WHATS_NEW) + """创建更新标签页""" + with gr.Tab("更新"): + gr.Markdown("## 版本管理") + + # 更新日志 + with gr.Accordion("更新日志", open=False): + from kotonebot.kaa.metadata import WHATS_NEW + gr.Markdown(WHATS_NEW) + + # 载入信息按钮 + load_info_btn = gr.Button("载入信息", variant="primary") + + # 状态信息 + status_text = gr.Markdown("") + + # 版本选择下拉框(用于安装) + version_dropdown = gr.Dropdown( + label="选择要安装的版本", + choices=[], + value=None, + visible=False, + interactive=True + ) + + # 安装选定版本按钮 + install_selected_btn = gr.Button("安装选定版本", visible=False) + + def list_all_versions(): + """列出所有可用版本""" + import logging + logger = logging.getLogger(__name__) + + try: + # 构建命令,使用清华镜像源 + cmd = [ + sys.executable, "-m", "pip", "index", "versions", "ksaa", "--json", + "--index-url", "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/simple", + "--trusted-host", "mirrors.tuna.tsinghua.edu.cn" + ] + logger.info(f"执行命令: {' '.join(cmd)}") + + # 使用 pip index versions --json 来获取版本信息 + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=30 + ) + + logger.info(f"命令返回码: {result.returncode}") + if result.stdout: + logger.info(f"命令输出: {result.stdout[:500]}...") # 只记录前500字符 + if result.stderr: + logger.warning(f"命令错误输出: {result.stderr}") + + if result.returncode != 0: + error_msg = f"获取版本列表失败: {result.stderr}" + logger.error(error_msg) + return ( + error_msg, + gr.Button(value="载入信息", interactive=True), + gr.Dropdown(visible=False), + gr.Button(visible=False) + ) + + # 解析 JSON 输出 + try: + data = json.loads(result.stdout) + versions = data.get("versions", []) + latest_version = data.get("latest", "") + installed_version = data.get("installed_version", "") + + logger.info(f"解析到 {len(versions)} 个版本") + logger.info(f"最新版本: {latest_version}") + logger.info(f"已安装版本: {installed_version}") + + except json.JSONDecodeError as e: + error_msg = f"解析版本信息失败: {str(e)}" + logger.error(error_msg) + return ( + error_msg, + gr.Button(value="载入信息", interactive=True), + gr.Dropdown(visible=False), + gr.Button(visible=False) + ) + + if not versions: + error_msg = "未找到可用版本" + logger.warning(error_msg) + return ( + error_msg, + gr.Button(value="载入信息", interactive=True), + gr.Dropdown(visible=False), + gr.Button(visible=False) + ) + + # 构建状态信息 + status_info = [] + if installed_version: + status_info.append(f"**当前安装版本:** {installed_version}") + if latest_version: + status_info.append(f"**最新版本:** {latest_version}") + status_info.append(f"**找到 {len(versions)} 个可用版本**") + + status_message = "\n\n".join(status_info) + logger.info(f"版本信息载入完成: {status_message}") + + # 返回更新后的组件 + return ( + status_message, + gr.Button(value="载入信息", interactive=True), + gr.Dropdown(choices=versions, value=versions[0] if versions else None, visible=True, label="选择要安装的版本"), + gr.Button(visible=True, value="安装选定版本") + ) + + except subprocess.TimeoutExpired: + error_msg = "获取版本列表超时" + logger.error(error_msg) + return ( + error_msg, + gr.Button(value="载入信息", interactive=True), + gr.Dropdown(visible=False), + gr.Button(visible=False) + ) + except Exception as e: + error_msg = f"获取版本列表失败: {str(e)}" + logger.error(error_msg) + return ( + error_msg, + gr.Button(value="载入信息", interactive=True), + gr.Dropdown(visible=False), + gr.Button(visible=False) + ) + + def install_selected_version(selected_version: str): + """安装选定的版本""" + import logging + import threading + import time + logger = logging.getLogger(__name__) + + if not selected_version: + error_msg = "请先选择一个版本" + logger.warning(error_msg) + return error_msg + + def install_and_exit(): + """在后台线程中执行安装并退出程序""" + try: + # 等待一小段时间确保UI响应已返回 + time.sleep(1) + + # 构建启动器命令 + bootstrap_path = os.path.join(os.getcwd(), "bootstrap.pyz") + cmd = [sys.executable, bootstrap_path, f"--install-version={selected_version}"] + logger.info(f"开始通过启动器安装版本 {selected_version}") + logger.info(f"执行命令: {' '.join(cmd)}") + + # 启动启动器进程(不等待完成) + subprocess.Popen( + cmd, + cwd=os.getcwd(), + creationflags=subprocess.CREATE_NEW_CONSOLE if os.name == 'nt' else 0 + ) + + # 等待一小段时间确保启动器启动 + time.sleep(2) + + # 退出当前程序 + logger.info("安装即将开始,正在退出当前程序...") + os._exit(0) + + except Exception as e: + raise + + try: + # 在后台线程中执行安装和退出 + install_thread = threading.Thread(target=install_and_exit, daemon=True) + install_thread.start() + + return f"正在启动器中安装版本 {selected_version},程序将自动重启..." + + except Exception as e: + error_msg = f"启动安装进程失败: {str(e)}" + logger.error(error_msg) + return error_msg + + def load_info_with_button_state(): + """载入信息并管理按钮状态""" + import logging + logger = logging.getLogger(__name__) + + logger.info("开始载入版本信息") + + # 先禁用按钮 + yield ( + "正在载入版本信息...", + gr.Button(value="载入中...", interactive=False), + gr.Dropdown(visible=False), + gr.Button(visible=False) + ) + + # 执行载入操作 + result = list_all_versions() + logger.info("版本信息载入操作完成") + yield result + + # 绑定事件 + load_info_btn.click( + fn=load_info_with_button_state, + outputs=[status_text, load_info_btn, version_dropdown, install_selected_btn] + ) + + install_selected_btn.click( + fn=install_selected_version, + inputs=[version_dropdown], + outputs=[status_text] + ) def _create_screen_tab(self) -> None: with gr.Tab("画面"):