feat(bootstrap): 新启动器现在支持安装指定版本与指定补丁

This commit is contained in:
XcantloadX 2025-07-02 20:31:24 +08:00
parent 3f88c3a6c4
commit 456019b5b5
7 changed files with 388 additions and 83 deletions

View File

@ -20,5 +20,5 @@
"venv", "venv",
"**/node_modules" "**/node_modules"
], ],
"python.analysis.diagnosticMode": "workspace" // "python.analysis.diagnosticMode": "workspace"
} }

16
bootstrap/README.md Normal file
View File

@ -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

View File

@ -7,6 +7,10 @@ import locale
import logging import logging
import subprocess import subprocess
import importlib.metadata import importlib.metadata
import argparse
import tempfile
import zipfile
import shutil
from pathlib import Path from pathlib import Path
from collections import deque from collections import deque
from datetime import datetime from datetime import datetime
@ -44,7 +48,8 @@ class Config(TypedDict, total=False):
user_configs: List[UserConfig] user_configs: List[UserConfig]
# 获取当前Python解释器路径 # 获取当前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(): def setup_logging():
""" """
@ -386,10 +391,77 @@ def print_update_notice(current_version: str, latest_version: str):
print() print()
sleep(5) 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: def install_pip_and_ksaa(pip_server: str, check_update: bool = True, install_update: bool = True) -> bool:
""" """
安装和更新pip以及ksaa包 安装和更新pip以及ksaa包
:param pip_server: pip服务器URL :param pip_server: pip服务器URL
:type pip_server: str :type pip_server: str
:param check_update: 是否检查更新 :param check_update: 是否检查更新
@ -400,17 +472,15 @@ def install_pip_and_ksaa(pip_server: str, check_update: bool = True, install_upd
:rtype: bool :rtype: bool
""" """
print_header("安装与更新小助手", color=Color.BLUE) 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 # 升级pip
print_status("更新 pip", status='info') 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): if not run_command(upgrade_pip_command):
return False 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") ksaa_version_str = package_version("ksaa")
# 未安装 # 未安装
if not ksaa_version_str: if not ksaa_version_str:
@ -440,7 +510,7 @@ def load_config() -> Optional[Config]:
""" """
config_path = Path("./config.json") config_path = Path("./config.json")
if not config_path.exists(): if not config_path.exists():
msg = f"配置文件 config.json 不存在,跳过配置加载" msg = "配置文件 config.json 不存在,跳过配置加载"
print_status(msg, status='warning') print_status(msg, status='warning')
logging.warning(msg) logging.warning(msg)
return None return None
@ -503,7 +573,7 @@ def restart_as_admin() -> None:
try: try:
# 使用 ShellExecute 以管理员身份启动程序 # 使用 ShellExecute 以管理员身份启动程序
ret = ctypes.windll.shell32.ShellExecuteW( 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表示成功 if ret > 32: # 返回值大于32表示成功
msg = "正在以管理员身份重启程序..." msg = "正在以管理员身份重启程序..."
@ -567,65 +637,103 @@ def check_admin(config: Config) -> bool:
def run_kaa() -> bool: def run_kaa() -> bool:
""" """
运行KAA程序 运行琴音小助手
:return: 运行是否成功 :return: 运行是否成功
:rtype: bool :rtype: bool
""" """
print_header("运行 KAA", color=Color.GREEN) print_header("运行琴音小助手", color=Color.GREEN)
clear_screen() clear_screen()
# 设置环境变量 # 设置环境变量
os.environ["no_proxy"] = "localhost, 127.0.0.1, ::1" os.environ["no_proxy"] = "localhost, 127.0.0.1, ::1"
# 运行kaa命令 # 运行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 return False
print_header("运行结束", color=Color.GREEN) print_header("运行结束", color=Color.GREEN)
return True 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(): 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() setup_logging()
run_command("title 琴音小助手(运行时请勿关闭此窗口)", verbatim=True, log_output=False) run_command("title 琴音小助手(运行时请勿关闭此窗口)", verbatim=True, log_output=False)
clear_screen() clear_screen()
print_header("琴音小助手启动器") print_header("琴音小助手启动器")
logging.info("启动器已启动。") logging.info("启动器已启动。")
try: try:
# 1. 加载配置文件(提前加载以获取更新设置) # 1. 加载配置文件(提前加载以获取更新设置)
print_header("加载配置", color=Color.BLUE) print_header("加载配置", color=Color.BLUE)
logging.info("加载配置。") logging.info("加载配置。")
config = load_config() config = load_config()
# 2. 获取更新设置 # 2. 获取更新设置
check_update, auto_install_update = get_update_settings(config if config else {"version": 5, "user_configs": []}) 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') print_status("正在寻找最快的 PyPI 镜像源...", status='info')
logging.info("正在寻找最快的 PyPI 镜像源...") logging.info("正在寻找最快的 PyPI 镜像源...")
pip_server = get_working_pip_server() pip_server = get_working_pip_server()
if not pip_server: if not pip_server:
raise RuntimeError("没有找到可用的pip服务器请检查网络连接。") raise RuntimeError("没有找到可用的pip服务器请检查网络连接。")
# 4. 安装和更新pip以及ksaa包 # 5. 处理特殊安装情况
if not install_pip_and_ksaa(pip_server, check_update, auto_install_update): if args.install_from_zip:
raise RuntimeError("依赖安装失败,请检查上面的错误日志。") # 从zip文件安装
print_header("安装补丁", color=Color.BLUE)
# 5. 检查Windows截图权限 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 config:
if not check_admin(config): if not check_admin(config):
raise RuntimeError("权限检查失败。") raise RuntimeError("权限检查失败。")
# 6. 运行KAA # 7. 运行琴音小助手
if not run_kaa(): if not run_kaa():
raise RuntimeError("KAA 主程序运行失败。") raise RuntimeError("琴音小助手主程序运行失败。")
msg = "KAA 已成功运行并退出。" msg = "琴音小助手已退出。"
print_status(msg, status='success') print_status(msg, status='success')
logging.info(msg) logging.info(msg)
@ -634,7 +742,7 @@ def main_launch():
print_status(msg, status='error') print_status(msg, status='error')
print_status("压缩 kaa 目录下的 logs 文件夹并给此窗口截图后一并发送给开发者", status='error') print_status("压缩 kaa 目录下的 logs 文件夹并给此窗口截图后一并发送给开发者", status='error')
logging.critical(msg, exc_info=True) logging.critical(msg, exc_info=True)
finally: finally:
logging.info("启动器运行结束。") logging.info("启动器运行结束。")
wait_key("\n按任意键退出...") wait_key("\n按任意键退出...")
@ -643,4 +751,4 @@ if __name__ == "__main__":
try: try:
main_launch() main_launch()
except KeyboardInterrupt: except KeyboardInterrupt:
print_status("运行结束", status='info') print_status("运行结束。现在可以安全关闭此窗口。", status='info')

View File

@ -12,7 +12,6 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
{ {
UNREFERENCED_PARAMETER(hInstance); UNREFERENCED_PARAMETER(hInstance);
UNREFERENCED_PARAMETER(hPrevInstance); UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(lpCmdLine);
UNREFERENCED_PARAMETER(nCmdShow); UNREFERENCED_PARAMETER(nCmdShow);
// 设置当前目录为程序所在目录 // 设置当前目录为程序所在目录
@ -51,6 +50,12 @@ int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
// 构建命令行 // 构建命令行
std::wstring cmd = pythonPath + L" " + bootstrapPath; std::wstring cmd = pythonPath + L" " + bootstrapPath;
// 如果有命令行参数,将其传递给 bootstrap
if (lpCmdLine && wcslen(lpCmdLine) > 0) {
cmd += L" ";
cmd += lpCmdLine;
}
// 启动信息 // 启动信息
STARTUPINFOW si = { sizeof(si) }; STARTUPINFOW si = { sizeof(si) };

View File

@ -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

View File

@ -41,10 +41,6 @@ env: fetch-submodule
} }
python tools/make_resources.py python tools/make_resources.py
# Build the project using pyinstaller
build: env
pyinstaller -y kotonebot-gr.spec
generate-metadata: env generate-metadata: env
#!{{shebang_python}} #!{{shebang_python}}
# 更新日志 # 更新日志
@ -125,7 +121,7 @@ publish-test: package
# #
build-bootstrap: build-bootstrap:
#!{{shebang_pwsh}} #!{{shebang_pwsh}}
echo "Building bootstrap"... echo "Building bootstrap..."
# 构建 Python # 构建 Python
cd bootstrap cd bootstrap
python -m zipapp kaa-bootstrap python -m zipapp kaa-bootstrap
@ -138,4 +134,7 @@ build-bootstrap:
mv kaa-wrapper/x64/Release/kaa-wrapper.exe ../dist/kaa.exe -fo mv kaa-wrapper/x64/Release/kaa-wrapper.exe ../dist/kaa.exe -fo
} else { } else {
Write-Host "MSBuild not found. Please install Visual Studio or build kaa-wrapper manually." Write-Host "MSBuild not found. Please install Visual Studio or build kaa-wrapper manually."
} }
# Build kaa and bootstrap
build: package build-bootstrap

View File

@ -3,6 +3,9 @@ import traceback
import zipfile import zipfile
import logging import logging
import copy import copy
import sys
import subprocess
import json
from functools import partial from functools import partial
from itertools import chain from itertools import chain
from datetime import datetime, timedelta from datetime import datetime, timedelta
@ -1607,10 +1610,224 @@ class KotoneBotUI:
) )
def _create_whats_new_tab(self) -> None: def _create_whats_new_tab(self) -> None:
"""创建更新日志标签页,并显示最新版本更新内容""" """创建更新标签页"""
with gr.Tab("更新日志"): with gr.Tab("更新"):
from kotonebot.kaa.metadata import WHATS_NEW gr.Markdown("## 版本管理")
gr.Markdown(WHATS_NEW)
# 更新日志
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: def _create_screen_tab(self) -> None:
with gr.Tab("画面"): with gr.Tab("画面"):