From c29ed69c32f003d4d51b9a05159db0c7db09696b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=97=AD=E5=85=B3=E9=99=8D=E7=BA=A7?= <973530837@qq.com> Date: Tue, 10 Nov 2026 10:42:57 +0800 Subject: [PATCH] Revert "feat: support source applications (#201)" This reverts commit a9196ef36ac36075eaff9b2e78ed1a3e5c66404e. --- .gitignore | 3 - .gitmodules | 3 - config/source_applications.yml | 12 - python-backend/pyproject.toml | 2 - python-backend/src/config/set_config.py | 4 +- python-backend/src/headless.py | 10 - python-backend/src/link.py | 21 +- python-backend/src/service/README.md | 5 - python-backend/src/service/app_service.py | 10 - python-backend/src/service/backup_service.py | 3 +- python-backend/src/service/chain_gen.py | 24 -- python-backend/src/service/script_service.py | 28 -- .../src/service/source_app_service.py | 336 --------------- python-backend/src/utils/utils_job.py | 10 - python-backend/src/utils/utils_sub_config.py | 39 +- .../tests/service/test_script_service.py | 43 -- .../tests/service/test_source_app_service.py | 382 ------------------ python-backend/tests/support/headless.py | 1 - .../tests/support/source_chain_smoke.py | 95 ----- .../tests/support/source_install_smoke.py | 49 --- .../tests/support/source_rpc_smoke.py | 130 ------ python-backend/tests/test_headless.py | 23 -- python-backend/tests/utils/test_utils_job.py | 23 -- python-gui/src/gui/cli_job.py | 9 +- python-gui/src/gui/controllers/cli_backup.py | 4 +- .../src/gui/controllers/cli_game_list.py | 5 +- .../src/gui/controllers/source_install.py | 133 ------ python-gui/src/gui/dialogs.py | 22 +- python-gui/src/gui/main_window.py | 12 - python-gui/src/gui/qml/main.qml | 8 +- python-gui/tests/gui/test_cli_run_starter.py | 3 +- python-gui/tests/gui/test_source_install.py | 66 --- rust-gui/src/controllers/game_list.rs | 9 +- rust-gui/src/controllers/mod.rs | 1 - rust-gui/src/dialogs/mod.rs | 1 - rust-gui/src/dialogs/script_config_dialog.rs | 8 - rust-gui/src/dialogs/source_dialog.rs | 270 ------------- rust-gui/src/main_window.rs | 66 +-- rust-gui/tests/main_window.rs | 1 - uv-app-manager | 1 - uv.lock | 113 ------ 41 files changed, 20 insertions(+), 1968 deletions(-) delete mode 100644 config/source_applications.yml delete mode 100644 python-backend/src/service/source_app_service.py delete mode 100644 python-backend/tests/service/test_source_app_service.py delete mode 100644 python-backend/tests/support/source_chain_smoke.py delete mode 100644 python-backend/tests/support/source_install_smoke.py delete mode 100644 python-backend/tests/support/source_rpc_smoke.py delete mode 100644 python-gui/src/gui/controllers/source_install.py delete mode 100644 python-gui/tests/gui/test_source_install.py delete mode 100644 rust-gui/src/dialogs/source_dialog.rs delete mode 160000 uv-app-manager diff --git a/.gitignore b/.gitignore index 1a83bae4..af1982be 100644 --- a/.gitignore +++ b/.gitignore @@ -275,6 +275,3 @@ config/backups/ /docs/rust-feasibility/assessment-startup.json /docs/rust-feasibility/assessment-size.json /startup.json - -# 源码应用的运行时安装目录 -/third_party/ diff --git a/.gitmodules b/.gitmodules index 8e157763..c262e561 100644 --- a/.gitmodules +++ b/.gitmodules @@ -4,6 +4,3 @@ [submodule "updater"] path = updater url = https://github.com/LevelDownRefine/updater.git -[submodule "uv-app-manager"] - path = uv-app-manager - url = https://github.com/LevelDownRefine/uv-app-manager.git diff --git a/config/source_applications.yml b/config/source_applications.yml deleted file mode 100644 index 5330b56b..00000000 --- a/config/source_applications.yml +++ /dev/null @@ -1,12 +0,0 @@ -version: 1 -applications: - ok-ww: - display_name: 鸣潮 - repository: https://github.com/ok-oldking/ok-wuthering-waves.git - release_api: https://api.github.com/repos/ok-oldking/ok-wuthering-waves/releases/latest - python_version: '3.12' - requirements: requirements.txt - entry: main.py - manual_arguments: [] - run_arguments: ['-t', '1', '-e'] - ascii_path: true diff --git a/python-backend/pyproject.toml b/python-backend/pyproject.toml index 42f196b8..009df221 100644 --- a/python-backend/pyproject.toml +++ b/python-backend/pyproject.toml @@ -9,7 +9,6 @@ dependencies = [ "colorama==0.4.6", "psutil==6.1.1", "leveldown-updater", - "source-app-manager", "pycaw==20220416", "pyinstaller==6.19.0", "pywin32==311; sys_platform == 'win32'", @@ -26,7 +25,6 @@ build-backend = "setuptools.build_meta" [tool.uv.sources] leveldown-updater = { path = "../updater", editable = true } -source-app-manager = { path = "../uv-app-manager", editable = true } [tool.setuptools.packages.find] where = ["."] diff --git a/python-backend/src/config/set_config.py b/python-backend/src/config/set_config.py index 8e25410f..8dd9df0a 100644 --- a/python-backend/src/config/set_config.py +++ b/python-backend/src/config/set_config.py @@ -17,7 +17,6 @@ from src.utils import open_in_explorer, utils_launcher from src.utils.utils_dict import get_field from src.utils.utils_sub_config import ( - declared_relative_path, get_script_game_path, load_game_config, resolve_script_path, @@ -537,8 +536,7 @@ def get_game_path_keys(script_name: str, rel: str) -> tuple[str, ...]: return () game = cfg.resources["game"] assert "config" in game and "keys" in game - config_path = declared_relative_path(script_name, game["config"]) - if rel.casefold() != config_path.casefold(): + if rel.casefold() != game["config"].casefold(): return () return tuple(game["keys"]) diff --git a/python-backend/src/headless.py b/python-backend/src/headless.py index ca276476..e649cd2e 100644 --- a/python-backend/src/headless.py +++ b/python-backend/src/headless.py @@ -144,7 +144,6 @@ def update_script( weekly_timeouts, switches, task_options=None, - source_manual_arguments_text=None, ): from src.service.script_service import ScriptEdit @@ -155,7 +154,6 @@ def update_script( weekly_timeouts, switches, {} if task_options is None else task_options, - source_manual_arguments_text, ) return {"script_name": self.service.update_script(edit)} @@ -303,11 +301,6 @@ def rpc_methods(service, *, persistent=True) -> dict: methods = { "app.snapshot": service.app_snapshot, "backup.start": service.start_backup, - "source.install": service.source_apps.start_install, - "source.catalog": service.source_apps.catalog, - "source.install_catalog": service.source_apps.start_catalog_install, - "source.register": service.source_apps.register, - "source.configure": service.source_apps.configure, "restore.start": service.start_restore, "job.poll": service.poll_job, "job.cancel": service.cancel_job, @@ -347,7 +340,6 @@ def rpc_methods(service, *, persistent=True) -> dict: } readonly = { "app.snapshot", - "source.catalog", "job.poll", "update.view", "settings.view", @@ -366,8 +358,6 @@ def rpc_methods(service, *, persistent=True) -> dict: } session_only = { "backup.start", - "source.install", - "source.install_catalog", "restore.start", "job.poll", "job.cancel", diff --git a/python-backend/src/link.py b/python-backend/src/link.py index fae03ce0..e19691f1 100644 --- a/python-backend/src/link.py +++ b/python-backend/src/link.py @@ -65,11 +65,7 @@ def resolve_script_target(script_name: str, target: str) -> dict: return _unavailable("未找到脚本路径") resolved = resolve_script_path(script["script_path"]) if target == "folder": - path = ( - resolve_script_path(script["source_root"]) - if "source_root" in script - else os.path.dirname(resolved) - ) + path = os.path.dirname(resolved) else: path = get_log_dir(script) if path is None: @@ -113,21 +109,6 @@ def resolve_launch_target(script_name: str, target: str) -> dict: resolved = resolve_script_path(path) if not resolved or not os.path.isfile(resolved): return _unavailable("启动文件不存在,请检查配置") - if "source_root" in script: - from src.service.source_app_service import source_launch_command - - try: - arguments = script.get("source_manual_arguments", []) - command = source_launch_command(script, arguments) - except (OSError, ValueError, RuntimeError) as exc: - return _unavailable(str(exc)) - return { - "kind": "command", - "program": command.program, - "args": list(command.arguments), - "cwd": command.working_directory, - "env": {}, - } script_type = script["script_type"] if "script_type" in script else "external" # noqa: SIM401 if script_type != "python": return {"kind": "association", "path": os.path.abspath(resolved)} diff --git a/python-backend/src/service/README.md b/python-backend/src/service/README.md index 319ed601..ef4c3a00 100644 --- a/python-backend/src/service/README.md +++ b/python-backend/src/service/README.md @@ -12,7 +12,6 @@ | `schedule.py`、`run_actions.py` | 运行选项、调度、前后动作与失败重跑 | | `daily_plan.py` | 每日计划、Windows 任务注册与状态回读 | | `backup_service.py` | 配置 ZIP 备份与恢复 | -| `source_app_service.py` | 源码应用安装、登记及启动配置 | | `../link.py`、`../utils/utils_wallpaper.py` | 资源/启动目标、壁纸映射与缓存 | | `../update.py`、`../utils/utils_job.py` | 更新适配与会话、后台任务执行 | @@ -42,10 +41,6 @@ Windows 任务按当前用户和安装目录标识,需管理员权限注册及 覆盖前备份目标文件,逐文件原子替换;不承诺整批事务回滚。失败结果保留完成数量和恢复前 ZIP,供用户核对。归档安全检查与路径校验归后端。 -## 源码应用 - -`AppService.source_apps` 统一提供源码应用目录、安装、登记和配置入口;安装与更新、备份共用后台任务执行器及进度查询。应用安装在助手根目录的 `third_party/<应用标识>/`,通用源码安装与 Python 环境管理由 `uv-app-manager` 提供。 - ## 手动更新 更新内核独立维护于 `updater/`;`src/update.py` 负责助手适配、`UpdateSession` 和独立更新器入口,AppService 只委托。 diff --git a/python-backend/src/service/app_service.py b/python-backend/src/service/app_service.py index 6d775037..3f0abe20 100644 --- a/python-backend/src/service/app_service.py +++ b/python-backend/src/service/app_service.py @@ -12,7 +12,6 @@ - 游戏侧 config 适配器(副本/周几起写脚本自身 config):归 :mod:`src.config.set_config` 模块函数 - 自定义壁纸表(config/wallpaper.json):归 :mod:`src.utils.utils_wallpaper` 模块函数 - 配置备份与恢复(各子脚本 config 打包为 ZIP / 按目录原样回写):归 :mod:`src.service.backup_service` 模块函数 -- 源码应用管理:归 :class:`src.service.source_app_service.SourceAppService` - 助手手动更新(检查 / 下载 / 安装交接):归 :class:`src.update.UpdateService` GUI(MainWindow)与 CLI(各子命令)都只实例化本类,控制器经构造注入持有它; @@ -27,7 +26,6 @@ import src.service.chain_service as chain_service import src.service.daily_plan as daily_plan import src.service.script_service as script_service -import src.service.source_app_service as source_app_service import src.service.task_service as task_service from src.config.native_task_switch import native_task_switch_of from src.config.set_config import ( @@ -105,7 +103,6 @@ def __init__(self, *, frontend: str = "qt"): """装配各 peer;GUI/CLI 入口选择对应发行版的更新服务。""" self._updates = UpdateService(frontend=frontend) self.jobs = JobExecutor() - self.source_apps = source_app_service.SourceAppService(self.jobs) self._update_session = UpdateSession(self._updates, self.jobs) def close(self) -> None: @@ -229,13 +226,6 @@ def script_edit_view(self, script_name: str) -> dict: script = self.get_script(script_name) if script is None: raise InvalidScript("脚本已不存在,请刷新列表") - if "source_root" in script: - script = { - **script, - "source_manual_arguments_text": source_app_service.format_arguments( - script["source_manual_arguments"] - ), - } return { "script_name": script_name, "script": script, diff --git a/python-backend/src/service/backup_service.py b/python-backend/src/service/backup_service.py index 9d16be53..b46e01e7 100644 --- a/python-backend/src/service/backup_service.py +++ b/python-backend/src/service/backup_service.py @@ -18,7 +18,7 @@ from src.config.set_config import get_game_path_keys, iter_backup_paths from src.utils import get_path_under_root from src.utils.utils_io import dump_data_str, load_data, parse_data -from src.utils.utils_sub_config import declared_relative_path, get_script_root_dir +from src.utils.utils_sub_config import get_script_root_dir logger = logging.getLogger(__name__) @@ -51,7 +51,6 @@ def create_backup() -> dict: if root is None: continue for rel in paths: - rel = declared_relative_path(script_name, rel) source = Path(root, rel) if source.is_file(): files[ diff --git a/python-backend/src/service/chain_gen.py b/python-backend/src/service/chain_gen.py index e4895680..b90174c7 100644 --- a/python-backend/src/service/chain_gen.py +++ b/python-backend/src/service/chain_gen.py @@ -9,13 +9,10 @@ 自 ``gui.chain`` 迁出:不依赖 Qt,收编到 service 层便于无头测试与 GUI/CLI 共用。 """ -import base64 import copy import logging from datetime import datetime -from pathlib import Path -from src.service.source_app_service import source_launch_command from src.utils import ( get_path_under_root, safe_path_join, @@ -155,27 +152,6 @@ def generate_chain_config( script["enabled"] = True log_path = resolve_log_path(script) script["log_path"] = str(log_path) if log_path is not None else "" - if "source_root" in script: - arguments = script.get("source_arguments", []) - command = source_launch_command(script, arguments) - script["script_type"] = "external" - # 旧 runner 以程序路径的父目录启动进程;在解释器内切换目录并 - # 执行入口;编码为单个参数,避免旧 runner 拆分路径和引号。 - entry, *args = command.arguments - bootstrap = ( - "import os,runpy,sys\n" - f"os.chdir({command.working_directory!r})\n" - f"sys.argv={[entry, *args]!r}\n" - f"sys.path[0]={str(Path(entry).parent)!r}\n" - f"runpy.run_path({entry!r},run_name='__main__')\n" - ) - encoded = base64.b64encode(bootstrap.encode("utf-8")).decode("ascii") - script["script_path"] = command.program - script["script_arguments"] = ( - f"-c exec(__import__('base64').b64decode('{encoded}'))" - ) - script["launcher_mode"] = False - script["script_process_name"] = [] script.pop("log_analysis_path", None) filtered.append(script) diff --git a/python-backend/src/service/script_service.py b/python-backend/src/service/script_service.py index 51ed2d9b..169403f2 100644 --- a/python-backend/src/service/script_service.py +++ b/python-backend/src/service/script_service.py @@ -40,7 +40,6 @@ class ScriptEdit: weekly_timeouts: list[int | None] # weekly.yml switches: dict[str, bool] # 脚本自身 config task_options: dict[str, bool | str | list[str]] = field(default_factory=dict) - source_manual_arguments_text: str | None = None class InvalidScript(ValueError): @@ -53,10 +52,6 @@ def validate_edit(edit: ScriptEdit, *, scripts: list[dict] | None = None) -> Scr raise InvalidScript("脚本标识不能为空") if not isinstance(edit.display_name, str) or not edit.display_name.strip(): raise InvalidScript("脚本名称不能为空") - if edit.source_manual_arguments_text is not None and not isinstance( - edit.source_manual_arguments_text, str - ): - raise InvalidScript("手动启动参数必须为文本") patch = edit.config_patch if not isinstance(patch, dict) or set(patch) != set( _TEXT_FIELDS + _BOOL_FIELDS + _INT_FIELDS @@ -233,29 +228,6 @@ def update(edit: ScriptEdit) -> str: except ValueError as exc: raise InvalidScript(str(exc)) from exc - if "source_root" in target: - from src.service.source_app_service import ( - parse_arguments, - source_launch_command, - ) - - if edit.config_patch["script_type"] != "external": - raise InvalidScript("源码应用必须使用 external 类型") - try: - arguments = parse_arguments(edit.config_patch["script_arguments"]) - candidate = {**target, **edit.config_patch} - source_launch_command(candidate, arguments) - manual = ( - parse_arguments(edit.source_manual_arguments_text) - if edit.source_manual_arguments_text is not None - else target["source_manual_arguments"] - ) - source_launch_command(candidate, manual) - except (OSError, ValueError, RuntimeError) as exc: - raise InvalidScript(f"源码应用无效: {exc}") from exc - target["source_arguments"] = arguments - target["source_manual_arguments"] = manual - target.update(edit.config_patch) target["display_name"] = edit.display_name new_script_name = get_script_name(target) diff --git a/python-backend/src/service/source_app_service.py b/python-backend/src/service/source_app_service.py deleted file mode 100644 index c505b0f7..00000000 --- a/python-backend/src/service/source_app_service.py +++ /dev/null @@ -1,336 +0,0 @@ -"""源码应用安装服务。""" - -import json -import os -import re -import shlex -import subprocess -from collections.abc import Callable -from pathlib import Path - -from source_app_manager import Application, install, launch_command - -from src.utils import get_path_under_root, get_root_dir -from src.utils.utils_io import load_data -from src.utils.utils_job import InvalidJob - - -def install_source_application( - application_id: str, - repository: str, - revision: str, - python_version: str, - requirements: str, - directory: str, - *, - progress: Callable[[str], None] | None = None, -) -> dict: - """在指定目录安装源码应用,返回应用源码目录。""" - if not isinstance(application_id, str) or not re.fullmatch( - r"[a-zA-Z0-9][a-zA-Z0-9_-]*", application_id - ): - raise InvalidJob("源码应用标识无效") - values = (repository, revision, python_version, requirements, directory) - if any(not isinstance(value, str) or not value.strip() for value in values): - raise InvalidJob("源码应用安装参数不能为空") - root = Path(directory).expanduser() - if not root.is_absolute(): - raise InvalidJob("源码应用目录必须是绝对路径") - target = root / application_id - app = Application(repository, revision, python_version, requirements) - try: - source = install(app, target, progress=progress) - except FileExistsError: - # 安装已完成但注册失败时,允许对同一版本重试注册。 - state_path = target / "install.json" - if not state_path.is_file(): - raise - state = json.loads(state_path.read_text(encoding="utf-8")) - expected = { - "repository": repository, - "revision": revision, - "python_version": python_version, - "requirements": requirements, - } - if state.get("complete") is not True or state.get("application") != expected: - raise - source = target / "app" - executable = ( - source - / ".venv" - / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - ) - if not source.is_dir() or not executable.is_file(): - raise - return {"application_id": application_id, "source": str(source)} - - -def source_catalog() -> dict: - """读取随助手发布的源码应用声明。""" - path = get_path_under_root("config", "source_applications.yml") - data = load_data(path, file_format="yaml") - assert isinstance(data, dict) and data.get("version", 0) == 1 - assert "applications" in data and isinstance(data["applications"], dict) - return data["applications"] - - -def install_catalog_application( - application_id: str, - *, - progress: Callable[[str], None] | None = None, -) -> dict: - """安装声明中的最新稳定版本。""" - catalog = source_catalog() - if not isinstance(application_id, str) or application_id not in catalog: - raise InvalidJob("未知源码应用") - assert application_id in catalog - definition = catalog[application_id] - assert isinstance(definition, dict) - for key in ("repository", "release_api", "python_version", "requirements"): - assert key in definition and isinstance(definition[key], str) - directory = str(Path(get_root_dir()) / "third_party") - if ( - definition.get("ascii_path", False) - and not str(Path(directory) / application_id).isascii() - ): - raise InvalidJob("此应用的安装目录须使用英文路径") - from src.utils import utils_config - from src.utils.utils_sub_config import get_script_name - - config = utils_config.load_config() - assert "script_list" in config - if any(get_script_name(item) == application_id for item in config["script_list"]): - raise InvalidJob( - f"脚本标识已存在: {application_id};请先从助手移除已有同标识脚本" - ) - if progress is not None: - progress("正在检查最新版本") - import requests - - response = requests.get(definition["release_api"], timeout=30) - response.raise_for_status() - release = response.json() - if ( - not isinstance(release, dict) - or not isinstance(release.get("tag_name", ""), str) - or not release.get("tag_name", "") - ): - raise InvalidJob("上游最新版本信息无效") - assert "tag_name" in release - return install_source_application( - application_id, - definition["repository"], - release["tag_name"], - definition["python_version"], - definition["requirements"], - directory, - progress=progress, - ) - - -def format_arguments(arguments: list[str]) -> str: - """将参数列表格式化为当前平台的命令行文本。""" - return ( - subprocess.list2cmdline(arguments) if os.name == "nt" else shlex.join(arguments) - ) - - -def parse_arguments(value: str) -> list[str]: - """按当前平台的命令行规则解析参数,不调用 shell。""" - if not value: - return [] - if os.name != "nt": - return shlex.split(value) - import ctypes - from ctypes import wintypes - - shell = ctypes.WinDLL("shell32", use_last_error=True) - kernel = ctypes.WinDLL("kernel32", use_last_error=True) - shell.CommandLineToArgvW.argtypes = [wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_int)] - shell.CommandLineToArgvW.restype = ctypes.POINTER(wintypes.LPWSTR) - kernel.LocalFree.argtypes = [wintypes.HLOCAL] - kernel.LocalFree.restype = wintypes.HLOCAL - count = ctypes.c_int() - result = shell.CommandLineToArgvW("program " + value, ctypes.byref(count)) - if not result: - raise OSError(ctypes.get_last_error(), "无法解析启动参数") - try: - return [result[index] for index in range(1, count.value)] - finally: - kernel.LocalFree(result) - - -def source_launch_command(script: dict, arguments: list[str]): - """解析已安装源码应用的解释器、入口与工作目录。""" - assert "source_root" in script and "script_path" in script - if not isinstance(arguments, list) or not all( - isinstance(argument, str) for argument in arguments - ): - raise InvalidJob("源码应用参数必须为文本列表") - if not isinstance(script["source_root"], str) or not isinstance( - script["script_path"], str - ): - raise InvalidJob("源码目录和入口必须为文本") - root = Path(script["source_root"]).resolve() - entry = Path(script["script_path"]).resolve() - try: - relative = entry.relative_to(root) - except ValueError as exc: - raise InvalidJob("Python 入口须位于源码目录内") from exc - return launch_command(root, str(relative), arguments) - - -def register_source_application( - application_id: str, - display_name: str, - source_root: str, - script_path: str, - manual_arguments: list[str], - run_arguments: list[str], - replace_existing: bool = False, -) -> dict: - """将已安装源码应用注册到助手。""" - from src.utils import utils_config, utils_weekly - from src.utils.utils_sub_config import default_script_entry, get_script_name - - if not isinstance(application_id, str) or not re.fullmatch( - r"[a-zA-Z0-9][a-zA-Z0-9_-]*", application_id - ): - raise InvalidJob("源码应用标识无效") - if not isinstance(display_name, str) or not display_name.strip(): - raise InvalidJob("源码应用名称不能为空") - if type(replace_existing) is not bool: - raise InvalidJob("替换选项必须是布尔值") - if not isinstance(source_root, str) or not Path(source_root).is_absolute(): - raise InvalidJob("源码目录必须是绝对路径") - if not isinstance(script_path, str) or not Path(script_path).is_absolute(): - raise InvalidJob("Python 入口必须是绝对路径") - if not all( - isinstance(args, list) and all(isinstance(arg, str) for arg in args) - for args in (manual_arguments, run_arguments) - ): - raise InvalidJob("源码应用参数必须为文本列表") - entry = default_script_entry(display_name.strip(), "external", script_path) - entry.update( - script_id=application_id, - source_root=source_root, - source_manual_arguments=manual_arguments, - source_arguments=run_arguments, - script_arguments=format_arguments(run_arguments), - ) - try: - source_launch_command(entry, manual_arguments) - source_launch_command(entry, run_arguments) - except (OSError, ValueError, RuntimeError) as exc: - raise InvalidJob(f"源码应用无效: {exc}") from exc - config = utils_config.load_config() - assert "script_list" in config - existing = next( - ( - index - for index, item in enumerate(config["script_list"]) - if get_script_name(item) == application_id - ), - None, - ) - if existing is None: - config["script_list"].append(entry) - elif replace_existing: - config["script_list"][existing] = entry - else: - raise InvalidJob(f"脚本标识已存在: {application_id}") - utils_config.save_config(config) - utils_weekly.ensure_weekly_entry(application_id) - return {"script_name": application_id, "display_name": display_name.strip()} - - -def configure_source_application( - script_name: str, - script_path: str, - manual_arguments: list[str], - run_arguments: list[str], -) -> dict: - """保存源码应用的入口与两种启动参数。""" - from src.config.set_config import invalidate_config - from src.utils import utils_config - from src.utils.utils_sub_config import get_script_name - - if not isinstance(script_name, str) or not script_name: - raise InvalidJob("脚本标识无效") - if not isinstance(script_path, str) or not Path(script_path).is_absolute(): - raise InvalidJob("Python 入口必须是绝对路径") - config = utils_config.load_config() - assert "script_list" in config - entry = next( - ( - item - for item in config["script_list"] - if get_script_name(item) == script_name - ), - None, - ) - if entry is None or "source_root" not in entry: - raise InvalidJob("源码应用不存在") - if not all( - isinstance(args, list) and all(isinstance(arg, str) for arg in args) - for args in (manual_arguments, run_arguments) - ): - raise InvalidJob("源码应用参数必须为文本列表") - candidate = { - **entry, - "script_path": script_path, - "source_manual_arguments": manual_arguments, - "source_arguments": run_arguments, - "script_arguments": format_arguments(run_arguments), - } - try: - source_launch_command(candidate, manual_arguments) - source_launch_command(candidate, run_arguments) - except (OSError, ValueError, RuntimeError) as exc: - raise InvalidJob(f"源码应用无效: {exc}") from exc - entry.update(candidate) - utils_config.save_config(config) - invalidate_config(script_name) - return {"script_name": script_name} - - -class SourceAppService: - """源码应用入口;安装任务与其他后台任务共用执行器。""" - - def __init__(self, jobs): - self._jobs = jobs - - def start_install( - self, - application_id, - repository, - revision, - python_version, - requirements, - directory, - ) -> dict: - return self._jobs.start( - "source_install", - lambda: install_source_application( - application_id, - repository, - revision, - python_version, - requirements, - directory, - progress=self._jobs.message, - ), - ) - - def start_catalog_install(self, application_id) -> dict: - return self._jobs.start( - "source_install", - lambda: install_catalog_application( - application_id, - progress=self._jobs.message, - ), - ) - - catalog = staticmethod(source_catalog) - register = staticmethod(register_source_application) - configure = staticmethod(configure_source_application) diff --git a/python-backend/src/utils/utils_job.py b/python-backend/src/utils/utils_job.py index 412b8203..c1159c06 100644 --- a/python-backend/src/utils/utils_job.py +++ b/python-backend/src/utils/utils_job.py @@ -21,7 +21,6 @@ def __init__(self): self._kind: str | None = None self._cancelled: Event | None = None self._progress: tuple[int, int] | None = None - self._message: str | None = None self._lock = Lock() @property @@ -42,7 +41,6 @@ def start( self._cancelled = cancelled with self._lock: self._progress = None - self._message = None self._future = self._pool.submit(self._execute, operation) return {"id": self._id} @@ -67,16 +65,8 @@ def poll(self, job_id: str) -> dict: if self._progress is not None: received, total = self._progress snapshot["progress"] = {"received": received, "total": total} - if self._message is not None: - snapshot["message"] = self._message return snapshot - def message(self, message: str) -> None: - """更新后台任务最近一条进度信息。""" - assert isinstance(message, str) - with self._lock: - self._message = message - def progress(self, received: int, total: int) -> None: """工作线程只替换一份进度快照,主线程轮询时读取。""" assert type(received) is int and type(total) is int diff --git a/python-backend/src/utils/utils_sub_config.py b/python-backend/src/utils/utils_sub_config.py index 0bdbac26..898cdbf0 100644 --- a/python-backend/src/utils/utils_sub_config.py +++ b/python-backend/src/utils/utils_sub_config.py @@ -24,7 +24,6 @@ DEFAULT_RUN_TIMEOUT = 3600 """脚本运行默认超时秒数。当 weekly.yml 的 weekly_timeouts 段 无条目或不足 7 格时作为 fallback。""" - # ============================================================ # 主配置加载 # ============================================================ @@ -140,10 +139,6 @@ def get_script_root_dir(script_name: str) -> str | None: config_data = _load_config_yml() for script in config_data.get("script_list", []): if get_script_name(script) == script_name: - if "source_root" in script: - root = script["source_root"] - assert isinstance(root, str) and root - return resolve_script_path(root).replace("\\", "/") script_path = script.get("script_path", "") if not script_path: return None @@ -152,27 +147,6 @@ def get_script_root_dir(script_name: str) -> str | None: return None -def source_relative_path(script: dict, rel_path: str) -> str: - """将源码安装的原生工作目录声明解析到源码根。""" - if "source_root" not in script: - return rel_path - prefix = f"data/apps/{get_script_name(script)}/working/" - if rel_path.startswith(prefix): - return rel_path[len(prefix) :] - return rel_path - - -def declared_relative_path(script_name: str, rel_path: str) -> str: - """解析原生配置和资源声明中的相对路径。""" - prefix = f"data/apps/{script_name}/working/" - if rel_path.startswith(prefix) and os.path.isfile(get_config_yml_path_under_root()): - config_data = _load_config_yml() - for script in config_data.get("script_list", []): - if get_script_name(script) == script_name: - return source_relative_path(script, rel_path) - return rel_path - - def get_script_game_path(script_name: str) -> str: """按脚本唯一标识取条目里手填的游戏路径(config.yml 的 game_path)。 @@ -208,7 +182,7 @@ def get_sub_config_path(script_name: str, rel_path: str) -> str: assert root is not None, ( f"[set_config] 无法解析脚本根目录(config.yml 中无 {script_name} 或 script_path 为空)" ) - return safe_path_join(root, declared_relative_path(script_name, rel_path)) + return safe_path_join(root, rel_path) # ============================================================ @@ -407,13 +381,10 @@ def resolve_log_path(script: dict, *, analysis: bool = False) -> Path | None: assert "script_path" in script if not script["script_path"]: return None - if "source_root" in script: - root = Path(resolve_script_path(script["source_root"])) - else: - root = Path( - resolve_script_path(script["script_path"]).replace("\\", "/") - ).parent - path = root / source_relative_path(script, raw) + path = ( + Path(resolve_script_path(script["script_path"]).replace("\\", "/")).parent + / raw + ) if any(char in str(path.parent) for char in "*?") or "**" in path.name: raise ValueError(f"{key} 仅允许文件名含通配符") return path diff --git a/python-backend/tests/service/test_script_service.py b/python-backend/tests/service/test_script_service.py index f4e619bb..fc67ae7f 100644 --- a/python-backend/tests/service/test_script_service.py +++ b/python-backend/tests/service/test_script_service.py @@ -90,49 +90,6 @@ def _seed(self, edit): class TestScriptEdit(ScriptServiceTestBase): - def test_source_arguments_saved_and_invalid_entry_rejected(self): - directory = self.config.parent / "source" - directory.mkdir() - entry = directory / "main.py" - entry.touch() - executable = ( - directory - / ".venv" - / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - ) - executable.parent.mkdir(parents=True) - executable.touch() - config = utils_config.load_config() - item = config["script_list"][0] - item.update( - script_id="BetterGI", - source_root=str(directory), - script_path=str(entry), - source_manual_arguments=[], - source_arguments=[], - ) - utils_config.save_config(config) - edit = replace( - self.edit(script_path=str(entry), script_arguments='--label "hello world"'), - source_manual_arguments_text='--manual "two words"', - ) - self.service.update_script(edit) - saved = self.service.get_script("BetterGI") - self.assertEqual(saved["source_arguments"], ["--label", "hello world"]) - self.assertEqual(saved["source_manual_arguments"], ["--manual", "two words"]) - original = self.config.read_bytes() - with self.assertRaises(InvalidScript): - self.service.update_script( - replace( - edit, - config_patch={ - **edit.config_patch, - "script_path": str(directory.parent / "outside.py"), - }, - ) - ) - self.assertEqual(self.config.read_bytes(), original) - def test_no_log_settings_round_trip(self): for seconds, retries in ((300, 1), (0, 0)): with self.subTest(seconds=seconds, retries=retries): diff --git a/python-backend/tests/service/test_source_app_service.py b/python-backend/tests/service/test_source_app_service.py deleted file mode 100644 index cba21c28..00000000 --- a/python-backend/tests/service/test_source_app_service.py +++ /dev/null @@ -1,382 +0,0 @@ -import json -import os -import shlex -import subprocess -import sys -import tempfile -import unittest -import zipfile -from pathlib import Path -from unittest.mock import Mock, patch - -from src.config.set_config import get_game_path_keys -from src.service import backup_service -from src.service.chain_gen import generate_chain_config -from src.service.source_app_service import ( - SourceAppService, - configure_source_application, - format_arguments, - install_catalog_application, - install_source_application, - parse_arguments, - register_source_application, -) -from src.utils.utils_io import load_data -from src.utils.utils_job import InvalidJob -from src.utils.utils_sub_config import ( - get_script_path, - get_script_root_dir, - get_sub_config_path, - resolve_log_path, -) - - -class SourceAppServiceTests(unittest.TestCase): - def test_install_job_uses_shared_executor_and_progress(self): - jobs = Mock() - jobs.start.return_value = {"id": "job"} - service = SourceAppService(jobs) - with patch( - "src.service.source_app_service.install_catalog_application", - return_value={"source": "root"}, - ) as install: - self.assertEqual(service.start_catalog_install("app"), {"id": "job"}) - kind, operation = jobs.start.call_args.args - self.assertEqual(kind, "source_install") - self.assertEqual(operation(), {"source": "root"}) - install.assert_called_once_with("app", progress=jobs.message) - - def test_argument_round_trip(self): - arguments = ["--name", "hello world", "", 'quote"inside', "C:/space path/"] - self.assertEqual(parse_arguments(format_arguments(arguments)), arguments) - - def test_completed_install_can_be_registered_after_retry(self): - with tempfile.TemporaryDirectory() as directory: - target = Path(directory) / "example" - source = target / "app" - executable = ( - source - / ".venv" - / ("Scripts/python.exe" if os.name == "nt" else "bin/python") - ) - executable.parent.mkdir(parents=True) - executable.touch() - identity = { - "repository": "https://example.com/a.git", - "revision": "v1", - "python_version": "3.12", - "requirements": "requirements.txt", - } - (target / "install.json").write_text( - json.dumps({"application": identity, "complete": True}), - encoding="utf-8", - ) - with patch( - "src.service.source_app_service.install", - side_effect=FileExistsError("installed"), - ): - result = install_source_application( - "example", - identity["repository"], - "v1", - "3.12", - "requirements.txt", - directory, - ) - self.assertEqual(result["source"], str(source)) - with self.assertRaises(FileExistsError): - install_source_application( - "example", - identity["repository"], - "v2", - "3.12", - "requirements.txt", - directory, - ) - - def test_configure_updates_entry_and_preserves_identity(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - script = root / "alternate.py" - script.touch() - executable = "Scripts/python.exe" if os.name == "nt" else "bin/python" - interpreter = root / ".venv" / executable - interpreter.parent.mkdir(parents=True) - interpreter.touch() - entry = { - "display_name": "鸣潮", - "script_id": "ok-ww", - "script_path": str(root / "main.py"), - "source_root": str(root), - "unrelated": "keep", - } - config = {"script_list": [entry]} - with ( - patch("src.utils.utils_config.load_config", return_value=config), - patch("src.utils.utils_config.save_config") as save, - patch("src.config.set_config.invalidate_config") as invalidate, - ): - configure_source_application( - "ok-ww", str(script), ["--gui"], ["-t", "1", "-e"] - ) - self.assertEqual(entry["script_path"], str(script)) - self.assertEqual(entry["source_manual_arguments"], ["--gui"]) - self.assertEqual(entry["source_arguments"], ["-t", "1", "-e"]) - self.assertEqual(entry["unrelated"], "keep") - save.assert_called_once_with(config) - invalidate.assert_called_once_with("ok-ww") - - def test_catalog_install_selects_latest_tag_and_checks_path(self): - definition = { - "repository": "https://example.org/repo.git", - "release_api": "https://example.org/latest", - "python_version": "3.12", - "requirements": "requirements.txt", - "ascii_path": True, - } - with ( - tempfile.TemporaryDirectory() as directory, - patch( - "src.service.source_app_service.source_catalog", - return_value={"ok-ww": definition}, - ), - patch("requests.get") as request, - patch( - "src.service.source_app_service.get_root_dir", return_value=directory - ) as root, - patch( - "src.utils.utils_config.load_config", return_value={"script_list": []} - ), - patch( - "src.service.source_app_service.install_source_application", - return_value={"ok": True}, - ) as install, - ): - request.return_value.json.return_value = {"tag_name": "v2"} - self.assertEqual(install_catalog_application("ok-ww"), {"ok": True}) - self.assertEqual(install.call_args.args[2], "v2") - self.assertEqual( - install.call_args.args[5], str(Path(directory) / "third_party") - ) - root.return_value = str(Path(directory) / "中文") - with self.assertRaises(InvalidJob): - install_catalog_application("ok-ww") - - def test_catalog_rejects_existing_script_before_network(self): - definition = { - "repository": "https://example.org/repo.git", - "release_api": "https://example.org/latest", - "python_version": "3.12", - "requirements": "requirements.txt", - } - with ( - tempfile.TemporaryDirectory() as directory, - patch( - "src.service.source_app_service.get_root_dir", return_value=directory - ), - patch( - "src.service.source_app_service.source_catalog", - return_value={"ok-ww": definition}, - ), - patch( - "src.utils.utils_config.load_config", - return_value={ - "script_list": [ - {"display_name": "鸣潮", "script_path": "ok-ww.exe"} - ] - }, - ), - patch("requests.get") as request, - ): - with self.assertRaisesRegex(InvalidJob, "脚本标识已存在"): - install_catalog_application("ok-ww") - request.assert_not_called() - - def test_existing_identity_requires_explicit_replacement(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - script = root / "main.py" - script.touch() - executable = "Scripts/python.exe" if os.name == "nt" else "bin/python" - interpreter = root / ".venv" / executable - interpreter.parent.mkdir(parents=True) - interpreter.touch() - existing = {"display_name": "鸣潮", "script_path": "ok-ww.exe"} - config = {"script_list": [existing]} - with ( - patch("src.utils.utils_config.load_config", return_value=config), - patch("src.utils.utils_config.save_config") as save, - patch("src.utils.utils_weekly.ensure_weekly_entry"), - ): - with self.assertRaises(InvalidJob): - register_source_application( - "ok-ww", "鸣潮", str(root), str(script), [], [] - ) - save.assert_not_called() - self.assertIs(config["script_list"][0], existing) - register_source_application( - "ok-ww", "鸣潮", str(root), str(script), [], [], True - ) - self.assertEqual(config["script_list"][0]["source_root"], str(root)) - - def test_register_keeps_stable_identity_and_separate_arguments(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - script = root / "main.py" - script.touch() - executable = "Scripts/python.exe" if os.name == "nt" else "bin/python" - interpreter = root / ".venv" / executable - interpreter.parent.mkdir(parents=True) - interpreter.touch() - config = {"script_list": []} - with ( - patch("src.utils.utils_config.load_config", return_value=config), - patch("src.utils.utils_config.save_config") as save, - patch("src.utils.utils_weekly.ensure_weekly_entry") as weekly, - ): - result = register_source_application( - "ok-ww", - "鸣潮", - str(root), - str(script), - ["--help"], - ["-t", "1", "-e"], - ) - self.assertEqual(result["script_name"], "ok-ww") - entry = config["script_list"][0] - self.assertEqual(entry["script_id"], "ok-ww") - self.assertEqual(entry["source_manual_arguments"], ["--help"]) - self.assertEqual(entry["source_arguments"], ["-t", "1", "-e"]) - save.assert_called_once_with(config) - weekly.assert_called_once_with("ok-ww") - - def test_source_config_and_log_use_source_root(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - entry = root / "nested" / "main.py" - entry.parent.mkdir() - entry.touch() - script = { - "display_name": "鸣潮", - "script_id": "ok-ww", - "script_path": str(entry), - "source_root": str(root), - } - config = root / "config.yml" - config.touch() - with ( - patch( - "src.utils.utils_sub_config._load_config_yml", - return_value={"script_list": [script]}, - ), - patch( - "src.utils.utils_sub_config.get_config_yml_path_under_root", - return_value=str(config), - ), - ): - self.assertEqual(Path(get_script_path("ok-ww")), entry) - self.assertEqual(Path(get_script_root_dir("ok-ww")), root) - self.assertEqual( - Path( - get_sub_config_path( - "ok-ww", "data/apps/ok-ww/working/configs/DailyTask.json" - ) - ), - root / "configs" / "DailyTask.json", - ) - self.assertEqual( - resolve_log_path(script), root / "logs" / "ok-script.log" - ) - self.assertEqual( - get_game_path_keys("ok-ww", "configs/devices.json"), - ("pc_full_path",), - ) - config_dir = root / "configs" - config_dir.mkdir() - (config_dir / "DailyTask.json").write_text("{}", encoding="utf-8") - backup_dir = root / "backups" - backup_dir.mkdir() - with ( - patch.object( - backup_service, - "iter_backup_paths", - return_value={"ok-ww": ("data/apps/ok-ww/working/configs",)}, - ), - patch.object( - backup_service, - "get_path_under_root", - return_value=str(backup_dir), - ), - ): - result = backup_service.create_backup() - with zipfile.ZipFile(result["path"]) as archive: - self.assertIn( - "scripts/ok-ww/configs/DailyTask.json", archive.namelist() - ) - - def test_chain_uses_source_interpreter_and_root(self): - with tempfile.TemporaryDirectory() as directory: - root = Path(directory) - script = root / "sub" / "main.py" - script.parent.mkdir() - script.write_text( - "import json, os, sys\n" - "from pathlib import Path\n" - "Path('result.json').write_text(json.dumps([os.getcwd(), sys.argv[1:], sys.path[0]]))\n", - encoding="utf-8", - ) - executable = "Scripts/python.exe" if os.name == "nt" else "bin/python" - interpreter = root / ".venv" / executable - interpreter.parent.mkdir(parents=True) - interpreter.touch() - output = str(root / "chain.yml") - entry = { - "display_name": "Example", - "script_path": str(script), - "source_root": str(root), - "source_arguments": ["hello world"], - } - generate_chain_config( - {"script_list": [entry]}, {"Example"}, out_path=output - ) - chain = load_data(output, file_format="yaml") - generated = chain["script_list"][0] - self.assertEqual(generated["script_path"], str(interpreter)) - self.assertTrue(generated["script_arguments"].startswith("-c ")) - self.assertNotIn("execution_program", generated) - self.assertFalse(generated["launcher_mode"]) - subprocess.run( - [ - getattr(sys, "_base_executable", sys.executable), - *shlex.split(generated["script_arguments"], posix=False), - ], - cwd=root.parent, - check=True, - ) - cwd, arguments, import_path = json.loads((root / "result.json").read_text()) - self.assertEqual(Path(cwd).resolve(), root.resolve()) - self.assertEqual(arguments, ["hello world"]) - self.assertEqual(Path(import_path).resolve(), script.parent.resolve()) - - def test_install_uses_application_directory(self): - with tempfile.TemporaryDirectory() as directory: - target = Path(directory) / "ok-ww" - with patch( - "src.service.source_app_service.install", return_value=target / "app" - ) as mock_install: - result = install_source_application( - "ok-ww", - "https://example.org/repo.git", - "v1", - "3.12", - "requirements.txt", - directory, - ) - self.assertEqual(result["source"], str(target / "app")) - self.assertEqual(mock_install.call_args.args[1], target) - - def test_rejects_invalid_application_id(self): - with tempfile.TemporaryDirectory() as directory, self.assertRaises(InvalidJob): - install_source_application( - "../escape", "repo", "v1", "3.12", "requirements.txt", directory - ) diff --git a/python-backend/tests/support/headless.py b/python-backend/tests/support/headless.py index b3b1975f..f2a75585 100644 --- a/python-backend/tests/support/headless.py +++ b/python-backend/tests/support/headless.py @@ -36,7 +36,6 @@ def __init__(self, root: Path): "daily_task_list.yml", "weekly_task_list.yml", "script_resources.yml", - "source_applications.yml", "task_switch_list.yml", "MAA任务.json", "ZZZ一条龙.yml", diff --git a/python-backend/tests/support/source_chain_smoke.py b/python-backend/tests/support/source_chain_smoke.py deleted file mode 100644 index 3220427a..00000000 --- a/python-backend/tests/support/source_chain_smoke.py +++ /dev/null @@ -1,95 +0,0 @@ -"""实机验证源码应用脚本链的参数和工作目录。""" - -import json -import subprocess -import sys -import tempfile -import time -from pathlib import Path - -import psutil - -from src.service.chain_gen import generate_chain_config -from src.utils.utils_io import load_data - - -def main(): - project = Path(__file__).resolve().parents[3] - sys.path.insert(0, str(project / "runner")) - from script_chainer.config.script_config import ScriptConfig - from script_chainer.win_exe.script_runner import _launch_script - - with tempfile.TemporaryDirectory(prefix="odh-source-chain-") as directory: - root = Path(directory) - subprocess.run( - [ - str(project / ".venv" / "Scripts" / "uv.exe"), - "venv", - "--python", - getattr(sys, "_base_executable", sys.executable), - str(root / ".venv"), - ], - check=True, - ) - script = root / "main.py" - script.write_text( - "import json, os, sys\n" - "from pathlib import Path\n" - "Path('result.json').write_text(json.dumps([os.getcwd(), sys.argv[1:]]))\n", - encoding="utf-8", - ) - entry = { - "display_name": "Source test", - "script_path": str(script), - "source_root": str(root), - "source_arguments": [ - "hello world", - "", - 'quote"here', - "中文", - "line\nbreak", - ], - "check_done": "script_closed", - "kill_game_after_done": False, - } - chain = str(root / "chain.yml") - generate_chain_config({"script_list": [entry]}, {"Source test"}, out_path=chain) - subprocess.run( - [sys.executable, str(project / "runner" / "launcher.py"), "--chain", chain], - cwd=project, - check=True, - timeout=60, - ) - cwd, arguments = json.loads((root / "result.json").read_text()) - assert Path(cwd).resolve() == root.resolve() - assert arguments == ["hello world", "", 'quote"here', "中文", "line\nbreak"] - - script.write_text( - "import subprocess, sys, time\n" - "from pathlib import Path\n" - "p=subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(60)'])\n" - "Path('child.pid').write_text(str(p.pid))\n" - "time.sleep(60)\n", - encoding="utf-8", - ) - generated = load_data(chain, file_format="yaml")["script_list"][0] - generated["script_path"] = str(root / ".venv" / "Scripts" / "python.exe") - manager = _launch_script(ScriptConfig(**generated)) - try: - marker = root / "child.pid" - deadline = time.monotonic() + 15 - while not marker.exists() and time.monotonic() < deadline: - time.sleep(0.05) - assert marker.exists(), "子进程未启动" - child_pid = int(marker.read_text()) - process = manager.process - assert process is not None - manager.kill() - assert process.poll() is not None - assert not psutil.pid_exists(child_pid), "子进程未退出" - finally: - manager.kill() - - -if __name__ == "__main__": - main() diff --git a/python-backend/tests/support/source_install_smoke.py b/python-backend/tests/support/source_install_smoke.py deleted file mode 100644 index dbd943e3..00000000 --- a/python-backend/tests/support/source_install_smoke.py +++ /dev/null @@ -1,49 +0,0 @@ -"""在临时目录实际安装最小源码项目并运行。""" - -import os -import subprocess -import tempfile -from pathlib import Path - -from dulwich import porcelain -from source_app_manager import launch_command - -from src.service.source_app_service import install_source_application - - -def main(): - with tempfile.TemporaryDirectory(prefix="odh-source-smoke-") as directory: - root = Path(directory) - repository = root / "repository" - repository.mkdir() - porcelain.init(repository).close() - (repository / "requirements.txt").touch() - (repository / "main.py").write_text( - "import sys\nprint(repr(sys.argv[1:]))\n", encoding="utf-8" - ) - porcelain.add(repository, ["requirements.txt", "main.py"]) - porcelain.commit( - repository, - message=b"initial", - author=b"Test ", - committer=b"Test ", - ) - porcelain.tag_create(repository, "v1") - if os.name == "nt": - os.environ["PATH"] = r"C:\Windows\System32;C:\Windows" - result = install_source_application( - "example", str(repository), "v1", "3.12", "requirements.txt", str(root) - ) - command = launch_command(Path(result["source"]), "main.py", ["hello world"]) - process = subprocess.run( - [command.program, *command.arguments], - cwd=command.working_directory, - check=True, - capture_output=True, - text=True, - ) - assert process.stdout.strip() == "['hello world']" - - -if __name__ == "__main__": - main() diff --git a/python-backend/tests/support/source_rpc_smoke.py b/python-backend/tests/support/source_rpc_smoke.py deleted file mode 100644 index cdf83c0f..00000000 --- a/python-backend/tests/support/source_rpc_smoke.py +++ /dev/null @@ -1,130 +0,0 @@ -"""实机验证源码安装不会污染无头协议输出。""" - -import json -import os -import subprocess -import tempfile -import time -from pathlib import Path - -from dulwich import porcelain - -from tests.support.headless import PROJECT_ROOT, HeadlessFixture - - -def main(): - with tempfile.TemporaryDirectory(prefix="odh-source-rpc-") as directory: - root = Path(directory) - repository = root / "repository" - repository.mkdir() - porcelain.init(repository).close() - (repository / "requirements.txt").touch() - (repository / "main.py").write_text( - "import sys\nprint(repr(sys.argv[1:]))\n", encoding="utf-8" - ) - porcelain.add(repository, ["requirements.txt", "main.py"]) - porcelain.commit( - repository, - message=b"initial", - author=b"Test ", - committer=b"Test ", - ) - porcelain.tag_create(repository, "v1") - executable = os.environ.get("ODH_SOURCE_CLI") - fixture_root = Path(executable).parent if executable else root / "helper" - fixture_root.mkdir(exist_ok=True) - fixture = HeadlessFixture(fixture_root) - environment = os.environ.copy() - if os.name == "nt": - environment["PATH"] = r"C:\Windows\System32;C:\Windows" - with subprocess.Popen( - [executable, "serve", "--stdio"] - if executable - else fixture.command("serve", "--stdio"), - cwd=PROJECT_ROOT, - env=environment, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, - text=True, - encoding="utf-8", - ) as process: - assert process.stdin is not None and process.stdout is not None - - def request(method, params): - payload = { - "jsonrpc": "2.0", - "id": 1, - "method": method, - "params": params, - } - process.stdin.write(json.dumps(payload) + "\n") - process.stdin.flush() - response = json.loads(process.stdout.readline()) - assert "result" in response, response - return response["result"] - - job = request( - "source.install", - { - "application_id": "test-source", - "repository": str(repository), - "revision": "v1", - "python_version": "3.12", - "requirements": "requirements.txt", - "directory": str(root), - }, - ) - deadline = time.monotonic() + 180 - while True: - result = request("job.poll", {"job_id": job["id"]}) - if result["state"] != "running": - break - if time.monotonic() >= deadline: - raise TimeoutError("源码安装未完成") - time.sleep(0.25) - assert result["state"] == "succeeded", result - source = Path(result["result"]["source"]) - request( - "source.register", - { - "application_id": "test-source", - "display_name": "源码测试", - "source_root": str(source), - "script_path": str(source / "main.py"), - "manual_arguments": ["hello world"], - "run_arguments": [], - }, - ) - command = request( - "script.launch_target", - {"script_name": "test-source", "target": "script"}, - ) - completed = subprocess.run( - [command["program"], *command["args"]], - cwd=command["cwd"], - check=True, - capture_output=True, - text=True, - ) - assert completed.stdout.strip() == "['hello world']" - request( - "source.configure", - { - "script_name": "test-source", - "script_path": str(source / "main.py"), - "manual_arguments": ["changed"], - "run_arguments": ["scheduled"], - }, - ) - command = request( - "script.launch_target", - {"script_name": "test-source", "target": "script"}, - ) - assert command["args"][-1] == "changed" - process.stdin.close() - assert process.wait(timeout=15) == 0 - - -if __name__ == "__main__": - main() diff --git a/python-backend/tests/test_headless.py b/python-backend/tests/test_headless.py index c54be40d..91ae446a 100644 --- a/python-backend/tests/test_headless.py +++ b/python-backend/tests/test_headless.py @@ -48,19 +48,6 @@ def mock_service(): for name in dir(AppService): if not name.startswith("_") and callable(getattr(AppService, name)): service.attach_mock(create_autospec(getattr(instance, name)), name) - from src.service.source_app_service import SourceAppService - - source_apps = Mock(spec=SourceAppService) - source_instance = object.__new__(SourceAppService) - for name in ( - "start_install", - "start_catalog_install", - "catalog", - "register", - "configure", - ): - source_apps.attach_mock(create_autospec(getattr(source_instance, name)), name) - service.source_apps = source_apps service.jobs = SimpleNamespace(running=False) return service @@ -1191,16 +1178,6 @@ def test_failed_save_returns_no_launch_target(self): class ProtocolValidationTests(unittest.TestCase): - def test_source_install_requires_persistent_session(self): - service = mock_service() - response = dispatch_to_serializable( - json.dumps(request("source.install")), - methods=rpc_methods(service, persistent=False), - deserializer=_parse_json, - ) - self.assertEqual(response["error"]["code"], -32600) - service.source_apps.start_install.assert_not_called() - def test_library_binds_positional_and_default_parameters(self): service = mock_service() service.select_daily.return_value = None diff --git a/python-backend/tests/utils/test_utils_job.py b/python-backend/tests/utils/test_utils_job.py index 4b507a4e..f404043b 100644 --- a/python-backend/tests/utils/test_utils_job.py +++ b/python-backend/tests/utils/test_utils_job.py @@ -8,29 +8,6 @@ class JobExecutorTests(unittest.TestCase): - def test_message_is_visible_and_cleared_for_next_job(self): - jobs = JobExecutor() - release = Event() - entered = Event() - self.addCleanup(jobs.close) - - def operation(): - jobs.message("正在安装依赖") - entered.set() - if not release.wait(10): - raise TimeoutError("测试任务未释放") - return {} - - self.addCleanup(release.set) - task = jobs.start("source_install", operation) - self.assertTrue(entered.wait(5)) - self.assertEqual(jobs.poll(task["id"])["message"], "正在安装依赖") - release.set() - jobs._future.result(timeout=5) - next_task = jobs.start("next", lambda: {}) - jobs.close() - self.assertNotIn("message", jobs.poll(next_task["id"])) - def test_cooperative_cancellation_retains_cancelled_result(self): jobs = JobExecutor() self.addCleanup(jobs.close) diff --git a/python-gui/src/gui/cli_job.py b/python-gui/src/gui/cli_job.py index 539d1ec2..221fa105 100644 --- a/python-gui/src/gui/cli_job.py +++ b/python-gui/src/gui/cli_job.py @@ -7,7 +7,6 @@ class CliJob(QObject): progress = Signal(object, object) - message = Signal(str) succeeded = Signal(object) failed = Signal(object) cancelled = Signal() @@ -26,7 +25,7 @@ def __init__(self, session, parent=None, interval_ms=400): self._timer.setInterval(interval_ms) self._timer.timeout.connect(self._poll) - def start(self, method, params=None, *, kind=None): + def start(self, method, params=None): """只提交一次;开始响应丢失也不重放任务。""" if self.active: return @@ -36,7 +35,7 @@ def start(self, method, params=None, *, kind=None): ) return self.active = True - self.kind = kind if kind is not None else method.removesuffix(".start") + self.kind = method.removesuffix(".start") self.job_id = None self._generation += 1 generation = self._generation @@ -74,8 +73,6 @@ def received(value): self.progress.emit( value["progress"]["received"], value["progress"]["total"] ) - if "message" in value: - self.message.emit(value["message"]) state = value["state"] if state == "running": self._timer.start() @@ -154,8 +151,6 @@ def validate_job(value, job_id, kind): for key in ("received", "total") ): raise ValueError("后台任务进度无效") - if "message" in value and not isinstance(value["message"], str): - raise ValueError("后台任务进度信息无效") if value["state"] == "succeeded" and "result" not in value: raise ValueError("后台任务结果缺失") if value["state"] == "failed" and ( diff --git a/python-gui/src/gui/controllers/cli_backup.py b/python-gui/src/gui/controllers/cli_backup.py index f1d1641b..0b6b088d 100644 --- a/python-gui/src/gui/controllers/cli_backup.py +++ b/python-gui/src/gui/controllers/cli_backup.py @@ -11,9 +11,7 @@ def __init__(self, title): super().__init__() self.setWindowTitle(title) layout = QVBoxLayout(self) - self.label = QLabel(title + ",请等待完成…", self) - self.label.setWordWrap(True) - layout.addWidget(self.label) + layout.addWidget(QLabel(title + ",请等待完成…", self)) self.set_pending(True) diff --git a/python-gui/src/gui/controllers/cli_game_list.py b/python-gui/src/gui/controllers/cli_game_list.py index 26fd5274..64c2e7a2 100644 --- a/python-gui/src/gui/controllers/cli_game_list.py +++ b/python-gui/src/gui/controllers/cli_game_list.py @@ -226,10 +226,7 @@ def saved(result): self._on_reload() self._toast(f"已保存 {edit.display_name} 配置") - params = asdict(edit) - if params["source_manual_arguments_text"] is None: - del params["source_manual_arguments_text"] - self._session.call("script.edit_save", params, saved, rejected) + self._session.call("script.edit_save", asdict(edit), saved, rejected) dialog.saveRequested.connect(save) dialog.exec() diff --git a/python-gui/src/gui/controllers/source_install.py b/python-gui/src/gui/controllers/source_install.py deleted file mode 100644 index 05fea40a..00000000 --- a/python-gui/src/gui/controllers/source_install.py +++ /dev/null @@ -1,133 +0,0 @@ -"""源码应用安装界面及后台任务编排。""" - -from pathlib import Path - -from PySide6.QtCore import QObject, QTimer -from PySide6.QtWidgets import QInputDialog - -from gui.cli_job import CliJob -from gui.controllers.cli_backup import JobDialog - - -class SourceInstallController(QObject): - def __init__(self, service, session, toast, reload_games, parent=None): - super().__init__(parent) - self.service = service - self.session = session - self.toast = toast - self.reload_games = reload_games - self.dialog = None - self.job = None - self.timer = None - - def start(self): - if self.dialog is not None or (self.session is not None and self.session.busy): - self.toast("已有后台操作,请稍候") - return - if self.session is None: - try: - self._choose(self.service.source_apps.catalog()) - except (OSError, ValueError) as exc: - self.toast(str(exc)) - else: - self.session.call("source.catalog", {}, self._choose, self._failure) - - def _choose(self, catalog): - if not isinstance(catalog, dict) or not catalog: - raise ValueError("源码应用列表为空或无效") - names = list(catalog) - labels = [catalog[name]["display_name"] for name in names] - selected, accepted = QInputDialog.getItem( - None, "安装源码应用", "选择应用", labels, 0, False - ) - if not accepted: - return - application_id = names[labels.index(selected)] - definition = catalog[application_id] - self.dialog = JobDialog("正在安装源码应用") - self.dialog.show() - if self.session is not None: - self.job = CliJob(self.session, self) - self.job.message.connect(self._message) - self.job.failed.connect(self._failure) - self.job.succeeded.connect( - lambda result: self._installed(application_id, definition, result) - ) - self.job.start( - "source.install_catalog", - {"application_id": application_id}, - kind="source_install", - ) - else: - try: - job_id = self.service.source_apps.start_catalog_install(application_id)[ - "id" - ] - except (OSError, ValueError) as exc: - self._failure(exc) - return - self.timer = QTimer(self) - self.timer.setInterval(400) - - def poll(): - snapshot = self.service.jobs.poll(job_id) - if "message" in snapshot: - self._message(snapshot["message"]) - if snapshot["state"] != "running": - self.timer.stop() - if snapshot["state"] == "succeeded": - self._installed(application_id, definition, snapshot["result"]) - else: - self._failure(ValueError(snapshot.get("error", "安装失败"))) - - self.timer.timeout.connect(poll) - self.timer.start() - - def _message(self, message): - if self.dialog is not None: - self.dialog.label.setText(message[-500:]) - - def _installed(self, application_id, definition, result): - if not isinstance(result, dict) or not isinstance(result.get("source"), str): - self._failure(ValueError("安装结果无效")) - return - source = result["source"] - params = { - "application_id": application_id, - "display_name": definition["display_name"], - "source_root": source, - "script_path": str(Path(source) / definition["entry"]), - "manual_arguments": definition["manual_arguments"], - "run_arguments": definition["run_arguments"], - } - if self.session is not None: - self.session.call( - "source.register", params, self._registered, self._failure - ) - else: - try: - self._registered(self.service.source_apps.register(**params)) - except (OSError, ValueError) as exc: - self._failure(exc) - - def _registered(self, result): - self._close() - self.reload_games() - self.toast("源码应用安装完成") - - def _failure(self, failure): - self._close() - self.toast(getattr(failure, "message", str(failure))) - - def _close(self): - if self.dialog is not None: - self.dialog.set_pending(False) - self.dialog.close() - self.dialog.deleteLater() - self.dialog = None - if self.job is not None: - self.job.deleteLater() - self.job = None - if self.timer is not None: - self.timer.deleteLater() - self.timer = None diff --git a/python-gui/src/gui/dialogs.py b/python-gui/src/gui/dialogs.py index 275cdb1f..942194d1 100644 --- a/python-gui/src/gui/dialogs.py +++ b/python-gui/src/gui/dialogs.py @@ -707,13 +707,7 @@ def _path_press(event): ) grid.addWidget(self._make_label("游戏参数:"), 8, 0) grid.addWidget(self.game_args_input, 8, 1, 1, 2) - self.manual_args_label = self._make_label("手动启动参数:") - self.manual_args_input = self._make_line_edit( - placeholder="可选,手动打开时使用" - ) - grid.addWidget(self.manual_args_label, 9, 0) - grid.addWidget(self.manual_args_input, 9, 1, 1, 2) - timeout_row = 10 + timeout_row = 9 timeout_grid = QGridLayout() timeout_grid.setHorizontalSpacing(4) timeout_grid.setVerticalSpacing(2) @@ -931,15 +925,6 @@ def load_data(self): self.type_combo.setCurrentText(script_data.get("script_type", "external")) # 启动参数 self.args_input.setText(script_data.get("script_arguments", "")) - is_source = "source_root" in script_data - self.manual_args_label.setVisible(is_source) - self.manual_args_input.setVisible(is_source) - if is_source: - from src.service.source_app_service import format_arguments - - self.manual_args_input.setText( - format_arguments(script_data["source_manual_arguments"]) - ) # 完成检测 self.check_done_combo.setCurrentText( script_data.get("check_done", "script_closed") @@ -1030,11 +1015,6 @@ def save_data(self): for name, checkbox in self.switch_checks.items() }, task_options=self._collect_task_options(), - source_manual_arguments_text=( - self.manual_args_input.text() - if "source_root" in self._find_script_data() - else None - ), ) if self._edit_view is not None: self.pending_changes = edit diff --git a/python-gui/src/gui/main_window.py b/python-gui/src/gui/main_window.py index 93658ac8..3a963945 100644 --- a/python-gui/src/gui/main_window.py +++ b/python-gui/src/gui/main_window.py @@ -15,7 +15,6 @@ from gui.controllers.game_list import GameListController from gui.controllers.launch import LaunchController from gui.controllers.links import LinksController -from gui.controllers.source_install import SourceInstallController from gui.controllers.task_card import TaskCardController from gui.controllers.window import WindowController from gui.icons import UiIconProvider @@ -122,13 +121,6 @@ def __init__(self, cli_client=None, cli_client_factory=None): self.game_list.loaded.connect(self._cli_games_loaded) self.game_list.loadFailed.connect(self._cli_games_failed) self._cli_session.recovered.connect(self._reload_games) - self.source_install = SourceInstallController( - self.app_service, - self._cli_session, - self.toastRequested.emit, - self._reload_games, - self, - ) self.window = WindowController() # UI 矢量图标提供器(无状态,门面持有) self._ui_icon_provider = UiIconProvider() @@ -253,10 +245,6 @@ def reorderGames(self, src, dst): def addScript(self): self.game_list.addScript() - @Slot() - def installSource(self): - self.source_install.start() - @Slot("QVariantList", result=bool) def canDropScripts(self, urls): return self.game_list.canDropScripts(urls) diff --git a/python-gui/src/gui/qml/main.qml b/python-gui/src/gui/qml/main.qml index 5141b0e0..1d105b1e 100644 --- a/python-gui/src/gui/qml/main.qml +++ b/python-gui/src/gui/qml/main.qml @@ -510,7 +510,7 @@ Window { objectName: "controlBubble" x: 92 y: sidebarBottom.y + controlModeButton.y + (controlModeButton.height - height) / 2 - width: 200; height: 60; radius: 16 + width: 156; height: 60; radius: 16 z: 36 visible: Bridge.controlMode && !gameList.dragActive color: Theme.panel @@ -534,10 +534,6 @@ Window { model: [ { name: "selectAllButton", label: "全", act: () => Bridge.selectAll() }, { name: "deselectAllButton", label: "清", act: () => Bridge.deselectAll() }, - { name: "installSourceButton", label: "源", act: () => { - Bridge.toggleMode() - Bridge.installSource() - } }, { name: "addScriptButton", label: "+", act: () => { Bridge.toggleMode() Bridge.addScript() @@ -552,7 +548,7 @@ Window { anchors.centerIn: parent text: modelData.label color: Theme.text - font.pixelSize: modelData.name === "addScriptButton" ? 23 : 14 + font.pixelSize: index === 2 ? 23 : 14 } MouseArea { id: actionMouse diff --git a/python-gui/tests/gui/test_cli_run_starter.py b/python-gui/tests/gui/test_cli_run_starter.py index c96b2c3a..522cac1f 100644 --- a/python-gui/tests/gui/test_cli_run_starter.py +++ b/python-gui/tests/gui/test_cli_run_starter.py @@ -59,8 +59,7 @@ def _wait(self, condition, timeout=5): def _target(self, code): return { - # Windows venv 启动器会保留 stdin 句柄,干扰关闭管道的实进程测试。 - "program": getattr(sys, "_base_executable", sys.executable), + "program": sys.executable, "args": ["-c", code], "cwd": str(self.root), "env": {}, diff --git a/python-gui/tests/gui/test_source_install.py b/python-gui/tests/gui/test_source_install.py deleted file mode 100644 index 9ae7acec..00000000 --- a/python-gui/tests/gui/test_source_install.py +++ /dev/null @@ -1,66 +0,0 @@ -"""源码安装交互与后台结果衔接。""" - -import unittest -from pathlib import Path -from unittest.mock import Mock, patch - -from PySide6.QtTest import QTest - -from gui.controllers.source_install import SourceInstallController -from tests.gui.helpers import get_app - - -class SourceInstallTests(unittest.TestCase): - def setUp(self): - get_app() - self.service = Mock() - self.toast = Mock() - self.reload = Mock() - self.controller = SourceInstallController( - self.service, None, self.toast, self.reload - ) - - def tearDown(self): - self.controller._close() - self.controller.deleteLater() - get_app().processEvents() - - def test_direct_install_registers_and_refreshes(self): - definition = { - "display_name": "测试", - "entry": "main.py", - "manual_arguments": [], - "run_arguments": ["--run"], - } - self.service.source_apps.catalog.return_value = {"example": definition} - self.service.source_apps.start_catalog_install.return_value = {"id": "job"} - self.service.jobs.poll.return_value = { - "state": "succeeded", - "result": {"source": str(Path.cwd())}, - } - with ( - patch( - "gui.controllers.source_install.QInputDialog.getItem", - return_value=("测试", True), - ), - ): - self.controller.start() - QTest.qWait(500) - self.service.source_apps.start_catalog_install.assert_called_once_with( - "example" - ) - self.service.source_apps.register.assert_called_once_with( - application_id="example", - display_name="测试", - source_root=str(Path.cwd()), - script_path=str(Path.cwd() / "main.py"), - manual_arguments=[], - run_arguments=["--run"], - ) - self.reload.assert_called_once() - self.assertIsNone(self.controller.dialog) - - def test_failed_install_is_reported_without_registration(self): - self.controller._failure(ValueError("安装失败")) - self.toast.assert_called_once_with("安装失败") - self.service.source_apps.register.assert_not_called() diff --git a/rust-gui/src/controllers/game_list.rs b/rust-gui/src/controllers/game_list.rs index 7de51b91..5e9ca20a 100644 --- a/rust-gui/src/controllers/game_list.rs +++ b/rust-gui/src/controllers/game_list.rs @@ -287,10 +287,10 @@ impl View { self.toast("手动选择:点击图标勾选,不影响每日计划"); } } - let bubble = rect(92.0, base + 2.0, 200.0, 60.0); + let bubble = rect(92.0, base + 2.0, 156.0, 60.0); if self.control_mode && self.dragging.is_none() { panel(ui, bubble, 16, PANEL); - for (index, text) in ["全", "清", "+", "源"].iter().enumerate() { + for (index, text) in ["全", "清", "+"].iter().enumerate() { let bounds = rect( bubble.left() + 16.0 + 44.0 * index as f32, bubble.top() + 12.0, @@ -317,10 +317,6 @@ impl View { self.control_mode = false; actions.push(Action::AddScript); } - 3 => { - self.control_mode = false; - actions.push(Action::InstallSource); - } _ => unreachable!(), } } @@ -361,7 +357,6 @@ impl App { && self.view.is_some() && self.editor.is_none() && self.list_dialog.is_none() - && self.source_dialog.is_none() && self.run_dialog.is_none() && self.settings_dialog.is_none() && self.backup_dialog.is_none() diff --git a/rust-gui/src/controllers/mod.rs b/rust-gui/src/controllers/mod.rs index d51f6741..47c04229 100644 --- a/rust-gui/src/controllers/mod.rs +++ b/rust-gui/src/controllers/mod.rs @@ -25,7 +25,6 @@ pub enum Action { Refresh, Diagnostics, AddScript, - InstallSource, RemoveScript(String), } diff --git a/rust-gui/src/dialogs/mod.rs b/rust-gui/src/dialogs/mod.rs index 1c4e64b3..e64d8274 100644 --- a/rust-gui/src/dialogs/mod.rs +++ b/rust-gui/src/dialogs/mod.rs @@ -10,7 +10,6 @@ pub(crate) mod run_confirm_dialog; pub(crate) mod run_options_editor; pub(crate) mod script_config_dialog; pub(crate) mod shutdown_dialog; -pub(crate) mod source_dialog; pub(crate) mod startup_dialog; pub(crate) mod update_dialog; pub(crate) mod wallpaper_dialog; diff --git a/rust-gui/src/dialogs/script_config_dialog.rs b/rust-gui/src/dialogs/script_config_dialog.rs index f80ee784..24c4a351 100644 --- a/rust-gui/src/dialogs/script_config_dialog.rs +++ b/rust-gui/src/dialogs/script_config_dialog.rs @@ -26,8 +26,6 @@ pub struct Fields { script_type: String, #[serde(default)] script_arguments: String, - #[serde(default)] - source_manual_arguments_text: Option, #[serde(default = "script_closed")] check_done: String, #[serde(default = "enabled")] @@ -368,7 +366,6 @@ impl ScriptEditor { }, "weekly_timeouts": timeouts, "switches": switches, "task_options": task_options, - "source_manual_arguments_text": fields.source_manual_arguments_text, }), }) } @@ -477,11 +474,6 @@ impl ScriptEditor { .hint_text("可选"), ); ui.end_row(); - if let Some(manual) = &mut fields.source_manual_arguments_text { - ui.label("手动启动参数"); - ui.add(crate::dialogs::common::text_input(manual).hint_text("可选")); - ui.end_row(); - } }); }); crate::dialogs::common::form_section(ui, "运行行为", |ui| { diff --git a/rust-gui/src/dialogs/source_dialog.rs b/rust-gui/src/dialogs/source_dialog.rs deleted file mode 100644 index 901e5b7c..00000000 --- a/rust-gui/src/dialogs/source_dialog.rs +++ /dev/null @@ -1,270 +0,0 @@ -use crate::dialogs::common; -use eframe::egui; -use onedragon_rust_gui::backend::Request; -use serde::Deserialize; -use serde_json::{Value, json}; -use std::{ - collections::BTreeMap, - path::Path, - time::{Duration, Instant}, -}; - -#[derive(Deserialize)] -struct Application { - display_name: String, - entry: String, - manual_arguments: Vec, - run_arguments: Vec, -} - -pub enum SourceAction { - Request(Request), - Close, -} - -pub struct SourceDialog { - catalog: BTreeMap, - selected: String, - job: Option, - awaiting: bool, - next_poll: Instant, - message: String, - error: Option, - finished: bool, -} - -impl SourceDialog { - pub fn new() -> Self { - Self { - catalog: BTreeMap::new(), - selected: String::new(), - job: None, - awaiting: true, - next_poll: Instant::now(), - message: String::new(), - error: None, - finished: false, - } - } - pub fn active(&self) -> bool { - self.awaiting || self.job.is_some() - } - pub fn failure(&mut self, message: String) { - self.awaiting = false; - self.job = None; - self.error = Some(message); - } - pub fn poll(&mut self) -> Option { - let id = self.job.as_ref()?; - if Instant::now() < self.next_poll { - return None; - } - self.next_poll = Instant::now() + Duration::from_millis(400); - Some(Request { - method: "job.poll".into(), - params: json!({"job_id": id}), - }) - } - pub fn receive(&mut self, method: &str, value: Value) -> Result, String> { - match method { - "source.catalog" => { - self.catalog = - serde_json::from_value(value).map_err(|e| format!("应用列表无效:{e}"))?; - self.selected = self.catalog.keys().next().ok_or("应用列表为空")?.clone(); - self.awaiting = false; - } - "source.install_catalog" => { - let id = value - .get("id") - .and_then(Value::as_str) - .filter(|id| !id.is_empty()) - .ok_or("任务编号无效")?; - self.job = Some(id.into()); - self.awaiting = false; - self.next_poll = Instant::now(); - } - "job.poll" => { - if value.get("id").and_then(Value::as_str) != self.job.as_deref() - || value.get("kind").and_then(Value::as_str) != Some("source_install") - { - return Err("安装任务身份不匹配".into()); - } - if let Some(message) = value.get("message").and_then(Value::as_str) { - self.message = message.to_owned(); - } - match value.get("state").and_then(Value::as_str) { - Some("running") => {} - Some("failed") => { - self.job = None; - self.error = Some( - value - .get("error") - .and_then(Value::as_str) - .ok_or("任务错误信息缺失")? - .into(), - ); - } - Some("succeeded") => { - let source = value - .get("result") - .and_then(|v| v.get("source")) - .and_then(Value::as_str) - .filter(|s| !s.is_empty()) - .ok_or("安装结果无效")?; - let app = self.catalog.get(&self.selected).ok_or("应用声明不存在")?; - let entry = Path::new(source) - .join(&app.entry) - .to_string_lossy() - .into_owned(); - self.job = None; - self.awaiting = true; - return Ok(Some(Request { - method: "source.register".into(), - params: json!({"application_id": self.selected, "display_name": app.display_name, "source_root": source, "script_path": entry, "manual_arguments": app.manual_arguments, "run_arguments": app.run_arguments}), - })); - } - _ => return Err("任务状态无效".into()), - } - } - "source.register" => { - if value.get("script_name").and_then(Value::as_str) != Some(self.selected.as_str()) - { - return Err("注册结果无效".into()); - } - self.awaiting = false; - self.finished = true; - self.message = "安装完成".into(); - } - _ => return Err("未知响应".into()), - } - Ok(None) - } - fn install_request(&mut self) -> Request { - self.error = None; - self.awaiting = true; - Request { - method: "source.install_catalog".into(), - params: json!({"application_id": self.selected}), - } - } - - pub fn show(&mut self, ctx: &egui::Context, blocked: bool) -> Option { - let active = self.active(); - let mut action = None; - let close = common::Dialog::new("source-install", "安装源码应用") - .description("选择应用,首次安装需要下载依赖") - .show(ctx, !blocked && !active, |ui| { - common::dialog_body(ui, |ui| { - ui.add_enabled_ui(!blocked && !active && !self.finished, |ui| { - common::form_section(ui, "安装配置", |ui| { - let label = self - .catalog - .get(&self.selected) - .map(|app| app.display_name.as_str()) - .unwrap_or("正在读取应用列表"); - egui::ComboBox::from_id_salt("source-app") - .selected_text(label) - .show_ui(ui, |ui| { - for (id, app) in &self.catalog { - ui.selectable_value( - &mut self.selected, - id.clone(), - &app.display_name, - ); - } - }); - }); - }); - }); - common::dialog_status( - ui, - self.error.as_deref(), - (!self.message.is_empty()).then_some(self.message.as_str()), - ); - common::dialog_footer(ui, |ui| { - if ui - .add_enabled( - !blocked && !active && !self.finished && !self.selected.is_empty(), - common::primary_button("安装"), - ) - .clicked() - { - action = Some(SourceAction::Request(self.install_request())); - } - if ui - .add_enabled( - !blocked && !active, - common::secondary_button(if self.finished { - "完成" - } else { - "关闭" - }), - ) - .clicked() - { - action = Some(SourceAction::Close); - } - if active { - ui.spinner(); - } - }); - }); - if close { - action = Some(SourceAction::Close); - } - action - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn install_polls_then_registers_catalog_entry() { - let mut dialog = SourceDialog::new(); - dialog.receive("source.catalog", json!({"app": {"display_name": "应用", "entry": "main.py", "manual_arguments": ["--gui"], "run_arguments": ["--run"]}})).unwrap(); - dialog.awaiting = true; - dialog - .receive("source.install_catalog", json!({"id": "job"})) - .unwrap(); - assert_eq!(dialog.poll().unwrap().method, "job.poll"); - assert!( - dialog - .receive( - "job.poll", - json!({"id": "other", "kind": "source_install", "state": "running"}) - ) - .is_err() - ); - let request = dialog.receive("job.poll", json!({"id": "job", "kind": "source_install", "state": "succeeded", "result": {"source": "C:/apps/app"}})).unwrap().unwrap(); - assert_eq!(request.method, "source.register"); - assert_eq!(request.params["run_arguments"], json!(["--run"])); - assert!(dialog.active()); - dialog - .receive("source.register", json!({"script_name": "app"})) - .unwrap(); - assert!(dialog.finished); - assert!(!dialog.active()); - } - - #[test] - fn install_request_uses_managed_directory() { - let mut dialog = SourceDialog::new(); - dialog.selected = "ok-ww".into(); - let request = dialog.install_request(); - assert_eq!(request.method, "source.install_catalog"); - assert_eq!(request.params, json!({"application_id": "ok-ww"})); - } - - #[test] - fn failed_install_allows_retry() { - let mut dialog = SourceDialog::new(); - dialog - .receive("source.install_catalog", json!({"id": "job"})) - .unwrap(); - dialog.receive("job.poll", json!({"id": "job", "kind": "source_install", "state": "failed", "error": "network"})).unwrap(); - assert_eq!(dialog.error.as_deref(), Some("network")); - assert!(!dialog.active()); - } -} diff --git a/rust-gui/src/main_window.rs b/rust-gui/src/main_window.rs index 8891935b..2772ba3f 100644 --- a/rust-gui/src/main_window.rs +++ b/rust-gui/src/main_window.rs @@ -5,7 +5,6 @@ use crate::dialogs::drop_dialog::DropDialog; use crate::dialogs::list_dialog::{ListAction, ListDialog}; use crate::dialogs::run_confirm_dialog::{RunAction, RunDialog, RunView}; use crate::dialogs::script_config_dialog::{EditAction, EditView, ScriptEditor}; -use crate::dialogs::source_dialog::{SourceAction, SourceDialog}; use crate::dialogs::startup_dialog::StartupDialog; use crate::dialogs::update_dialog::{UpdateAction, UpdateDialog, UpdateView}; use crate::file_drop::FileDrop; @@ -87,7 +86,6 @@ pub struct App { launched: Vec, editor: Option, list_dialog: Option, - source_dialog: Option, run_dialog: Option, settings_dialog: Option, backup_dialog: Option, @@ -147,7 +145,6 @@ impl App { launched: Vec::new(), editor: None, list_dialog: None, - source_dialog: None, run_dialog: None, settings_dialog: None, backup_dialog: None, @@ -266,9 +263,6 @@ impl App { if let Some(dialog) = &mut self.backup_dialog { dialog.failure(failure.message.clone()); } - if let Some(dialog) = &mut self.source_dialog { - dialog.failure(failure.message.clone()); - } if let Some(dialog) = &mut self.update_dialog { dialog.failure(failure.message.clone(), true); } @@ -285,32 +279,6 @@ impl App { self.request_pending = false; self.pid = reply.pid; self.diagnostics = reply.diagnostics; - if reply.method.starts_with("source.") - || (reply.method == "job.poll" && self.source_dialog.is_some()) - { - match reply.result { - Ok(value) => { - let handled = self - .source_dialog - .as_mut() - .ok_or_else(|| "安装窗口已关闭".to_owned()) - .and_then(|dialog| dialog.receive(&reply.method, value)); - match handled { - Ok(Some(request)) => self.request(&request.method, request.params), - Ok(None) => {} - Err(error) => self.fail(Failure::transport(error)), - } - } - Err(failure) => { - let message = failure.message.clone(); - self.fail(failure); - if let Some(dialog) = &mut self.source_dialog { - dialog.failure(message); - } - } - } - return; - } if reply.method.starts_with("wallpaper.") { self.receive_wallpaper(&reply.method, reply.result); return; @@ -657,13 +625,9 @@ impl App { } fn background_task_active(&self) -> bool { - self.source_dialog + self.backup_dialog .as_ref() - .is_some_and(SourceDialog::active) - || self - .backup_dialog - .as_ref() - .is_some_and(BackupDialog::active) + .is_some_and(BackupDialog::active) || self.drop_dialog.as_ref().is_some_and(DropDialog::active) || self .update_dialog @@ -737,11 +701,6 @@ impl eframe::App for App { { self.request(&request.method, request.params); } - if !self.request_pending - && let Some(request) = self.source_dialog.as_mut().and_then(SourceDialog::poll) - { - self.request(&request.method, request.params); - } if !self.request_pending && let Some(request) = self.backup_dialog.as_mut().and_then(BackupDialog::poll) { @@ -849,11 +808,6 @@ impl eframe::App for App { } } Action::Diagnostics => self.ui.diagnostics_open = !self.ui.diagnostics_open, - Action::InstallSource => { - self.ui.close_menu(); - self.source_dialog = Some(SourceDialog::new()); - self.request("source.catalog", json!({})); - } Action::AddScript => { self.ui.close_menu(); self.list_dialog = Some(ListDialog::add()); @@ -879,22 +833,6 @@ impl eframe::App for App { self.show_startup(ui); self.show_script_config(ui); self.show_script_list(ui); - let blocked = self.dialog_blocked(); - if let Some(action) = self - .source_dialog - .as_mut() - .and_then(|dialog| dialog.show(ui.ctx(), blocked)) - { - match action { - SourceAction::Request(request) => self.request(&request.method, request.params), - SourceAction::Close => { - self.source_dialog = None; - if self.backend.is_some() { - self.request("app.snapshot", json!({})); - } - } - } - } self.show_run_confirm(ui); self.show_wallpaper(ui); let blocked = self.dialog_blocked(); diff --git a/rust-gui/tests/main_window.rs b/rust-gui/tests/main_window.rs index 7d460a2e..2ae49e20 100644 --- a/rust-gui/tests/main_window.rs +++ b/rust-gui/tests/main_window.rs @@ -235,7 +235,6 @@ fn test_app(command: Command, root: &std::path::Path, python: PathBuf) -> App { launched: Vec::new(), editor: None, list_dialog: None, - source_dialog: None, open_editor_after_snapshot: false, run_dialog: None, settings_dialog: None, diff --git a/uv-app-manager b/uv-app-manager deleted file mode 160000 index 32370dfe..00000000 --- a/uv-app-manager +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 32370dfef4db8323b52f6ad224f3a12049ce5f47 diff --git a/uv.lock b/uv.lock index 53adf811..bcc7b574 100644 --- a/uv.lock +++ b/uv.lock @@ -263,74 +263,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/8c/f3147f5c4b73e7550fe5f9352eaa956ae838d5c51eb58e7a25b9f3e2643b/decorator-5.2.1-py3-none-any.whl", hash = "sha256:d316bb415a2d9e2d2b3abcc4084c6502fc09240e292cd76a76afc106a1c8e04a", size = 9190, upload-time = "2025-02-24T04:41:32.565Z" }, ] -[[package]] -name = "dulwich" -version = "1.2.21" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.12'" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/01/d1/01adf1db003374db4c2a70f7bae757ddfc39a33d9fb5ab765522b4d339e6/dulwich-1.2.21.tar.gz", hash = "sha256:5d3939f64883e6799614cab708ef4ee9ef415dd6dcad47527cdbb0eea43bf3a2", size = 1451069, upload-time = "2026-11-05T02:26:59.899Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/47/9c/aa1a26885358f78155f00da38290cedcd1b4e095539aba2166970daf5609/dulwich-1.2.21-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:06b910393bf73b2016551cedde5fb3387bf268c99ca8c33707bbbf5040f53f5d", size = 1514080, upload-time = "2026-11-05T02:25:19.278Z" }, - { url = "https://files.pythonhosted.org/packages/b4/1d/7a73f54622c7f4fc2ee25773b4d29ca15acce05c7e4adfd20c76c198fc98/dulwich-1.2.21-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8c726631322735bb497e53b623ec17b2da040def5742c1d26f63e16401f3fb60", size = 1499521, upload-time = "2026-11-05T02:25:21.008Z" }, - { url = "https://files.pythonhosted.org/packages/44/99/89a307ee862c50d8d6e8c7a31a66c690bced91b26acc741682ea95190188/dulwich-1.2.21-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:b0bc5e54c3d917a2c1d40eefc8e34a3543ca80b0bf602a0220580cc99793d893", size = 1515088, upload-time = "2026-11-05T02:25:22.65Z" }, - { url = "https://files.pythonhosted.org/packages/e2/7e/f90c7568a16e127e518b4c2e1594b0b3e0c8a6e450a961e14f264e29d232/dulwich-1.2.21-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:7af063ab230fd9e8d4b864c1cbc5f02a02715e112ed2b9ef4e20acdc63ba94bd", size = 1549297, upload-time = "2026-11-05T02:25:24.502Z" }, - { url = "https://files.pythonhosted.org/packages/30/3e/9ae2adf48bbdb45710cd8860e3946ced7237d047ef69aab2e23692f41107/dulwich-1.2.21-cp311-cp311-win32.whl", hash = "sha256:839cccb0ee895e8fc4d997c6c90945b6e8925e84fb3f63c040939defc6476ef8", size = 1178586, upload-time = "2026-11-05T02:25:26.077Z" }, - { url = "https://files.pythonhosted.org/packages/cc/20/b83651ebedba6dcef4f4cf48604cb48d1e842b6a816e7c4bf65e1ab6b619/dulwich-1.2.21-cp311-cp311-win_amd64.whl", hash = "sha256:2e222d4454febc3876af851ddcacc1a602240a3cfe3f8881ae21db997ba4fb83", size = 1192799, upload-time = "2026-11-05T02:25:27.988Z" }, - { url = "https://files.pythonhosted.org/packages/17/b3/70438e4795ec4d89a36fbcf18872c3f52be2e0db9a20e6df215b5b929c8c/dulwich-1.2.21-cp311-cp311-win_arm64.whl", hash = "sha256:74fbac8d95a81977a07eaa218037c609ee57837db51014de76dfdeccb16108a8", size = 1185874, upload-time = "2026-11-05T02:25:29.523Z" }, - { url = "https://files.pythonhosted.org/packages/ed/97/765237993132d47b55ace49d96830d069d79a686b5df5bf986fd9f33046e/dulwich-1.2.21-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a7f106746f0596fa07974a5522dfd61d7516721a2ae6ecfdcce6e43f9deb6bd7", size = 1510639, upload-time = "2026-11-05T02:25:31.291Z" }, - { url = "https://files.pythonhosted.org/packages/7a/6a/cee01d75b2f0a0feaca4fdb570ae5f7cd85bc3bf7881c1ecc6fae434cbbf/dulwich-1.2.21-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:93adb787872f330cccf77b50ee41131d9932287725ca00625cd9c346c9ee70b9", size = 1487571, upload-time = "2026-11-05T02:25:32.813Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9d/9f508827357ee9647e4074f2eace4315b39e51f9d159a6ba1d4f2ebb49eb/dulwich-1.2.21-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:a3ac7c2e075b5130ce7055b9278eb1d820cc8196cb07d5480b0dc89499d0357d", size = 1507427, upload-time = "2026-11-05T02:25:34.445Z" }, - { url = "https://files.pythonhosted.org/packages/80/06/b033cdb1e34165d8df6c4cc741f7e583ffff771112e9344ed6de31981186/dulwich-1.2.21-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:945278b0f74f83e0f0253aa5ede25f0228b7cca5ca31eb5f5fb76c445c5d5b02", size = 1540987, upload-time = "2026-11-05T02:25:36.362Z" }, - { url = "https://files.pythonhosted.org/packages/eb/4e/bc692f443e5f38ffa960e7412cd76f96084686dac3c5101bf6bc24c48597/dulwich-1.2.21-cp312-cp312-win32.whl", hash = "sha256:3206426faf4cb2474458f2dacf5c1b597a076831f2e7afa2a350df5fff2cce1a", size = 1173883, upload-time = "2026-11-05T02:25:38.006Z" }, - { url = "https://files.pythonhosted.org/packages/80/67/42d9eca568488e960c9726c27355536e8e239b780e937f6b84896729cb69/dulwich-1.2.21-cp312-cp312-win_amd64.whl", hash = "sha256:b3d30d40384cee2e8aa3722f87cef2f3d71717a0b45ac88eba457d62acf65dda", size = 1128317, upload-time = "2026-11-05T02:25:39.986Z" }, - { url = "https://files.pythonhosted.org/packages/a2/cc/a55ab15df42fb6d7ea74f9e6cfd0b4b665ba3b26f67322b9ffa09fb8d2aa/dulwich-1.2.21-cp312-cp312-win_arm64.whl", hash = "sha256:4747b1f94dbbf06e90d49247199db458bd9fefc588a4368669cb551ba4b6439f", size = 1123847, upload-time = "2026-11-05T02:25:41.613Z" }, - { url = "https://files.pythonhosted.org/packages/99/40/b808d4083330c48325b51102df29e414535377eab531cd0f4eab9978486d/dulwich-1.2.21-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:40649c539ec2fc2d7929709ac5788696b78c39d19248b23c3a998565baefa82b", size = 1598389, upload-time = "2026-11-05T02:25:43.28Z" }, - { url = "https://files.pythonhosted.org/packages/6f/96/5c5eae15e86755a70ed81503da9cdbc0169c68f4ee13b7526a913129d3b2/dulwich-1.2.21-cp313-cp313-android_24_x86_64.whl", hash = "sha256:dbe87ac0bda6d9516d196d1682fc39b596aef5855bfb51e71e9d8f2854f08a2f", size = 1589037, upload-time = "2026-11-05T02:25:45.026Z" }, - { url = "https://files.pythonhosted.org/packages/39/55/379eb7c74a9dee6f18a7b92a1681d2b81ec92f5f4765836cfe26ae498b51/dulwich-1.2.21-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bbbfc7b9c0216acc4bafb37b5f11cf50ad970703c7a095b06af04eebe97bc1", size = 1510428, upload-time = "2026-11-05T02:25:46.831Z" }, - { url = "https://files.pythonhosted.org/packages/30/19/1e8ad768740687df45711b32bbad3e68eec131313f70a76cbf799c390e95/dulwich-1.2.21-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fe852ca18749eaa72377cc3e98938cf6e21d5d57ba195e4efe18f89beb86dd30", size = 1487905, upload-time = "2026-11-05T02:25:48.504Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b3/29441f36620c0f550305e0e4e07a9e04d3e3e3cc0d8051a3dbdc27c59fb8/dulwich-1.2.21-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:76fb77ee2e2a28226933923adfd7c56d059c83ac99bb9d299e790c9b90ed5ad2", size = 1507385, upload-time = "2026-11-05T02:25:50.049Z" }, - { url = "https://files.pythonhosted.org/packages/d5/37/74e8911e9fe3bc8c47375cee9c4ca207676a8c34c4f1172586aa488b5f95/dulwich-1.2.21-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:fa9ef02ec417282303e103158c5ffbd94cb2c3a51691fc9722807fceca24f55e", size = 1541092, upload-time = "2026-11-05T02:25:51.83Z" }, - { url = "https://files.pythonhosted.org/packages/fc/1d/eb5a085dbb789f162991f991d99c5e435365e92be31d1e1ae42f253f35e5/dulwich-1.2.21-cp313-cp313-win32.whl", hash = "sha256:4d900b322f73e817560d54b6e975354084da3390b1197513f863fba143ba9ebf", size = 1173604, upload-time = "2026-11-05T02:25:53.597Z" }, - { url = "https://files.pythonhosted.org/packages/d9/04/f520d539ff3acf4c3e25cf6de4cdba651a0dc2654e30feccbfdf04d0d044/dulwich-1.2.21-cp313-cp313-win_amd64.whl", hash = "sha256:d32a96474226da594065e34c6c83b76bf9422e930cedadf481cb4f1b60de7e31", size = 1128289, upload-time = "2026-11-05T02:25:55.25Z" }, - { url = "https://files.pythonhosted.org/packages/cb/31/83c743abd234087d303c5a7894180f6b9fcdefb2aa883da47799bc0d0a7d/dulwich-1.2.21-cp313-cp313-win_arm64.whl", hash = "sha256:a3ae7882440c711927111c70c09b65486093a6a49df4efcb46914b8c433b8bd9", size = 1123433, upload-time = "2026-11-05T02:25:56.877Z" }, - { url = "https://files.pythonhosted.org/packages/3a/94/1b89e70b594b7c4d24ad2d2a04a280da91ff08059ed01ad991febb286ef6/dulwich-1.2.21-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:a5fafde91c4529c39599a900526cae6bdf9ccaf6435c2f8b5243c83362f3d47f", size = 1602814, upload-time = "2026-11-05T02:25:58.369Z" }, - { url = "https://files.pythonhosted.org/packages/24/89/6e58a1dcd72eff46b0e00c9cbe84bf798485dfbea415c14488c33b980ae7/dulwich-1.2.21-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f6e343cee37fa7e58c82dcc022353fcba1e38e812d4d925cf72b180fa152d8b9", size = 1656944, upload-time = "2026-11-05T02:25:59.91Z" }, - { url = "https://files.pythonhosted.org/packages/07/41/490f0873cbb6d4836fba26f9fe5e62beedb3281713ed3ff9d5ff7be9be0a/dulwich-1.2.21-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c3aa44524ecab49baad8130af65386de7fa4e325cbae0592b918e46e3dc15305", size = 1451444, upload-time = "2026-11-05T02:26:01.553Z" }, - { url = "https://files.pythonhosted.org/packages/72/f4/b21f18d80acd9c7f30f4b2041c29df2602b490106b98581fcc23f48137a3/dulwich-1.2.21-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ab651ef2aee6f6f713e905eec68f67b5711def27a80ca45d5a9270310e77d4a2", size = 1433389, upload-time = "2026-11-05T02:26:03.49Z" }, - { url = "https://files.pythonhosted.org/packages/2e/dd/b99c7ae6a2bfbcad714993e6db34bd78aed21d581c2f8ab9d1c30de2f239/dulwich-1.2.21-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:001da0691a2fe5d6def8d239062021c5042ace4d4b37fd5d0e4092d0f87a4a5a", size = 1510338, upload-time = "2026-11-05T02:26:05.035Z" }, - { url = "https://files.pythonhosted.org/packages/07/2a/5d2b983519f968f47f02bffbbe8ca29ec4e5308352fa0b0a6814e5036981/dulwich-1.2.21-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:657e45d73c65f6440eb27a26d3e6661b07b69bf205c508deb9959c6355d71272", size = 1610491, upload-time = "2026-11-05T02:26:06.673Z" }, - { url = "https://files.pythonhosted.org/packages/ba/77/be5db7298d31b08664fa33ff01cffec8318f75c7ce3286c282ed0ee5bf63/dulwich-1.2.21-cp314-cp314-win32.whl", hash = "sha256:f8686c722d1612e75e1fdcfda0be0264cf8c390eebd0df03237dc8a45f996881", size = 1186030, upload-time = "2026-11-05T02:26:08.322Z" }, - { url = "https://files.pythonhosted.org/packages/c7/57/8c0803d14f70f3f7a437ab9062d7b19710ac54d9e7aee2dddf0948bbf1ee/dulwich-1.2.21-cp314-cp314-win_amd64.whl", hash = "sha256:d5caa0b8dcd956f5d8adfbc3c9ab82d69dedd340011928ada3d0a94e09afdb18", size = 1198943, upload-time = "2026-11-05T02:26:09.946Z" }, - { url = "https://files.pythonhosted.org/packages/66/a7/8cc84718d1dc304e3e4fb73eee27818519d97b77edc7111d825ad4070f94/dulwich-1.2.21-cp314-cp314-win_arm64.whl", hash = "sha256:0d4a23851d272f82a07dec2be136e590beff9c00e05c95c4f7d3bb54c084c885", size = 1135516, upload-time = "2026-11-05T02:26:11.751Z" }, - { url = "https://files.pythonhosted.org/packages/53/99/ecc80ca1b9776c62aff3a2a8e687af19cedbebc19a4a05de5d2c256b287c/dulwich-1.2.21-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:1dc827348e2929f8e9620ca6b762e8e553a997eee788933d348ef451b6f9a261", size = 1450471, upload-time = "2026-11-05T02:26:13.4Z" }, - { url = "https://files.pythonhosted.org/packages/0c/58/85047280a4ee8ee3a8f0d7b7436125b841541b8e2c72843769bf3592b2bb/dulwich-1.2.21-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:02dcd28b5d1a3a8abc2f0e5ff42deed1431c81d02b89c41b5cfdda352cba7fe3", size = 1431004, upload-time = "2026-11-05T02:26:15.29Z" }, - { url = "https://files.pythonhosted.org/packages/52/42/0c93a97abf55d22c5a639cdf9c67060955d5ff4cf0012153cb7dd2481e9b/dulwich-1.2.21-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:179bb5942e0ee808eedab6d3bef4935c95869bfcc921e6d4585506386fa68bf4", size = 1567979, upload-time = "2026-11-05T02:26:17.367Z" }, - { url = "https://files.pythonhosted.org/packages/f8/21/653079f2a363536910d51cbcfb84204b071f3d500e8b0c4f09bb9b49ff92/dulwich-1.2.21-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a03bd34006172c764642f1dbf179b1c3eba7bb1e25ccf5787be48ee28f922d18", size = 1611144, upload-time = "2026-11-05T02:26:19.173Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d6/bde8f2fc37537dcf10495ba5d4335f2941e7f80dccc03b4bc35e46f25046/dulwich-1.2.21-cp314-cp314t-win32.whl", hash = "sha256:f7708c8318ab9ea6b0fb2cd77e40b06ff64aae6ba6c269cd3d3a18c619b83eea", size = 1185353, upload-time = "2026-11-05T02:26:21.024Z" }, - { url = "https://files.pythonhosted.org/packages/e1/dc/1268696ed6a22edd5d89051c9e6f6cbddab40dc13e6accdf6eb2e28af6e7/dulwich-1.2.21-cp314-cp314t-win_amd64.whl", hash = "sha256:cd7070dabeba6e5cef6ab70b4968b42bf342a5585914b517c86ae429b92a7c20", size = 1139138, upload-time = "2026-11-05T02:26:22.518Z" }, - { url = "https://files.pythonhosted.org/packages/96/9f/3069a81a1263e72fc2072102917dc7ff1208d88f290f0802cd6026c6db43/dulwich-1.2.21-cp314-cp314t-win_arm64.whl", hash = "sha256:adf4318d7da19f1b63f3f7ed7290a9f7bfdbf9219d98d7ed8b172c1df917a6c0", size = 1193385, upload-time = "2026-11-05T02:26:25.555Z" }, - { url = "https://files.pythonhosted.org/packages/42/80/c56cb6c027f1f97a1644db00a104f9714acf684d50bc609bf3c5bd23c9df/dulwich-1.2.21-cp315-cp315-android_24_arm64_v8a.whl", hash = "sha256:5dbb8160ad2652df2add5d34a6d3f363694bc5f5f482044b41e9d0384f439341", size = 1604337, upload-time = "2026-11-05T02:26:28.408Z" }, - { url = "https://files.pythonhosted.org/packages/62/6f/30d6a0181c88d471f2c98252e23921130b0f1801e9ac24a6eb1c0dd1000e/dulwich-1.2.21-cp315-cp315-android_24_x86_64.whl", hash = "sha256:9ff0ebb78ccf94e97c83a83c85daa22c75dad6fae83ff18ec0d9a5a4aaed9335", size = 1658829, upload-time = "2026-11-05T02:26:30.084Z" }, - { url = "https://files.pythonhosted.org/packages/19/c3/7768927c32fef9f48ba7447b6d35fecae92ec6bc805b05fbe5771e6a5f54/dulwich-1.2.21-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:7daac64f153b11968f27e2573720bb23fe6da722ff06a1ad3623350d1dfa6a1a", size = 1452980, upload-time = "2026-11-05T02:26:32.898Z" }, - { url = "https://files.pythonhosted.org/packages/34/8e/184fd605429b8a71ca3447c902a4787da77c4b8d4beb021d491f8e27f35e/dulwich-1.2.21-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:426cfcb6a9269129096c449643f96b406cb257add3ecdba1f1fdcad8e645d7be", size = 1434082, upload-time = "2026-11-05T02:26:34.674Z" }, - { url = "https://files.pythonhosted.org/packages/80/14/67e4b9ec2295bbb5f7acbb01194e1e02d1b5db007675b6e1e7551b472ca3/dulwich-1.2.21-cp315-cp315-manylinux_2_28_aarch64.whl", hash = "sha256:d4d2df962e680c8ce76ca43334787c96489034edbf35dd866619e663d1aff773", size = 1571578, upload-time = "2026-11-05T02:26:36.935Z" }, - { url = "https://files.pythonhosted.org/packages/40/89/d13e220ade016008fd1036a3020592bcca3ffb8062f96cd4398559ebb916/dulwich-1.2.21-cp315-cp315-manylinux_2_28_x86_64.whl", hash = "sha256:603f34501a78a1998b5da91638b7e162ddea269fb56f95ad7aedc5f55e562fbd", size = 1611137, upload-time = "2026-11-05T02:26:39.111Z" }, - { url = "https://files.pythonhosted.org/packages/bf/f2/208af25f7b3648c2c65f1af1252a1b6399d751d6aeeacbc730c42aac5ac5/dulwich-1.2.21-cp315-cp315-win32.whl", hash = "sha256:69405adb23f62a624fb5f95f1b49748fef823d7c5eace2025b5c77845c6eb972", size = 1186478, upload-time = "2026-11-05T02:26:40.968Z" }, - { url = "https://files.pythonhosted.org/packages/6c/21/949a54ed21fee947f56c101b4e68f8ce8565478ee07c66699b907a7b0cca/dulwich-1.2.21-cp315-cp315-win_amd64.whl", hash = "sha256:634f4da28d67150f6145eb833a6d24b3575024120fa9a7a496e4f3e7f1e3d6c2", size = 1138741, upload-time = "2026-11-05T02:26:42.539Z" }, - { url = "https://files.pythonhosted.org/packages/0a/68/14b9431f3e30431f984ff39c901799833383104931bd49f5972995c8a8e3/dulwich-1.2.21-cp315-cp315-win_arm64.whl", hash = "sha256:375f3e6e2b7e1c886d2c3a22bb12351427f655d4e2323236a2ff677f54ef5789", size = 1135581, upload-time = "2026-11-05T02:26:44.046Z" }, - { url = "https://files.pythonhosted.org/packages/2d/46/da2a20d65c2676aeb31c31b65e8e8bc117a1a2d3ac4e5b882b881601308e/dulwich-1.2.21-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e76aec0a33dd5f7ed2fa76a8fd7af22f80e5f8654020be716660236ca3b749c9", size = 1451175, upload-time = "2026-11-05T02:26:45.983Z" }, - { url = "https://files.pythonhosted.org/packages/a6/4d/8576d4a1248a1c06a0ca32d1fe37f4de0f71fd838218c80af4b867b86c08/dulwich-1.2.21-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:33dfb35033babd11a5fe667859ba07880b983077ff419af68d3db10df1b2ada3", size = 1432198, upload-time = "2026-11-05T02:26:47.762Z" }, - { url = "https://files.pythonhosted.org/packages/d7/5f/c1987b4737c655aa5f18f83169c2a0072adf5e880ffcdff0ef3857cdd978/dulwich-1.2.21-cp315-cp315t-manylinux_2_28_aarch64.whl", hash = "sha256:e313ea1fefba05fc3e6953ed37882459c29955aa69d84c2add7974a830a197dc", size = 1508317, upload-time = "2026-11-05T02:26:50.275Z" }, - { url = "https://files.pythonhosted.org/packages/2e/9f/03d0ad2c5fcfce977db9ac9b99cfec31a464f18bf8e98d1d471e006240fe/dulwich-1.2.21-cp315-cp315t-manylinux_2_28_x86_64.whl", hash = "sha256:cb21f2bd25e96423fdc56463c7993acff52a5d2d53e16265773005d4dbd150b1", size = 1546141, upload-time = "2026-11-05T02:26:52.007Z" }, - { url = "https://files.pythonhosted.org/packages/37/d8/fee4654027a924484115d41a6f19e11cf6628b5a4ba1bc8987a75f42021a/dulwich-1.2.21-cp315-cp315t-win32.whl", hash = "sha256:7f71ee62a8d3ee250b7c92fae167bc7db07667d46ad4c226d02c583b864010f9", size = 1185632, upload-time = "2026-11-05T02:26:53.55Z" }, - { url = "https://files.pythonhosted.org/packages/a2/92/a96cc2790b7a80cad5e07f9037510c2053564fdaefb173d07ac91f76a95a/dulwich-1.2.21-cp315-cp315t-win_amd64.whl", hash = "sha256:b77a927cc52857c884568548be8dcb3c832c6fe1b4ccf64c1c4c1756f7952a7d", size = 1139598, upload-time = "2026-11-05T02:26:55.109Z" }, - { url = "https://files.pythonhosted.org/packages/61/da/ee87f516ba90cb3b1bcc90a2ec9c8440da3fb67079881e0d3f613ef01cfa/dulwich-1.2.21-cp315-cp315t-win_arm64.whl", hash = "sha256:3e9c211334624a327447fbf0bed2e672ad81edb5cd65a87dde43e8953f29f90d", size = 1193895, upload-time = "2026-11-05T02:26:56.915Z" }, - { url = "https://files.pythonhosted.org/packages/6d/0e/e44bc7c8619596af498738917bb21f8511d0f928f32cae0c4a2ba01fdebd/dulwich-1.2.21-py3-none-any.whl", hash = "sha256:31a522b9611e8032a43b8fb6941469c24e65ff4f14e0427986c58e90c22e154f", size = 762744, upload-time = "2026-11-05T02:26:58.412Z" }, -] - [[package]] name = "idna" version = "3.18" @@ -525,7 +457,6 @@ dependencies = [ { name = "requests" }, { name = "ruamel-yaml" }, { name = "ruff" }, - { name = "source-app-manager" }, ] [package.metadata] @@ -542,7 +473,6 @@ requires-dist = [ { name = "requests", specifier = "==2.33.0" }, { name = "ruamel-yaml", specifier = ">=0.18" }, { name = "ruff", specifier = ">=0.15.5" }, - { name = "source-app-manager", editable = "uv-app-manager" }, ] [[package]] @@ -972,23 +902,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/46/1f/4323ee8fdca5c441b349cd5569934d53a1fd9e3695d53089e18e20ef3611/shiboken6-6.8.0.2-cp39-abi3-win_amd64.whl", hash = "sha256:b11e750e696bb565d897e0f5836710edfb86bd355f87b09988bd31b2aad404d3", size = 1146649, upload-time = "2024-10-24T12:06:58.305Z" }, ] -[[package]] -name = "source-app-manager" -version = "0.1.0" -source = { editable = "uv-app-manager" } -dependencies = [ - { name = "dulwich" }, - { name = "portalocker" }, - { name = "uv" }, -] - -[package.metadata] -requires-dist = [ - { name = "dulwich", specifier = ">=1.2.21" }, - { name = "portalocker", specifier = ">=4.0" }, - { name = "uv", specifier = "==0.11.28" }, -] - [[package]] name = "tee" version = "0.0.3" @@ -1013,32 +926,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] -[[package]] -name = "uv" -version = "0.11.28" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a2/a2/bfd6755b40682ef7e775ddb9d52823dea6551352f4244106da4bad37cd3c/uv-0.11.28.tar.gz", hash = "sha256:df86cfd135542a833e9f84708b3b8dbaa987a3b9db85b267062db49ab639d242", size = 5985690, upload-time = "2026-07-07T23:12:47.095Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/5d/507b829e79353fe3dcb2779cde8f64497d9c99f9b08b18b8f55ee3bf1786/uv-0.11.28-py3-none-linux_armv6l.whl", hash = "sha256:ae5bbdb6150adcd625f5fa720b04abf2014247d878d1035f19751bb0e7274543", size = 25893158, upload-time = "2026-07-07T23:11:55.77Z" }, - { url = "https://files.pythonhosted.org/packages/10/54/50c85a663ce723e061523ab4ac8b01b650077584e80950f9c93fd073979f/uv-0.11.28-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:bb11d94cb848ff58af79e0bb5e4037cd324d27dbe2dabb7746db698b724a9a20", size = 25041511, upload-time = "2026-07-07T23:11:58.885Z" }, - { url = "https://files.pythonhosted.org/packages/32/e1/49968cab72f16a7d6c45d095d319f9efbe8ce05f3f15c5f7104493694289/uv-0.11.28-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e9eb317b1cdb249887df77ac232d8a9448f26858b2399f9f2949c6a7b9bedf88", size = 23570471, upload-time = "2026-07-07T23:12:01.832Z" }, - { url = "https://files.pythonhosted.org/packages/b8/4d/c9fe448dcd5cf65a5f054517aa42551b7f0920710a6891d98af321a06b22/uv-0.11.28-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:041e4b80bebc58d7142ac9394370cacd73185fd8d066d6675d14707d83408f6d", size = 25594677, upload-time = "2026-07-07T23:12:04.597Z" }, - { url = "https://files.pythonhosted.org/packages/07/aa/4c0c71075ba66cc594f856cbd98844058fcb53cb4dd8a6fccda8419562bb/uv-0.11.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:185416a5316df8c5442b47178349f1f27fc1034468670ac1fb499eae3b25bd68", size = 25427944, upload-time = "2026-07-07T23:12:07.226Z" }, - { url = "https://files.pythonhosted.org/packages/6c/77/50fef66f4e26bf771429e1d88f849476d8ed21f0fb0708acaa53dc5772a5/uv-0.11.28-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a4a9fe246cb2882532277f5d5e5bd8a59462981462a2f98426f35ecfca82460e", size = 25448458, upload-time = "2026-07-07T23:12:10.894Z" }, - { url = "https://files.pythonhosted.org/packages/d0/93/720f45af65ebda460166dc64f3318acd65f7bd3a8e326fbd21810fd920ee/uv-0.11.28-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6f7ce6f6015a3e857bc6a663514afa62856b669ee5c1bd120e4c58ac2ef5513d", size = 26917218, upload-time = "2026-07-07T23:12:13.802Z" }, - { url = "https://files.pythonhosted.org/packages/cd/27/a9b68a15a5fe8db7103bea514c2adb79e9b1114fc8dc96fb39dbd7a5b898/uv-0.11.28-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6b3d0ea11e83b373a2166b82dd0864f5677fbadf98db64541ab2e59c42968905", size = 27771542, upload-time = "2026-07-07T23:12:16.519Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fd/208607a7f5f86188775387fe0839ef97cf8d013e8d0e909140b7fdb7d0d1/uv-0.11.28-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c60294e3be4fa203a04015fc02ac8a31d936e86fde06dcb43c7f8f22661dfff", size = 26972190, upload-time = "2026-07-07T23:12:19.191Z" }, - { url = "https://files.pythonhosted.org/packages/75/2e/62273ee6c9fbebccd8248c153b44870f81ebf5267c31edf4c095d78537fb/uv-0.11.28-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49fe42df9f42056037473f3876adec1615709b57d3470ed39178ff420f3afb9f", size = 27127688, upload-time = "2026-07-07T23:12:22.43Z" }, - { url = "https://files.pythonhosted.org/packages/dd/8c/b15212904e6f0aa4a3709dc86838c6fa070fe97c7e96b3f10174a26b16e3/uv-0.11.28-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fab3c31007a611866475824a666f5a721bf0c9335db806355a97fcfba2a6bbb7", size = 25715221, upload-time = "2026-07-07T23:12:25.144Z" }, - { url = "https://files.pythonhosted.org/packages/64/35/b83b7c599474aaf1277c2224c09679640c2320562155c4b6ece1c6f014c1/uv-0.11.28-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:2e91eb8a0b00d5f4427195fc818bcaa4d8bb4fccb79f4e973e74802419ab06ca", size = 26392793, upload-time = "2026-07-07T23:12:27.848Z" }, - { url = "https://files.pythonhosted.org/packages/81/49/8093318206dee51b5cfcabbf110892ff63cfd897a5df002e2d8b61350fe6/uv-0.11.28-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:47e3f12fe6f5c80a01639d8df36efde7bdddfc3bbc52250df623547d8d393105", size = 26522809, upload-time = "2026-07-07T23:12:30.757Z" }, - { url = "https://files.pythonhosted.org/packages/cf/c5/b26d82e9297c29c201f61698ee56bba956f94953b23089532d026a97d93f/uv-0.11.28-py3-none-musllinux_1_1_i686.whl", hash = "sha256:d01c7c665511c047f350e587b8b6557c96b61b2eddafbcd8964f0cc2f5b9afbe", size = 26156793, upload-time = "2026-07-07T23:12:33.449Z" }, - { url = "https://files.pythonhosted.org/packages/d0/c2/163e89424668d6c01499efbe85a854ad38f07834bde3f2b16df159eab1d5/uv-0.11.28-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:3fcfda468448093f4d5961ca8c068b0aeec2d02f7226d58ee8513321a929fe4f", size = 27327614, upload-time = "2026-07-07T23:12:36.252Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c9/db4cb824777d013272ccfa77db07a4d12bf1584899458c1917a4b5a4069d/uv-0.11.28-py3-none-win32.whl", hash = "sha256:692edef9cf1d2dd69bb9d9fc01f281a82610547900ce227a3cb269cdf988b5ce", size = 24665179, upload-time = "2026-07-07T23:12:38.803Z" }, - { url = "https://files.pythonhosted.org/packages/40/bc/d67b18cddd54c503c7bad2b189a47fd7a1d07ea10b9212624f892b985498/uv-0.11.28-py3-none-win_amd64.whl", hash = "sha256:f4fcf2c8d9f1444b900e6b8dbbb828825fb76eca01acd18aeaa5c90240408cda", size = 27603677, upload-time = "2026-07-07T23:12:41.985Z" }, - { url = "https://files.pythonhosted.org/packages/57/94/dc31a771eac989973219c730552dbcf5bf7ea6652dba4ba89b1bbdc75a80/uv-0.11.28-py3-none-win_arm64.whl", hash = "sha256:e94560995737c50525d586da553521fbafe9ef06641e7d885db4b270f53ee84d", size = 25839294, upload-time = "2026-07-07T23:12:44.893Z" }, -] - [[package]] name = "zipp" version = "4.1.0"