From 55e3489c19963e87ec496c55a0fe9db148f272f2 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:22:15 +0800 Subject: [PATCH 01/23] =?UTF-8?q?refactor:=20=E6=B7=BB=E5=8A=A0=E8=8E=B7?= =?UTF-8?q?=E5=8F=96=E8=B5=84=E6=BA=90=E8=B7=AF=E5=BE=84=E5=87=BD=E6=95=B0?= =?UTF-8?q?=EF=BC=8Cexe=E6=97=81=E8=BE=B9=E6=89=BE=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E7=9A=84=E6=97=B6=E5=80=99=E5=9B=9E=E9=80=80=E5=88=B0MEIPASS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/utils/i18_utils.py | 2 +- src/one_dragon_qt/view/like_interface.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/one_dragon/utils/i18_utils.py b/src/one_dragon/utils/i18_utils.py index 003e9bf..bea4efb 100644 --- a/src/one_dragon/utils/i18_utils.py +++ b/src/one_dragon/utils/i18_utils.py @@ -15,7 +15,7 @@ def get_translations(model: str, lang: str): :param lang: 语言 :return: """ - translate_path = os_utils.get_path_under_work_dir('assets', 'text', 'output') + translate_path = os_utils.get_resource_path('assets', 'text', 'output') lang_dir = os.path.join(translate_path, lang, 'LC_MESSAGES', f'{model}.mo') # 未有对应的文本mo文件 if not os.path.exists(lang_dir): diff --git a/src/one_dragon_qt/view/like_interface.py b/src/one_dragon_qt/view/like_interface.py index 9693d65..45085f7 100644 --- a/src/one_dragon_qt/view/like_interface.py +++ b/src/one_dragon_qt/view/like_interface.py @@ -37,7 +37,7 @@ def get_content_widget(self) -> QWidget: content.add_widget(cafe_opt) img_label = ImageLabel() - img = cv2_utils.read_image(os.path.join(os_utils.get_path_under_work_dir('assets', 'ui'), 'sponsor_wechat.png')) + img = cv2_utils.read_image(os_utils.get_resource_path('assets', 'ui', 'sponsor_wechat.png')) image = Cv2Image(img) img_label.setImage(image) img_label.setFixedWidth(250) From e2bdac1effed01056c8aaf5f346a9b40ddc5c901 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Mon, 6 Apr 2026 15:23:26 +0800 Subject: [PATCH 02/23] =?UTF-8?q?refactor:=20=E4=BD=BF=E7=94=A8onedir?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=E9=81=BF=E5=85=8D=E5=8F=8D=E5=A4=8D=E8=A7=A3?= =?UTF-8?q?=E5=8E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/OneDragon ScriptChainer.spec | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/deploy/OneDragon ScriptChainer.spec b/deploy/OneDragon ScriptChainer.spec index 9136733..55606d1 100644 --- a/deploy/OneDragon ScriptChainer.spec +++ b/deploy/OneDragon ScriptChainer.spec @@ -22,16 +22,14 @@ pyz = PYZ(a.pure) exe = EXE( pyz, a.scripts, - a.binaries, - a.datas, [], + exclude_binaries=True, name='OneDragon ScriptChainer', debug=False, bootloader_ignore_signals=False, strip=False, upx=True, upx_exclude=[], - runtime_tmpdir=None, console=True, disable_windowed_traceback=False, argv_emulation=False, @@ -40,4 +38,15 @@ exe = EXE( entitlements_file=None, uac_admin=False, icon=['..\\assets\\ui\\editor_icon.ico'], + contents_directory='.runtime', +) + +coll = COLLECT( + exe, + a.binaries, + a.datas, + strip=False, + upx=True, + upx_exclude=[], + name='OneDragon ScriptChainer', ) From ad5083e74b3a5d7a91f76bcb6d00d85b1c785152 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:22:05 +0800 Subject: [PATCH 03/23] =?UTF-8?q?ci:=20=E6=9B=B4=E6=96=B0=E6=89=93?= =?UTF-8?q?=E5=8C=85=E6=B5=81=E7=A8=8B=E9=80=82=E5=BA=94=E7=9B=AE=E5=BD=95?= =?UTF-8?q?=E6=89=93=E5=8C=85=E5=BD=A2=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-release.yml | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index b5adab0..bd638e0 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -136,13 +136,14 @@ jobs: cd deploy pyinstaller "OneDragon ScriptChainer.spec" pyinstaller "OneDragon ScriptChainer Runner.spec" + Copy-Item "dist/OneDragon ScriptChainer Runner.exe" -Destination "dist/OneDragon ScriptChainer/" -Force - name: Upload Editor if: ${{ env.CREATE_RELEASE == 'false' }} uses: actions/upload-artifact@v4 with: name: Editor - path: deploy/dist/OneDragon ScriptChainer.exe + path: deploy/dist/OneDragon ScriptChainer/ - name: Upload Runner if: ${{ env.CREATE_RELEASE == 'false' }} @@ -157,7 +158,7 @@ jobs: with: name: dist if-no-files-found: error - path: deploy/dist + path: deploy/dist/OneDragon ScriptChainer/ release: runs-on: windows-latest @@ -173,7 +174,7 @@ jobs: uses: actions/download-artifact@v4 with: name: dist - path: . + path: OneDragon ScriptChainer - name: Prepare release packages shell: pwsh @@ -182,8 +183,7 @@ jobs: run: | $version = $env:RELEASE_VERSION - # 在根目录打包两个 exe - Compress-Archive -Path "OneDragon ScriptChainer.exe","OneDragon ScriptChainer Runner.exe" -DestinationPath "OneDragon-ScriptChainer-$version.zip" -Force + Compress-Archive -Path "OneDragon ScriptChainer" -DestinationPath "OneDragon-ScriptChainer-$version.zip" -Force - name: Generate Changelog @@ -310,10 +310,12 @@ jobs: ### 使用方法 1. 下载 `OneDragon-ScriptChainer-${{ needs.build.outputs.version }}.zip` - 2. 解压到任意目录 + 2. 如果是更新,请先删除旧版的 `.runtime` 文件夹,再解压覆盖 3. 运行 `OneDragon ScriptChainer.exe` 创建和编辑脚本链 4. 运行 `OneDragon ScriptChainer Runner.exe` 执行脚本链 + > 注意:`OneDragon ScriptChainer.exe` 所在目录包含运行时依赖(`.runtime` 文件夹),请勿单独移动该文件。 + ### 命令行参数 `OneDragon ScriptChainer.exe` 支持以下参数: From 21dec9a44d5fb08be259572bc122569c85743b05 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 8 Apr 2026 00:55:00 +0800 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20=E5=90=8C=E6=AD=A5=E4=BB=8EMEIPAS?= =?UTF-8?q?S=E8=8E=B7=E5=8F=96=E8=B5=84=E6=BA=90=E7=9A=84=E5=8F=98?= =?UTF-8?q?=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/OneDragon ScriptChainer Runner.spec | 2 +- deploy/OneDragon ScriptChainer.spec | 4 +-- src/one_dragon/base/config/yaml_operator.py | 38 ++++++++++++--------- src/one_dragon/utils/i18_utils.py | 2 +- src/script_chainer/win_exe/script_runner.py | 2 +- 5 files changed, 26 insertions(+), 22 deletions(-) diff --git a/deploy/OneDragon ScriptChainer Runner.spec b/deploy/OneDragon ScriptChainer Runner.spec index 7567860..e111db0 100644 --- a/deploy/OneDragon ScriptChainer Runner.spec +++ b/deploy/OneDragon ScriptChainer Runner.spec @@ -5,7 +5,7 @@ a = Analysis( ['..\\src\\script_chainer\\win_exe\\script_runner.py'], pathex=[], binaries=[], - datas=[('../config/project.yml', 'config')], + datas=[('../config/project.yml', 'resources/config')], hiddenimports=[], hookspath=[], hooksconfig={}, diff --git a/deploy/OneDragon ScriptChainer.spec b/deploy/OneDragon ScriptChainer.spec index 55606d1..03beccb 100644 --- a/deploy/OneDragon ScriptChainer.spec +++ b/deploy/OneDragon ScriptChainer.spec @@ -6,8 +6,8 @@ a = Analysis( pathex=[], binaries=[], datas=[ - ('../config/project.yml', 'config'), - ('../assets', 'assets') + ('../config/project.yml', 'resources/config'), + ('../assets', 'resources/assets') ], hiddenimports=[], hookspath=[], diff --git a/src/one_dragon/base/config/yaml_operator.py b/src/one_dragon/base/config/yaml_operator.py index 0c089b8..3b0d69f 100644 --- a/src/one_dragon/base/config/yaml_operator.py +++ b/src/one_dragon/base/config/yaml_operator.py @@ -1,33 +1,35 @@ import os -import sys -from typing import Optional import yaml +from one_dragon.utils import yaml_utils from one_dragon.utils.log_utils import log +from one_dragon.utils.os_utils import get_resource_path +cached_yaml_data: dict[str, tuple[float, dict]] = {} -def get_temp_config_path(file_path: str) -> str: - """ - 优先检查PyInstaller运行时的_MEIPASS目录下是否有对应的yml文件 - 有则返回该路径,否则返回原路径 - """ - if hasattr(sys, '_MEIPASS'): - mei_path = os.path.join(sys._MEIPASS, 'config', os.path.basename(file_path)) - if os.path.exists(mei_path): - return mei_path - return file_path +def read_cache_or_load(file_path: str): + cached = cached_yaml_data.get(file_path) + last_modify = os.path.getmtime(file_path) + if cached is not None and cached[0] == last_modify: + return cached[1] + + with open(file_path, 'r', encoding='utf-8') as file: + log.debug(f"加载yaml: {file_path}") + data = yaml_utils.safe_load(file) + cached_yaml_data[file_path] = (last_modify, data) + return data class YamlOperator: - def __init__(self, file_path: Optional[str] = None): + def __init__(self, file_path: str | None = None): """ yml文件的操作器 :param file_path: yml文件的路径。不传入时认为是mock,用于测试。 """ - self.file_path: str = get_temp_config_path(file_path) if file_path else None + self.file_path: str | None = get_resource_path(file_path) if file_path else None """yml文件的路径""" self.data: dict = {} @@ -46,8 +48,7 @@ def __read_from_file(self) -> None: return try: - with open(self.file_path, 'r', encoding='utf-8') as file: - self.data = yaml.safe_load(file) + self.data = read_cache_or_load(self.file_path) except Exception: log.error(f'文件读取失败 将使用默认值 {self.file_path}', exc_info=True) return @@ -91,12 +92,15 @@ def delete(self): 删除配置文件 :return: """ + if self.file_path is None: + return if os.path.exists(self.file_path): os.remove(self.file_path) + @property def is_file_exists(self) -> bool: """ 配置文件是否存在 :return: """ - return os.path.exists(self.file_path) + return bool(self.file_path) and os.path.exists(self.file_path) diff --git a/src/one_dragon/utils/i18_utils.py b/src/one_dragon/utils/i18_utils.py index bea4efb..9bf1439 100644 --- a/src/one_dragon/utils/i18_utils.py +++ b/src/one_dragon/utils/i18_utils.py @@ -10,7 +10,7 @@ def get_translations(model: str, lang: str): """ - 加载语音 + 加载语言 :param model: 模块 将ocr 界面 日志等翻译区分开来 :param lang: 语言 :return: diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 3665464..2700e75 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -566,7 +566,7 @@ def run_chain(chain_name: str = '01', shutdown_delay: int = 0, debug_index: int log.error(f'初始化上下文实例失败: {e}') try: - if not chain_config.is_file_exists(): + if not chain_config.is_file_exists: print_message(f'脚本链配置不存在 {chain_name}', "ERROR") else: attach_targets = chain_config.compute_attach_targets() From ce04c5b487a33f07f9b485a5080bf1b70e1677cf Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 8 Apr 2026 01:25:22 +0800 Subject: [PATCH 05/23] =?UTF-8?q?refactor:=20yaml=E5=9B=9E=E9=80=80?= =?UTF-8?q?=E7=A7=BB=E5=88=B0config=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/base/config/yaml_config.py | 77 +++++++++++++++------ src/one_dragon/base/config/yaml_operator.py | 3 +- src/one_dragon/utils/os_utils.py | 2 +- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/src/one_dragon/base/config/yaml_config.py b/src/one_dragon/base/config/yaml_config.py index 4db354a..cc9622e 100644 --- a/src/one_dragon/base/config/yaml_config.py +++ b/src/one_dragon/base/config/yaml_config.py @@ -1,6 +1,5 @@ import os import shutil -from typing import Optional, List from one_dragon.base.config.yaml_operator import YamlOperator from one_dragon.utils import os_utils @@ -8,21 +7,28 @@ class YamlConfig(YamlOperator): - def __init__(self, - module_name: str, - instance_idx: Optional[int] = None, - sub_dir: Optional[List[str]] = None, - sample: bool = False, copy_from_sample: bool = False, - is_mock: bool = False): - self.instance_idx: Optional[int] = instance_idx + def __init__( + self, + module_name: str, + backup_module_name: str | None = None, + instance_idx: int | None = None, + sub_dir: list[str] | None = None, + sample: bool = False, copy_from_sample: bool = False, + read_sample_only: bool = False, + is_mock: bool = False + ): + self.instance_idx: int | None = instance_idx """传入时 该配置为一个的脚本实例独有的配置""" - self.sub_dir: Optional[List[str]] = sub_dir + self.sub_dir: list[str] | None = sub_dir """配置所在的子目录""" self.module_name: str = module_name """配置文件名称""" + self.backup_module_name: str | None = backup_module_name + """备用的配置文件名称 主要用于配置文件改名时做迁移使用""" + self.is_mock: bool = is_mock """mock情况下 不读取文件 也不会实际保存 用于测试""" @@ -32,9 +38,12 @@ def __init__(self, self._copy_from_sample: bool = copy_from_sample """配置文件不存在时 是否从sample文件中读取""" + self._read_sample_only: bool = read_sample_only + """是否只读取sample文件(即使.yml文件存在也只读sample)""" + YamlOperator.__init__(self, self._get_yaml_file_path()) - def _get_yaml_file_path(self) -> Optional[str]: + def _get_yaml_file_path(self) -> str | None: """ 获取配置文件的路径 如果只有sample文件,就复制一个到实例文件夹下 @@ -48,16 +57,38 @@ def _get_yaml_file_path(self) -> Optional[str]: if self.sub_dir is not None: sub_dir = sub_dir + self.sub_dir - yml_path = os.path.join(os_utils.get_path_under_work_dir(*sub_dir), f'{self.module_name}.yml') - sample_yml_path = os.path.join(os_utils.get_path_under_work_dir(*sub_dir), f'{self.module_name}.sample.yml') - usage_yml_path = sample_yml_path if self._sample and not os.path.exists(yml_path) else yml_path - use_sample = self._sample and not os.path.exists(yml_path) + dir_path = os_utils.get_path_under_work_dir(*sub_dir) + + yml_path = os.path.join(dir_path, f'{self.module_name}.yml') + sample_yml_path = os.path.join(dir_path, f'{self.module_name}.sample.yml') + + # 只读sample文件模式 + if self._read_sample_only and os.path.exists(sample_yml_path): + return sample_yml_path - if use_sample and self._copy_from_sample: - shutil.copyfile(sample_yml_path, yml_path) - usage_yml_path = yml_path + # 指定文件存在时 直接使用 + if os.path.exists(yml_path): + return yml_path - return usage_yml_path + # 备用文件存在时 复制使用 + if self.backup_module_name is not None: + backup_yml_path = os.path.join(dir_path, f'{self.backup_module_name}.yml') + if os.path.exists(backup_yml_path): + shutil.copyfile(backup_yml_path, yml_path) + return yml_path + + # 最后看是否有示例文件 + if self._sample and os.path.exists(sample_yml_path): + if self._copy_from_sample: + shutil.copyfile(sample_yml_path, yml_path) + return sample_yml_path + + # 冻结环境回退到 MEIPASS/resources + frozen_path = os_utils.get_resource_path(*sub_dir, f'{self.module_name}.yml') + if os.path.exists(frozen_path): + return frozen_path + + return yml_path @property def is_sample(self) -> bool: @@ -65,11 +96,13 @@ def is_sample(self) -> bool: 是否样例文件 :return: """ + if self.file_path is None: + return False return self.file_path.endswith('.sample.yml') def get_prop_adapter(self, prop: str, - getter_convert: Optional[str] = None, - setter_convert: Optional[str] = None): + getter_convert: str | None = None, + setter_convert: str | None = None): """ 获取一个配置适配器 :param prop: 配置字段 @@ -77,7 +110,9 @@ def get_prop_adapter(self, prop: str, :param setter_convert: 设置时的转换器 :return: """ - from one_dragon_qt.widgets.setting_card.yaml_config_adapter import YamlConfigAdapter + from one_dragon_qt.widgets.setting_card.yaml_config_adapter import ( + YamlConfigAdapter, + ) return YamlConfigAdapter( config=self, field=prop, diff --git a/src/one_dragon/base/config/yaml_operator.py b/src/one_dragon/base/config/yaml_operator.py index 3b0d69f..7b8cba6 100644 --- a/src/one_dragon/base/config/yaml_operator.py +++ b/src/one_dragon/base/config/yaml_operator.py @@ -4,7 +4,6 @@ from one_dragon.utils import yaml_utils from one_dragon.utils.log_utils import log -from one_dragon.utils.os_utils import get_resource_path cached_yaml_data: dict[str, tuple[float, dict]] = {} @@ -29,7 +28,7 @@ def __init__(self, file_path: str | None = None): :param file_path: yml文件的路径。不传入时认为是mock,用于测试。 """ - self.file_path: str | None = get_resource_path(file_path) if file_path else None + self.file_path: str | None = file_path """yml文件的路径""" self.data: dict = {} diff --git a/src/one_dragon/utils/os_utils.py b/src/one_dragon/utils/os_utils.py index 6075106..8668b49 100644 --- a/src/one_dragon/utils/os_utils.py +++ b/src/one_dragon/utils/os_utils.py @@ -35,7 +35,7 @@ def get_path_under_work_dir(*sub_paths: str) -> str: def get_resource_path(*sub_paths: str) -> str: """获取资源文件路径。 - 优先查找工作目录下的路径,不存在时回退到 PyInstaller _MEIPASS。 + 优先查找工作目录下的路径,不存在时回退到 PyInstaller _MEIPASS/resources。 """ work_path = os.path.join(get_work_dir(), *sub_paths) if os.path.exists(work_path): From 60ede4227661b1e7f87496012c84a7ecbe4482cb Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:10:41 +0800 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20=E4=BB=8EGitHub=E6=8B=89=E5=8F=96?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gui/page/editor_setting_interface.py | 51 +++- .../services/github_update_service.py | 242 ++++++++++++++++++ 2 files changed, 292 insertions(+), 1 deletion(-) create mode 100644 src/script_chainer/services/github_update_service.py diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index 9e95e8f..90ac6b9 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -1,5 +1,6 @@ +from PySide6.QtCore import QThread, Signal from PySide6.QtGui import QColor -from PySide6.QtWidgets import QWidget +from PySide6.QtWidgets import QApplication, QWidget from qfluentwidgets import ( ColorDialog, FluentIcon, @@ -9,6 +10,7 @@ ) from one_dragon.custom.custom_config import ThemeEnum +from one_dragon.utils.i18_utils import gt from one_dragon_qt.services.theme_manager import ThemeManager from one_dragon_qt.widgets.column import Column from one_dragon_qt.widgets.setting_card.combo_box_setting_card import ( @@ -17,12 +19,34 @@ from one_dragon_qt.widgets.setting_card.push_setting_card import PushSettingCard from one_dragon_qt.widgets.vertical_scroll_interface import VerticalScrollInterface from script_chainer.context.script_chainer_context import ScriptChainerContext +from script_chainer.services.github_update_service import GithubUpdateService + + +class GithubUpdateRunner(QThread): + + progress_changed = Signal(float, str) + finished = Signal(bool, str) + + def __init__(self, service: GithubUpdateService): + QThread.__init__(self) + self.service = service + + def run(self) -> None: + success, message = self.service.download_and_restart(self._on_progress) + self.finished.emit(success, message) + + def _on_progress(self, progress: float, message: str) -> None: + self.progress_changed.emit(progress, message) class EditorSettingInterface(VerticalScrollInterface): def __init__(self, ctx: ScriptChainerContext, parent=None): self.ctx: ScriptChainerContext = ctx + self.github_update_service = GithubUpdateService(ctx) + self.github_update_runner = GithubUpdateRunner(self.github_update_service) + self.github_update_runner.progress_changed.connect(self._on_github_update_progress) + self.github_update_runner.finished.connect(self._on_github_update_finished) VerticalScrollInterface.__init__( self, @@ -55,6 +79,15 @@ def get_basic_group(self) -> SettingCardGroup: self.theme_color_mode_opt.clicked.connect(self._on_custom_theme_color_clicked) group.addSettingCard(self.theme_color_mode_opt) + self.github_update_opt = PushSettingCard( + icon=FluentIcon.SYNC, + title='程序更新', + text='从 GitHub 更新', + content='下载最新发布版并重启替换', + ) + self.github_update_opt.clicked.connect(self._on_github_update_clicked) + group.addSettingCard(self.github_update_opt) + return group def on_interface_shown(self) -> None: @@ -84,3 +117,19 @@ def _update_custom_theme_color(self, color: QColor) -> None: self.ctx.custom_config.theme_color = color_tuple ThemeManager.set_theme_color(color_tuple) + def _on_github_update_clicked(self) -> None: + if self.github_update_runner.isRunning(): + return + self.github_update_opt.button.setEnabled(False) + self.github_update_opt.setContent(gt('正在下载 GitHub 最新发布版...')) + self.github_update_runner.start() + + def _on_github_update_progress(self, progress: float, message: str) -> None: + self.github_update_opt.setContent(message) + + def _on_github_update_finished(self, success: bool, message: str) -> None: + self.github_update_opt.setContent(message) + if success: + QApplication.quit() + return + self.github_update_opt.button.setEnabled(True) diff --git a/src/script_chainer/services/github_update_service.py b/src/script_chainer/services/github_update_service.py new file mode 100644 index 0000000..6f3171f --- /dev/null +++ b/src/script_chainer/services/github_update_service.py @@ -0,0 +1,242 @@ +import os +import shutil +import subprocess +import sys +import urllib.parse +import urllib.request +import zipfile +from collections.abc import Callable +from pathlib import Path, PurePosixPath +from typing import TYPE_CHECKING + +from one_dragon.utils import http_utils, os_utils +from one_dragon.utils.i18_utils import gt +from one_dragon.utils.log_utils import log + +if TYPE_CHECKING: + from script_chainer.context.script_chainer_context import ScriptChainerContext + +ProgressCallback = Callable[[float, str], None] + +APP_EXE_NAMES = ( + 'OneDragon ScriptChainer.exe', + 'OneDragon ScriptChainer Runner.exe', +) +RELEASE_ZIP_PREFIX = 'OneDragon-ScriptChainer' +UPDATE_DIR_NAME = 'github_update' +RUNTIME_DIR_NAME = '.runtime' + + +class GithubUpdateService: + """下载 GitHub Release,并在进程退出后替换发布版文件。""" + + def __init__(self, ctx: 'ScriptChainerContext'): + self.ctx = ctx + + def download_and_restart( + self, + progress_callback: ProgressCallback | None = None, + ) -> tuple[bool, str]: + if not os_utils.run_in_exe(): + return False, gt('当前不是发布版,无法自动更新') + + work_dir = Path(os_utils.get_work_dir()) + update_dir = work_dir / '.temp' / UPDATE_DIR_NAME + stage_dir = update_dir / 'stage' + + try: + if update_dir.exists(): + shutil.rmtree(update_dir) + stage_dir.mkdir(parents=True, exist_ok=True) + + if progress_callback is not None: + progress_callback(0, gt('正在获取 GitHub 最新版本')) + latest_tag = self._get_latest_tag() + release_zip_name = f'{RELEASE_ZIP_PREFIX}-{latest_tag}.zip' + zip_path = update_dir / release_zip_name + download_url = self._build_download_url(latest_tag, release_zip_name) + proxy = self.ctx.env_config.personal_proxy if self.ctx.env_config.is_personal_proxy else None + if not http_utils.download_file(download_url, str(zip_path), proxy, progress_callback): + return False, gt('下载 GitHub 更新失败') + + if progress_callback is not None: + progress_callback(1, gt('正在解压更新包')) + self._extract_release(zip_path, stage_dir) + + update_items = self._get_update_items(stage_dir) + if not update_items: + return False, gt('更新包中没有可替换文件') + + script_path = self._write_apply_script(work_dir, update_dir, update_items) + self._start_apply_script(script_path, work_dir) + return True, gt('更新包已下载完成,程序将重启并替换文件') + except Exception as e: + log.error('准备 GitHub 更新失败', exc_info=True) + return False, f"{gt('准备 GitHub 更新失败')}: {e}" + + def _get_latest_tag(self) -> str: + latest_url = f'{self.ctx.project_config.github_homepage}/releases/latest' + request_url = self._with_gh_proxy(latest_url) + proxy = self.ctx.env_config.personal_proxy if self.ctx.env_config.is_personal_proxy else None + opener = urllib.request.build_opener( + urllib.request.ProxyHandler({'http': proxy, 'https': proxy}) + if proxy is not None else urllib.request.ProxyHandler({}), + ) + try: + request = urllib.request.Request(request_url, method='HEAD') + with opener.open(request, timeout=15) as response: + final_url = response.geturl() + except Exception: + request = urllib.request.Request(request_url) + with opener.open(request, timeout=15) as response: + final_url = response.geturl() + + marker = '/releases/tag/' + if marker not in final_url: + raise RuntimeError(f'无法解析最新版本: {final_url}') + + tag = final_url.rsplit(marker, 1)[1].split('?', 1)[0].split('#', 1)[0] + tag = urllib.parse.unquote(tag).strip('/') + if not tag: + raise RuntimeError(f'无法解析最新版本: {final_url}') + return tag + + def _build_download_url(self, latest_tag: str, release_zip_name: str) -> str: + download_url = ( + f'{self.ctx.project_config.github_homepage}' + f'/releases/download/{latest_tag}/{release_zip_name}' + ) + return self._with_gh_proxy(download_url) + + def _with_gh_proxy(self, url: str) -> str: + if not self.ctx.env_config.is_gh_proxy: + return url + return f'{self.ctx.env_config.gh_proxy_url.rstrip("/")}/{url}' + + def _extract_release(self, zip_path: Path, stage_dir: Path) -> None: + stage_root = stage_dir.resolve() + with zipfile.ZipFile(zip_path) as zip_file: + root_dir = self._detect_root_dir(zip_file) + for info in zip_file.infolist(): + if info.is_dir(): + continue + + parts = PurePosixPath(info.filename.replace('\\', '/')).parts + if root_dir is not None and parts and parts[0] == root_dir: + parts = parts[1:] + if not parts or '..' in parts: + continue + if not self._should_extract(parts): + continue + + target_path = stage_dir.joinpath(*parts) + if not self._is_relative_to(target_path.resolve(), stage_root): + continue + + target_path.parent.mkdir(parents=True, exist_ok=True) + with zip_file.open(info) as source, target_path.open('wb') as target: + shutil.copyfileobj(source, target) + + def _detect_root_dir(self, zip_file: zipfile.ZipFile) -> str | None: + file_parts = [ + PurePosixPath(info.filename.replace('\\', '/')).parts + for info in zip_file.infolist() + if not info.is_dir() + ] + if not file_parts: + return None + + first_parts = [parts[0] for parts in file_parts if len(parts) > 1] + if len(first_parts) != len(file_parts): + return None + + root_dir = first_parts[0] + return root_dir if all(part == root_dir for part in first_parts) else None + + def _should_extract(self, parts: tuple[str, ...]) -> bool: + top_name = parts[0] + if top_name == RUNTIME_DIR_NAME: + return True + return len(parts) == 1 and top_name.lower().endswith('.exe') + + def _get_update_items(self, stage_dir: Path) -> list[Path]: + items: list[Path] = [] + for name in (*APP_EXE_NAMES, RUNTIME_DIR_NAME): + item_path = stage_dir / name + if item_path.exists(): + items.append(item_path) + + known_names = {item.name for item in items} + for item_path in stage_dir.glob('*.exe'): + if item_path.name not in known_names: + items.append(item_path) + return items + + def _write_apply_script( + self, + work_dir: Path, + update_dir: Path, + update_items: list[Path], + ) -> Path: + lines = [ + "$ErrorActionPreference = 'Stop'", + f'$ProcessIdToWait = {os.getpid()}', + f'$CurrentExe = {self._ps_quote(sys.executable)}', + f'$WorkDir = {self._ps_quote(str(work_dir))}', + 'Start-Sleep -Milliseconds 500', + 'try { Wait-Process -Id $ProcessIdToWait -Timeout 30 -ErrorAction SilentlyContinue } catch {}', + ] + + for item_path in update_items: + target_path = work_dir / item_path.name + backup_path = work_dir / f'{item_path.name}.bak' + lines.extend([ + f'$Source = {self._ps_quote(str(item_path))}', + f'$Target = {self._ps_quote(str(target_path))}', + f'$Backup = {self._ps_quote(str(backup_path))}', + 'if (Test-Path -LiteralPath $Backup) { Remove-Item -LiteralPath $Backup -Recurse -Force }', + 'if (Test-Path -LiteralPath $Target) { Move-Item -LiteralPath $Target -Destination $Backup -Force }', + 'try {', + ' Move-Item -LiteralPath $Source -Destination $Target -Force', + ' if (Test-Path -LiteralPath $Backup) { Remove-Item -LiteralPath $Backup -Recurse -Force }', + '} catch {', + ' if (Test-Path -LiteralPath $Backup) {', + ' if (Test-Path -LiteralPath $Target) { Remove-Item -LiteralPath $Target -Recurse -Force }', + ' Move-Item -LiteralPath $Backup -Destination $Target -Force', + ' }', + ' throw', + '}', + ]) + + lines.extend([ + 'Start-Process -FilePath $CurrentExe -WorkingDirectory $WorkDir', + f'Remove-Item -LiteralPath {self._ps_quote(str(update_dir))} -Recurse -Force', + ]) + + script_path = update_dir / 'apply_update.ps1' + script_path.write_text('\n'.join(lines), encoding='utf-8') + return script_path + + def _start_apply_script(self, script_path: Path, work_dir: Path) -> None: + subprocess.Popen( + [ + 'powershell', + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + str(script_path), + ], + cwd=str(work_dir), + creationflags=subprocess.CREATE_NO_WINDOW, + ) + + def _ps_quote(self, value: str) -> str: + return "'" + value.replace("'", "''") + "'" + + def _is_relative_to(self, path: Path, parent: Path) -> bool: + try: + path.relative_to(parent) + return True + except ValueError: + return False From 2491e3e7ad1e2fb85870823d2ff4f17b63818ade Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 1 May 2026 11:06:35 +0800 Subject: [PATCH 07/23] =?UTF-8?q?perf:=20=E8=AF=BB=E5=8F=96yaml=E8=BF=94?= =?UTF-8?q?=E5=9B=9E=E6=B7=B1=E6=8B=B7=E8=B4=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/base/config/yaml_operator.py | 22 ++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/one_dragon/base/config/yaml_operator.py b/src/one_dragon/base/config/yaml_operator.py index 7b8cba6..fc05195 100644 --- a/src/one_dragon/base/config/yaml_operator.py +++ b/src/one_dragon/base/config/yaml_operator.py @@ -1,3 +1,4 @@ +import copy import os import yaml @@ -7,17 +8,28 @@ cached_yaml_data: dict[str, tuple[float, dict]] = {} -def read_cache_or_load(file_path: str): + +def _validate_yaml_data(file_path: str, data) -> dict: + if data is None: + return {} + if not isinstance(data, dict): + raise TypeError( + f"YAML root must be a dict in {file_path}, got {type(data).__name__}" + ) + return data + + +def read_cache_or_load(file_path: str) -> dict: cached = cached_yaml_data.get(file_path) last_modify = os.path.getmtime(file_path) if cached is not None and cached[0] == last_modify: - return cached[1] + return copy.deepcopy(cached[1]) - with open(file_path, 'r', encoding='utf-8') as file: + with open(file_path, encoding='utf-8') as file: log.debug(f"加载yaml: {file_path}") - data = yaml_utils.safe_load(file) + data = _validate_yaml_data(file_path, yaml_utils.safe_load(file)) cached_yaml_data[file_path] = (last_modify, data) - return data + return copy.deepcopy(data) class YamlOperator: From c276a3ae5bd1949004f8e3a59b0d4e86e0d31fbb Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 1 May 2026 11:12:07 +0800 Subject: [PATCH 08/23] =?UTF-8?q?ci:=20=E4=BF=AE=E5=A4=8D=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-release.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index bd638e0..430a89d 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -142,14 +142,16 @@ jobs: if: ${{ env.CREATE_RELEASE == 'false' }} uses: actions/upload-artifact@v4 with: - name: Editor + name: Editor-r${{ github.run_number }} + include-hidden-files: true path: deploy/dist/OneDragon ScriptChainer/ - name: Upload Runner if: ${{ env.CREATE_RELEASE == 'false' }} uses: actions/upload-artifact@v4 with: - name: Runner + name: Runner-r${{ github.run_number }} + include-hidden-files: true path: deploy/dist/OneDragon ScriptChainer Runner.exe - name: Upload Dist @@ -157,6 +159,7 @@ jobs: uses: actions/upload-artifact@v4 with: name: dist + include-hidden-files: true if-no-files-found: error path: deploy/dist/OneDragon ScriptChainer/ From 2aed4ac4e5991428143ed3e5095a04f053b8bfc5 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 1 May 2026 13:36:47 +0800 Subject: [PATCH 09/23] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E6=A3=80?= =?UTF-8?q?=E6=9F=A5=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +- .../context/script_chainer_context.py | 6 + .../gui/page/editor_setting_interface.py | 293 ++++++++++++++++-- .../services/github_update_service.py | 70 ++++- 4 files changed, 328 insertions(+), 44 deletions(-) diff --git a/.gitignore b/.gitignore index d84134f..03a6a5b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ __pycache__/ .log/ config/script_chain/ config/custom.yml -config/push.yml \ No newline at end of file +config/push.yml +config/env.yml \ No newline at end of file diff --git a/src/script_chainer/context/script_chainer_context.py b/src/script_chainer/context/script_chainer_context.py index ad78074..6b9804e 100644 --- a/src/script_chainer/context/script_chainer_context.py +++ b/src/script_chainer/context/script_chainer_context.py @@ -6,10 +6,12 @@ from one_dragon.base.push.push_service import PushService from one_dragon.custom.custom_config import CustomConfig from one_dragon.envs.env_config import EnvConfig +from one_dragon.envs.ghproxy_service import GhProxyService from one_dragon.envs.project_config import ProjectConfig from one_dragon.utils import os_utils from one_dragon.utils.log_utils import log from script_chainer.config.script_config import ScriptChainConfig +from script_chainer.services.github_update_service import GithubUpdateService ONE_DRAGON_CONTEXT_EXECUTOR = ThreadPoolExecutor(thread_name_prefix='one_dragon_context', max_workers=1) @@ -20,6 +22,8 @@ def __init__(self): self.project_config: ProjectConfig = ProjectConfig() self.env_config: EnvConfig = EnvConfig() self.custom_config: CustomConfig = CustomConfig() + self.gh_proxy_service: GhProxyService = GhProxyService(self.env_config) + self.github_update_service: GithubUpdateService = GithubUpdateService(self) self.push_service: PushService = PushService(self) self.notify_config: NotifyConfig = NotifyConfig(None, {}) self._init_lock = threading.Lock() @@ -30,6 +34,8 @@ def init(self) -> None: try: self.push_service.init_push_channels() + if self.env_config.is_gh_proxy: + self.gh_proxy_service.update_proxy_url() except Exception: log.error('初始化出错', exc_info=True) finally: diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index 90ac6b9..2c7df24 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -1,22 +1,33 @@ -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import Qt, QThread, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import QApplication, QWidget from qfluentwidgets import ( ColorDialog, + ComboBox, FluentIcon, + HyperlinkButton, + PrimaryPushButton, + PushButton, SettingCardGroup, Theme, setTheme, ) from one_dragon.custom.custom_config import ThemeEnum +from one_dragon.envs.env_config import ProxyTypeEnum +from one_dragon.utils import os_utils from one_dragon.utils.i18_utils import gt from one_dragon_qt.services.theme_manager import ThemeManager from one_dragon_qt.widgets.column import Column from one_dragon_qt.widgets.setting_card.combo_box_setting_card import ( ComboBoxSettingCard, ) +from one_dragon_qt.widgets.setting_card.multi_push_setting_card import ( + MultiPushSettingCard, +) from one_dragon_qt.widgets.setting_card.push_setting_card import PushSettingCard +from one_dragon_qt.widgets.setting_card.switch_setting_card import SwitchSettingCard +from one_dragon_qt.widgets.setting_card.text_setting_card import TextSettingCard from one_dragon_qt.widgets.vertical_scroll_interface import VerticalScrollInterface from script_chainer.context.script_chainer_context import ScriptChainerContext from script_chainer.services.github_update_service import GithubUpdateService @@ -25,28 +36,186 @@ class GithubUpdateRunner(QThread): progress_changed = Signal(float, str) - finished = Signal(bool, str) + update_finished = Signal(bool, str) def __init__(self, service: GithubUpdateService): QThread.__init__(self) self.service = service + self.target_version = '' def run(self) -> None: - success, message = self.service.download_and_restart(self._on_progress) - self.finished.emit(success, message) + success, message = self.service.download_and_restart(self.target_version, self._on_progress) + self.update_finished.emit(success, message) def _on_progress(self, progress: float, message: str) -> None: self.progress_changed.emit(progress, message) +class GithubUpdateChecker(QThread): + + check_finished = Signal(bool, str, str, str) + + def __init__(self, ctx: ScriptChainerContext): + QThread.__init__(self) + self.ctx = ctx + + def run(self) -> None: + try: + latest_stable, latest_beta = self.ctx.github_update_service.get_latest_tags() + self.check_finished.emit(True, latest_stable, latest_beta, '') + except Exception as e: + self.check_finished.emit(False, '', '', str(e)) + + +class GithubUpdateCard(MultiPushSettingCard): + + def __init__(self, ctx: ScriptChainerContext, parent=None): + self.ctx = ctx + self.current_version = '' + self.latest_stable_version = '' + self.latest_beta_version = '' + self.target_version = '' + + self.channel_combo = ComboBox() + self.channel_combo.addItem(gt('正式版'), userData='stable') + self.channel_combo.addItem(gt('测试版'), userData='beta') + self.channel_combo.setCurrentIndex(0) + self.channel_combo.currentIndexChanged.connect(self._on_channel_changed) + + self.check_btn = PushButton(gt('检查')) + self.check_btn.clicked.connect(self.check_and_update_display) + + self.update_btn = PrimaryPushButton(text=gt('更新')) + self.update_btn.clicked.connect(self._on_update_clicked) + + self.version_checker = GithubUpdateChecker(ctx) + self.version_checker.check_finished.connect(self._on_version_check_finished) + + self.update_runner = GithubUpdateRunner(ctx.github_update_service) + self.update_runner.progress_changed.connect(self._on_update_progress) + self.update_runner.update_finished.connect(self._on_update_finished) + + MultiPushSettingCard.__init__( + self, + btn_list=[self.channel_combo, self.check_btn, self.update_btn], + icon=FluentIcon.SYNC, + title='程序更新', + content='检查中...', + parent=parent, + ) + self.check_and_update_display() + + def check_and_update_display(self) -> None: + if self.version_checker.isRunning() or self.update_runner.isRunning(): + return + + self.setContent(gt('正在检查 GitHub 最新版本...')) + self.channel_combo.setDisabled(True) + self.check_btn.setDisabled(True) + self.update_btn.setDisabled(True) + self.update_btn.setText(gt('检查中')) + self.version_checker.start() + + def _on_version_check_finished( + self, + success: bool, + latest_stable_version: str, + latest_beta_version: str, + message: str, + ) -> None: + self.channel_combo.setEnabled(True) + self.check_btn.setEnabled(True) + + if not success: + self.latest_stable_version = '' + self.latest_beta_version = '' + self.target_version = '' + self.setContent(f"{gt('检查更新失败')}: {message}") + self.update_btn.setText(gt('重试')) + self.update_btn.setDisabled(True) + return + + self.current_version = self._current_version() + self.latest_stable_version = latest_stable_version + self.latest_beta_version = latest_beta_version + self._update_display_by_channel() + + def _on_channel_changed(self, _index: int) -> None: + if self.version_checker.isRunning() or self.update_runner.isRunning(): + return + self._update_display_by_channel() + + def _update_display_by_channel(self) -> None: + channel = self.channel_combo.currentData() + channel_name = gt('测试版') if channel == 'beta' else gt('正式版') + self.target_version = self.latest_beta_version if channel == 'beta' else self.latest_stable_version + + if not self.target_version: + self.setContent(f"{channel_name}{gt('暂无可用版本')}") + self.update_btn.setText(gt('不可用')) + self.update_btn.setDisabled(True) + return + + if not os_utils.run_in_exe(): + self.setContent( + f"{gt('当前版本')}: {self.current_version}; " + f"{channel_name}: {self.target_version}; " + f"{gt('当前不是发布版,无法自动更新')}" + ) + self.update_btn.setText(gt('不可用')) + self.update_btn.setDisabled(True) + elif self.current_version == self.target_version: + self.setContent(f"{gt('已是最新版本')} {self.current_version}") + self.update_btn.setText(gt('已最新')) + self.update_btn.setDisabled(True) + else: + self.setContent( + f"{gt('可更新')} {gt('当前版本')}: {self.current_version}; " + f"{channel_name}: {self.target_version}" + ) + self.update_btn.setText(gt('更新')) + self.update_btn.setEnabled(True) + + def _on_update_clicked(self) -> None: + if self.update_runner.isRunning(): + return + + self.channel_combo.setDisabled(True) + self.check_btn.setDisabled(True) + self.update_btn.setDisabled(True) + self.update_btn.setText(gt('更新中')) + self.setContent(gt('正在准备 GitHub 更新...')) + self.update_runner.target_version = self.target_version + self.update_runner.start() + + def _on_update_progress(self, progress: float, message: str) -> None: + if progress > 0: + self.setContent(f'{message} {progress:.0%}') + else: + self.setContent(message) + + def _on_update_finished(self, success: bool, message: str) -> None: + self.setContent(message) + if success: + self.update_btn.setText(gt('重启中')) + QApplication.quit() + return + + self.channel_combo.setEnabled(True) + self.check_btn.setEnabled(True) + self.update_btn.setText(gt('更新')) + self.update_btn.setEnabled(bool(self.target_version) and os_utils.run_in_exe()) + + def _current_version(self) -> str: + from one_dragon.version import __version__ + + return __version__ + + class EditorSettingInterface(VerticalScrollInterface): def __init__(self, ctx: ScriptChainerContext, parent=None): self.ctx: ScriptChainerContext = ctx - self.github_update_service = GithubUpdateService(ctx) - self.github_update_runner = GithubUpdateRunner(self.github_update_service) - self.github_update_runner.progress_changed.connect(self._on_github_update_progress) - self.github_update_runner.finished.connect(self._on_github_update_finished) VerticalScrollInterface.__init__( self, @@ -59,13 +228,14 @@ def __init__(self, ctx: ScriptChainerContext, parent=None): def get_content_widget(self) -> QWidget: content_widget = Column() - content_widget.add_widget(self.get_basic_group()) + content_widget.add_widget(self.get_appearance_group()) + content_widget.add_widget(self.get_update_group()) content_widget.add_stretch(1) return content_widget - def get_basic_group(self) -> SettingCardGroup: - group = SettingCardGroup('基础') + def get_appearance_group(self) -> SettingCardGroup: + group = SettingCardGroup(gt('外观')) self.theme_opt = ComboBoxSettingCard( icon=FluentIcon.CONSTRACT, title='界面主题', @@ -79,20 +249,73 @@ def get_basic_group(self) -> SettingCardGroup: self.theme_color_mode_opt.clicked.connect(self._on_custom_theme_color_clicked) group.addSettingCard(self.theme_color_mode_opt) - self.github_update_opt = PushSettingCard( + return group + + def get_update_group(self) -> SettingCardGroup: + group = SettingCardGroup(gt('更新')) + + self.github_update_opt = GithubUpdateCard(self.ctx) + group.addSettingCard(self.github_update_opt) + + self.proxy_type_opt = ComboBoxSettingCard( + icon=FluentIcon.GLOBE, + title='网络代理', + content='免费代理仅能加速 GitHub 更新,无法加速个人代理之外的请求', + options_enum=ProxyTypeEnum, + ) + self.proxy_type_opt.value_changed.connect(self._on_proxy_type_changed) + group.addSettingCard(self.proxy_type_opt) + + self.personal_proxy_input = TextSettingCard( + icon=FluentIcon.WIFI, + title='个人代理', + input_placeholder='http://127.0.0.1:8080', + ) + self.personal_proxy_input.value_changed.connect(self._on_proxy_changed) + group.addSettingCard(self.personal_proxy_input) + + self.gh_proxy_url_opt = TextSettingCard( + icon=FluentIcon.GLOBE, + title='免费代理', + ) + group.addSettingCard(self.gh_proxy_url_opt) + + self.auto_fetch_gh_proxy_url_opt = SwitchSettingCard( icon=FluentIcon.SYNC, - title='程序更新', - text='从 GitHub 更新', - content='下载最新发布版并重启替换', + title='自动获取免费代理地址', + content='获取失败时 可前往 https://ghproxy.link/ 查看自行更新', ) - self.github_update_opt.clicked.connect(self._on_github_update_clicked) - group.addSettingCard(self.github_update_opt) + self.fetch_gh_proxy_url_btn = PushButton(gt('获取'), self) + self.fetch_gh_proxy_url_btn.clicked.connect(self.on_fetch_gh_proxy_url_clicked) + self.auto_fetch_gh_proxy_url_opt.hBoxLayout.addWidget( + self.fetch_gh_proxy_url_btn, + 0, + Qt.AlignmentFlag.AlignRight, + ) + self.auto_fetch_gh_proxy_url_opt.hBoxLayout.addSpacing(16) + + self.goto_gh_proxy_link_btn = HyperlinkButton('https://ghproxy.link', gt('前往'), self) + self.auto_fetch_gh_proxy_url_opt.hBoxLayout.addWidget( + self.goto_gh_proxy_link_btn, + 0, + Qt.AlignmentFlag.AlignRight, + ) + self.auto_fetch_gh_proxy_url_opt.hBoxLayout.addSpacing(16) + + group.addSettingCard(self.auto_fetch_gh_proxy_url_opt) return group def on_interface_shown(self) -> None: VerticalScrollInterface.on_interface_shown(self) self.theme_opt.init_with_adapter(self.ctx.custom_config.get_prop_adapter('theme')) + self.proxy_type_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('proxy_type')) + self.personal_proxy_input.init_with_adapter(self.ctx.env_config.get_prop_adapter('personal_proxy')) + self.gh_proxy_url_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('gh_proxy_url')) + self.auto_fetch_gh_proxy_url_opt.init_with_adapter( + self.ctx.env_config.get_prop_adapter('auto_fetch_gh_proxy_url') + ) + self.update_proxy_ui() def on_theme_changed(self, index: int, value: str) -> None: """主题改变。 @@ -117,19 +340,27 @@ def _update_custom_theme_color(self, color: QColor) -> None: self.ctx.custom_config.theme_color = color_tuple ThemeManager.set_theme_color(color_tuple) - def _on_github_update_clicked(self) -> None: - if self.github_update_runner.isRunning(): - return - self.github_update_opt.button.setEnabled(False) - self.github_update_opt.setContent(gt('正在下载 GitHub 最新发布版...')) - self.github_update_runner.start() + def _on_proxy_type_changed(self, index: int, value: str) -> None: + self.update_proxy_ui() + self._on_proxy_changed() - def _on_github_update_progress(self, progress: float, message: str) -> None: - self.github_update_opt.setContent(message) + def _on_proxy_changed(self) -> None: + self.ctx.env_config.init_system_proxy() - def _on_github_update_finished(self, success: bool, message: str) -> None: - self.github_update_opt.setContent(message) - if success: - QApplication.quit() - return - self.github_update_opt.button.setEnabled(True) + def on_fetch_gh_proxy_url_clicked(self) -> None: + self.ctx.gh_proxy_service.update_proxy_url() + self.gh_proxy_url_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('gh_proxy_url')) + + def update_proxy_ui(self) -> None: + if self.ctx.env_config.proxy_type == ProxyTypeEnum.GHPROXY.value.value: + self.personal_proxy_input.hide() + self.gh_proxy_url_opt.show() + self.auto_fetch_gh_proxy_url_opt.show() + elif self.ctx.env_config.proxy_type == ProxyTypeEnum.PERSONAL.value.value: + self.personal_proxy_input.show() + self.gh_proxy_url_opt.hide() + self.auto_fetch_gh_proxy_url_opt.hide() + elif self.ctx.env_config.proxy_type == ProxyTypeEnum.NONE.value.value: + self.personal_proxy_input.hide() + self.gh_proxy_url_opt.hide() + self.auto_fetch_gh_proxy_url_opt.hide() diff --git a/src/script_chainer/services/github_update_service.py b/src/script_chainer/services/github_update_service.py index 6f3171f..3a09d12 100644 --- a/src/script_chainer/services/github_update_service.py +++ b/src/script_chainer/services/github_update_service.py @@ -1,3 +1,4 @@ +import json import os import shutil import subprocess @@ -35,6 +36,7 @@ def __init__(self, ctx: 'ScriptChainerContext'): def download_and_restart( self, + target_tag: str | None = None, progress_callback: ProgressCallback | None = None, ) -> tuple[bool, str]: if not os_utils.run_in_exe(): @@ -50,8 +52,8 @@ def download_and_restart( stage_dir.mkdir(parents=True, exist_ok=True) if progress_callback is not None: - progress_callback(0, gt('正在获取 GitHub 最新版本')) - latest_tag = self._get_latest_tag() + progress_callback(0, gt('正在获取 GitHub 更新版本')) + latest_tag = target_tag or self.get_latest_tag() release_zip_name = f'{RELEASE_ZIP_PREFIX}-{latest_tag}.zip' zip_path = update_dir / release_zip_name download_url = self._build_download_url(latest_tag, release_zip_name) @@ -74,21 +76,23 @@ def download_and_restart( log.error('准备 GitHub 更新失败', exc_info=True) return False, f"{gt('准备 GitHub 更新失败')}: {e}" - def _get_latest_tag(self) -> str: + def get_latest_tags(self) -> tuple[str, str]: + stable_tag = self.get_latest_tag() + try: + beta_tag = self.get_latest_beta_tag() + except Exception: + log.error('获取 GitHub 最新测试版失败', exc_info=True) + beta_tag = '' + return stable_tag, beta_tag + + def get_latest_tag(self) -> str: latest_url = f'{self.ctx.project_config.github_homepage}/releases/latest' request_url = self._with_gh_proxy(latest_url) - proxy = self.ctx.env_config.personal_proxy if self.ctx.env_config.is_personal_proxy else None - opener = urllib.request.build_opener( - urllib.request.ProxyHandler({'http': proxy, 'https': proxy}) - if proxy is not None else urllib.request.ProxyHandler({}), - ) try: - request = urllib.request.Request(request_url, method='HEAD') - with opener.open(request, timeout=15) as response: + with self._open_request(request_url, method='HEAD') as response: final_url = response.geturl() except Exception: - request = urllib.request.Request(request_url) - with opener.open(request, timeout=15) as response: + with self._open_request(request_url) as response: final_url = response.geturl() marker = '/releases/tag/' @@ -101,6 +105,48 @@ def _get_latest_tag(self) -> str: raise RuntimeError(f'无法解析最新版本: {final_url}') return tag + def get_latest_beta_tag(self) -> str: + repo_path = self._get_github_repo_path() + releases_url = f'https://api.github.com/repos/{repo_path}/releases?per_page=30' + with self._open_request(releases_url, headers={'User-Agent': 'OneDragon-ScriptChainer'}) as response: + releases = json.loads(response.read().decode('utf-8')) + + for release in releases: + if release.get('draft'): + continue + if release.get('prerelease'): + tag = str(release.get('tag_name', '')).strip() + if tag: + return tag + + return '' + + def _get_github_repo_path(self) -> str: + parsed = urllib.parse.urlparse(self.ctx.project_config.github_homepage) + repo_path = parsed.path.strip('/') + if repo_path.endswith('.git'): + repo_path = repo_path[:-4] + if repo_path.count('/') < 1: + raise RuntimeError(f'无法解析 GitHub 仓库地址: {self.ctx.project_config.github_homepage}') + return repo_path + + def _open_request( + self, + url: str, + method: str = 'GET', + headers: dict[str, str] | None = None, + ): + request = urllib.request.Request(url, method=method, headers=headers or {}) + return self._build_opener().open(request, timeout=15) + + def _build_opener(self): + proxy = self.ctx.env_config.personal_proxy if self.ctx.env_config.is_personal_proxy else None + proxy_handler = ( + urllib.request.ProxyHandler({'http': proxy, 'https': proxy}) + if proxy is not None else urllib.request.ProxyHandler({}) + ) + return urllib.request.build_opener(proxy_handler) + def _build_download_url(self, latest_tag: str, release_zip_name: str) -> str: download_url = ( f'{self.ctx.project_config.github_homepage}' From 9e331f2cad8b7b7718cdb12d6fd567dafd3f5331 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 1 May 2026 21:42:40 +0800 Subject: [PATCH 10/23] =?UTF-8?q?feat:=20=E5=90=8E=E5=8F=B0=E6=9B=B4?= =?UTF-8?q?=E6=96=B0ghproxy=E9=93=BE=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../context/script_chainer_context.py | 2 -- .../gui/page/editor_setting_interface.py | 36 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/src/script_chainer/context/script_chainer_context.py b/src/script_chainer/context/script_chainer_context.py index 6b9804e..959e653 100644 --- a/src/script_chainer/context/script_chainer_context.py +++ b/src/script_chainer/context/script_chainer_context.py @@ -34,8 +34,6 @@ def init(self) -> None: try: self.push_service.init_push_channels() - if self.env_config.is_gh_proxy: - self.gh_proxy_service.update_proxy_url() except Exception: log.error('初始化出错', exc_info=True) finally: diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index 2c7df24..834df6e 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -67,6 +67,19 @@ def run(self) -> None: self.check_finished.emit(False, '', '', str(e)) +class GhProxyUpdateRunner(QThread): + + update_finished = Signal(str) + + def __init__(self, ctx: ScriptChainerContext): + QThread.__init__(self) + self.ctx = ctx + + def run(self) -> None: + self.ctx.gh_proxy_service.update_proxy_url() + self.update_finished.emit(self.ctx.env_config.gh_proxy_url) + + class GithubUpdateCard(MultiPushSettingCard): def __init__(self, ctx: ScriptChainerContext, parent=None): @@ -216,6 +229,8 @@ class EditorSettingInterface(VerticalScrollInterface): def __init__(self, ctx: ScriptChainerContext, parent=None): self.ctx: ScriptChainerContext = ctx + self.gh_proxy_update_runner = GhProxyUpdateRunner(ctx) + self.gh_proxy_update_runner.update_finished.connect(self._on_gh_proxy_update_finished) VerticalScrollInterface.__init__( self, @@ -316,6 +331,7 @@ def on_interface_shown(self) -> None: self.ctx.env_config.get_prop_adapter('auto_fetch_gh_proxy_url') ) self.update_proxy_ui() + self.refresh_gh_proxy_url_by_config() def on_theme_changed(self, index: int, value: str) -> None: """主题改变。 @@ -348,8 +364,24 @@ def _on_proxy_changed(self) -> None: self.ctx.env_config.init_system_proxy() def on_fetch_gh_proxy_url_clicked(self) -> None: - self.ctx.gh_proxy_service.update_proxy_url() - self.gh_proxy_url_opt.init_with_adapter(self.ctx.env_config.get_prop_adapter('gh_proxy_url')) + self.start_gh_proxy_url_refresh() + + def refresh_gh_proxy_url_by_config(self) -> None: + if not self.ctx.env_config.auto_fetch_gh_proxy_url: + return + if self.ctx.env_config.proxy_type != ProxyTypeEnum.GHPROXY.value.value: + return + self.start_gh_proxy_url_refresh() + + def start_gh_proxy_url_refresh(self) -> None: + if self.gh_proxy_update_runner.isRunning(): + return + self.fetch_gh_proxy_url_btn.setDisabled(True) + self.gh_proxy_update_runner.start() + + def _on_gh_proxy_update_finished(self, _proxy_url: str) -> None: + self.gh_proxy_url_opt.setValue(_proxy_url, emit_signal=False) + self.fetch_gh_proxy_url_btn.setEnabled(True) def update_proxy_ui(self) -> None: if self.ctx.env_config.proxy_type == ProxyTypeEnum.GHPROXY.value.value: From 2ff368128b2de2acc86961364e49ae1846804820 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Fri, 1 May 2026 22:08:00 +0800 Subject: [PATCH 11/23] fix: address GitHub update review feedback --- .../gui/page/editor_setting_interface.py | 25 +++++++++++-------- .../services/github_update_service.py | 21 +++++----------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index 834df6e..6e607d4 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -38,10 +38,10 @@ class GithubUpdateRunner(QThread): progress_changed = Signal(float, str) update_finished = Signal(bool, str) - def __init__(self, service: GithubUpdateService): + def __init__(self, service: GithubUpdateService, target_version: str): QThread.__init__(self) self.service = service - self.target_version = '' + self.target_version = target_version def run(self) -> None: success, message = self.service.download_and_restart(self.target_version, self._on_progress) @@ -104,9 +104,7 @@ def __init__(self, ctx: ScriptChainerContext, parent=None): self.version_checker = GithubUpdateChecker(ctx) self.version_checker.check_finished.connect(self._on_version_check_finished) - self.update_runner = GithubUpdateRunner(ctx.github_update_service) - self.update_runner.progress_changed.connect(self._on_update_progress) - self.update_runner.update_finished.connect(self._on_update_finished) + self.update_runner: GithubUpdateRunner | None = None MultiPushSettingCard.__init__( self, @@ -119,7 +117,7 @@ def __init__(self, ctx: ScriptChainerContext, parent=None): self.check_and_update_display() def check_and_update_display(self) -> None: - if self.version_checker.isRunning() or self.update_runner.isRunning(): + if self.version_checker.isRunning() or self._is_update_running(): return self.setContent(gt('正在检查 GitHub 最新版本...')) @@ -154,7 +152,7 @@ def _on_version_check_finished( self._update_display_by_channel() def _on_channel_changed(self, _index: int) -> None: - if self.version_checker.isRunning() or self.update_runner.isRunning(): + if self.version_checker.isRunning() or self._is_update_running(): return self._update_display_by_channel() @@ -190,7 +188,7 @@ def _update_display_by_channel(self) -> None: self.update_btn.setEnabled(True) def _on_update_clicked(self) -> None: - if self.update_runner.isRunning(): + if self._is_update_running(): return self.channel_combo.setDisabled(True) @@ -198,7 +196,9 @@ def _on_update_clicked(self) -> None: self.update_btn.setDisabled(True) self.update_btn.setText(gt('更新中')) self.setContent(gt('正在准备 GitHub 更新...')) - self.update_runner.target_version = self.target_version + self.update_runner = GithubUpdateRunner(self.ctx.github_update_service, self.target_version) + self.update_runner.progress_changed.connect(self._on_update_progress) + self.update_runner.update_finished.connect(self._on_update_finished) self.update_runner.start() def _on_update_progress(self, progress: float, message: str) -> None: @@ -216,8 +216,11 @@ def _on_update_finished(self, success: bool, message: str) -> None: self.channel_combo.setEnabled(True) self.check_btn.setEnabled(True) - self.update_btn.setText(gt('更新')) - self.update_btn.setEnabled(bool(self.target_version) and os_utils.run_in_exe()) + self.update_btn.setText(gt('重试')) + self.update_btn.setEnabled(True) + + def _is_update_running(self) -> bool: + return self.update_runner is not None and self.update_runner.isRunning() def _current_version(self) -> str: from one_dragon.version import __version__ diff --git a/src/script_chainer/services/github_update_service.py b/src/script_chainer/services/github_update_service.py index 3a09d12..8ec92da 100644 --- a/src/script_chainer/services/github_update_service.py +++ b/src/script_chainer/services/github_update_service.py @@ -86,23 +86,14 @@ def get_latest_tags(self) -> tuple[str, str]: return stable_tag, beta_tag def get_latest_tag(self) -> str: - latest_url = f'{self.ctx.project_config.github_homepage}/releases/latest' - request_url = self._with_gh_proxy(latest_url) - try: - with self._open_request(request_url, method='HEAD') as response: - final_url = response.geturl() - except Exception: - with self._open_request(request_url) as response: - final_url = response.geturl() - - marker = '/releases/tag/' - if marker not in final_url: - raise RuntimeError(f'无法解析最新版本: {final_url}') + repo_path = self._get_github_repo_path() + release_url = f'https://api.github.com/repos/{repo_path}/releases/latest' + with self._open_request(release_url, headers={'User-Agent': 'OneDragon-ScriptChainer'}) as response: + release = json.loads(response.read().decode('utf-8')) - tag = final_url.rsplit(marker, 1)[1].split('?', 1)[0].split('#', 1)[0] - tag = urllib.parse.unquote(tag).strip('/') + tag = str(release.get('tag_name', '')).strip() if not tag: - raise RuntimeError(f'无法解析最新版本: {final_url}') + raise RuntimeError(f'无法解析最新版本: {release_url}') return tag def get_latest_beta_tag(self) -> str: From 788714b2d87ebbf1b6f65ae366efc1bd1505aad3 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 2 May 2026 13:50:47 +0800 Subject: [PATCH 12/23] =?UTF-8?q?fix:=20=E8=8E=B7=E5=8F=96=E4=B8=8D?= =?UTF-8?q?=E5=88=B0=E5=9B=BE=E5=83=8F=E7=9A=84=E6=97=B6=E5=80=99=E4=B8=8D?= =?UTF-8?q?=E5=88=9B=E5=BB=BA=E5=9B=BE=E5=83=8F=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon_qt/view/like_interface.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/one_dragon_qt/view/like_interface.py b/src/one_dragon_qt/view/like_interface.py index 45085f7..7f9d17f 100644 --- a/src/one_dragon_qt/view/like_interface.py +++ b/src/one_dragon_qt/view/like_interface.py @@ -36,13 +36,14 @@ def get_content_widget(self) -> QWidget: url='https://one-dragon.com/other/zh/like/like.html') content.add_widget(cafe_opt) - img_label = ImageLabel() img = cv2_utils.read_image(os_utils.get_resource_path('assets', 'ui', 'sponsor_wechat.png')) - image = Cv2Image(img) - img_label.setImage(image) - img_label.setFixedWidth(250) - img_label.setFixedHeight(250) - content.add_widget(img_label) + if img is not None: + img_label = ImageLabel() + image = Cv2Image(img) + img_label.setImage(image) + img_label.setFixedWidth(250) + img_label.setFixedHeight(250) + content.add_widget(img_label) content.add_stretch(1) return content From 84dd56b139c937f4681d28ef56897540728b9557 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 2 May 2026 15:28:22 +0800 Subject: [PATCH 13/23] =?UTF-8?q?fix:=20yaml=E5=A4=B1=E6=95=88=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/base/config/yaml_config.py | 1 + src/one_dragon/base/config/yaml_operator.py | 9 +++++++++ 2 files changed, 10 insertions(+) diff --git a/src/one_dragon/base/config/yaml_config.py b/src/one_dragon/base/config/yaml_config.py index cc9622e..0f94bef 100644 --- a/src/one_dragon/base/config/yaml_config.py +++ b/src/one_dragon/base/config/yaml_config.py @@ -81,6 +81,7 @@ def _get_yaml_file_path(self) -> str | None: if self._sample and os.path.exists(sample_yml_path): if self._copy_from_sample: shutil.copyfile(sample_yml_path, yml_path) + return yml_path return sample_yml_path # 冻结环境回退到 MEIPASS/resources diff --git a/src/one_dragon/base/config/yaml_operator.py b/src/one_dragon/base/config/yaml_operator.py index fc05195..886b004 100644 --- a/src/one_dragon/base/config/yaml_operator.py +++ b/src/one_dragon/base/config/yaml_operator.py @@ -32,6 +32,12 @@ def read_cache_or_load(file_path: str) -> dict: return copy.deepcopy(data) +def invalidate_cache(file_path: str | None) -> None: + if file_path is None: + return + cached_yaml_data.pop(file_path, None) + + class YamlOperator: def __init__(self, file_path: str | None = None): @@ -73,6 +79,7 @@ def save(self): with open(self.file_path, 'w', encoding='utf-8') as file: yaml.dump(self.data, file, allow_unicode=True, sort_keys=False) + invalidate_cache(self.file_path) def save_diy(self, text: str): """ @@ -85,6 +92,7 @@ def save_diy(self, text: str): with open(self.file_path, "w", encoding="utf-8") as file: file.write(text) + invalidate_cache(self.file_path) def get(self, prop: str, value=None): return self.data.get(prop, value) @@ -107,6 +115,7 @@ def delete(self): return if os.path.exists(self.file_path): os.remove(self.file_path) + invalidate_cache(self.file_path) @property def is_file_exists(self) -> bool: From fe4d5148812da598d5c13100911f6cbbbc8c568b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 00:27:48 +0800 Subject: [PATCH 14/23] =?UTF-8?q?chore:=20=E5=88=A0=E9=99=A4=E4=B8=80?= =?UTF-8?q?=E4=B8=AA=E4=BB=A4=E4=BA=BA=E8=AF=AF=E8=A7=A3=E7=9A=84=E6=8F=90?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/gui/page/editor_setting_interface.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index 6e607d4..86d0a8d 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -278,7 +278,6 @@ def get_update_group(self) -> SettingCardGroup: self.proxy_type_opt = ComboBoxSettingCard( icon=FluentIcon.GLOBE, title='网络代理', - content='免费代理仅能加速 GitHub 更新,无法加速个人代理之外的请求', options_enum=ProxyTypeEnum, ) self.proxy_type_opt.value_changed.connect(self._on_proxy_type_changed) @@ -294,7 +293,7 @@ def get_update_group(self) -> SettingCardGroup: self.gh_proxy_url_opt = TextSettingCard( icon=FluentIcon.GLOBE, - title='免费代理', + title='GitHub 代理', ) group.addSettingCard(self.gh_proxy_url_opt) From 952f5d281227c09eeee5936fea806cad8e2403a9 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 00:55:01 +0800 Subject: [PATCH 15/23] =?UTF-8?q?=E5=90=8C=E6=AD=A5=E6=A1=86=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/base/web/__init__.py | 0 src/one_dragon/base/web/common_downloader.py | 103 +++++++++++++++++++ src/one_dragon/base/web/zip_downloader.py | 98 ++++++++++++++++++ src/one_dragon/envs/ghproxy_service.py | 14 ++- src/one_dragon/utils/http_utils.py | 31 ++++-- 5 files changed, 234 insertions(+), 12 deletions(-) create mode 100644 src/one_dragon/base/web/__init__.py create mode 100644 src/one_dragon/base/web/common_downloader.py create mode 100644 src/one_dragon/base/web/zip_downloader.py diff --git a/src/one_dragon/base/web/__init__.py b/src/one_dragon/base/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/one_dragon/base/web/common_downloader.py b/src/one_dragon/base/web/common_downloader.py new file mode 100644 index 0000000..a837fca --- /dev/null +++ b/src/one_dragon/base/web/common_downloader.py @@ -0,0 +1,103 @@ +import os +from collections.abc import Callable + +from one_dragon.utils import http_utils +from one_dragon.utils.log_utils import log + + +class CommonDownloaderParam: + + def __init__( + self, + save_file_path: str, + save_file_name: str, + github_release_download_url: str | None = None, + gitee_release_download_url: str | None = None, + mirror_chan_download_url: str | None = None, + check_existed_list: list[str] | None = None, + unzip_dir_path: str | None = None, + ): + """ + 一个通用下载器 可提供3个下载源 并检查文件是否存在 如果存在则不进行下载 + + Args: + save_file_path (str): 文件保存的路径 + save_file_name (str): 文件保存的名称 + github_release_download_url (Optional[str], optional): Github Release下载地址. Defaults to None. + gitee_release_download_url (Optional[str], optional): Gitee Release下载地址. Defaults to None. + mirror_chan_download_url (Optional[str], optional): Mirror酱下载地址. Defaults to None. + check_existed_list (Optional[list[str]], optional): 需要检查文件是否存在的列表 完整路径的列表. Defaults to None. + unzip_dir_path (Optional[str], optional): 解压目录路径,如果为None则解压到save_file_path. Defaults to None. + """ + self.save_file_path: str = save_file_path + self.save_file_name: str = save_file_name + self.github_release_download_url: str | None = github_release_download_url + self.gitee_release_download_url: str | None = gitee_release_download_url + self.mirror_chan_download_url: str | None = mirror_chan_download_url + self.check_existed_list: list[str] = [] if check_existed_list is None else check_existed_list + self.unzip_dir_path: str | None = unzip_dir_path + + +class CommonDownloader: + + def __init__( + self, + param: CommonDownloaderParam, + ) -> None: + """ + 一个通用下载器 可提供3个下载源 并检查文件是否存在 如果存在则不进行下载 + + Args: + param (CommonDownloaderParam): 下载参数 + """ + self.param: CommonDownloaderParam = param + + def download( + self, + download_by_github: bool = True, + download_by_gitee: bool = False, + download_by_mirror_chan: bool = False, + proxy_url: str | None = None, + ghproxy_url: str | None = None, + skip_if_existed: bool = True, + progress_signal: dict[str, str | None] | None = None, + progress_callback: Callable[[float, str], None] | None = None + ) -> bool: + if skip_if_existed and self.is_file_existed(): + return True + + download_url: str = '' + if download_by_github and self.param.github_release_download_url is not None: + if ghproxy_url is not None: + download_url=f'{ghproxy_url}/{self.param.github_release_download_url}' + else: + download_url = self.param.github_release_download_url + elif download_by_gitee and self.param.gitee_release_download_url is not None: + download_url = self.param.gitee_release_download_url + elif download_by_mirror_chan and self.param.mirror_chan_download_url is not None: + download_url = self.param.mirror_chan_download_url + + if download_url == '': + log.error('没有指定下载方法或对应的下载地址') + return False + + return http_utils.download_file( + download_url=download_url, + save_file_path=os.path.join(self.param.save_file_path, self.param.save_file_name), + proxy=proxy_url, + progress_signal=progress_signal, + progress_callback=progress_callback) + + def is_file_existed(self) -> bool: + """ + 判断所需文件是否都已经存在了 + + Returns: + bool: 是否都存在 + """ + all_existed: bool = True + for file_name in self.param.check_existed_list: + if not os.path.exists(file_name): + all_existed = False + break + return all_existed diff --git a/src/one_dragon/base/web/zip_downloader.py b/src/one_dragon/base/web/zip_downloader.py new file mode 100644 index 0000000..70bc7ec --- /dev/null +++ b/src/one_dragon/base/web/zip_downloader.py @@ -0,0 +1,98 @@ +import os +from collections.abc import Callable + +from one_dragon.base.web.common_downloader import ( + CommonDownloader, + CommonDownloaderParam, +) +from one_dragon.utils import file_utils +from one_dragon.utils.log_utils import log + + +class ZipDownloader(CommonDownloader): + + def __init__( + self, + param: CommonDownloaderParam, + ) -> None: + """ + 一个Zip的通用下载器 可提供3个下载源 并检查文件是否存在 如果存在则不进行下载 下载后进行文件解压 + + Args: + param (CommonDownloaderParam): 下载参数 + """ + CommonDownloader.__init__( + self, + param=param, + ) + + def download( + self, + download_by_github: bool = True, + download_by_gitee: bool = False, + download_by_mirror_chan: bool = False, + proxy_url: str | None = None, + ghproxy_url: str | None = None, + skip_if_existed: bool = True, + progress_signal: dict[str, str | None] | None = None, + progress_callback: Callable[[float, str], None] | None = None + ) -> bool: + for i in range(2): + download_result = CommonDownloader.download( + self, + download_by_github=download_by_github, + download_by_gitee=download_by_gitee, + download_by_mirror_chan=download_by_mirror_chan, + proxy_url=proxy_url, + ghproxy_url=ghproxy_url, + skip_if_existed=skip_if_existed if i == 0 else False, # 第2次重试时必定重新下载 + progress_signal=progress_signal, + progress_callback=progress_callback, + ) + + if not download_result: + return download_result + + unzip_result = self.unzip() + if unzip_result: + break + else: # 可能压缩包下载不完整 解压不成功 重新下载 + log.warning('疑似压缩包损毁 重新下载') + continue + + # 解压有可能失败 最后再判断一次文件是否已经存在了 + return self.is_file_existed() + + def unzip(self) -> bool: + """ + 对目标压缩包进行解压 + """ + # 文件已存在则不解压 + exists = CommonDownloader.is_file_existed(self) + if exists: + return True + + zip_file_path = os.path.join(self.param.save_file_path, self.param.save_file_name) + if not os.path.exists(zip_file_path): + return False + + # 使用指定的解压路径,如果没有指定则使用save_file_path + unzip_dir = self.param.unzip_dir_path or self.param.save_file_path + os.makedirs(unzip_dir, exist_ok=True) + file_utils.unzip_file(zip_file_path=zip_file_path, unzip_dir_path=unzip_dir) + log.info(f"解压完成 {zip_file_path} 到 {unzip_dir}") + + # 最后判断压缩包以外的文件是否完整了 完整了才说明解压成功 + return CommonDownloader.is_file_existed(self) + + def is_file_existed(self) -> bool: + """ + 检查文件是否存在 + 额外判断压缩包是否已经存在了 + """ + exists = CommonDownloader.is_file_existed(self) + if exists: + return True + + zip_file_path = os.path.join(self.param.save_file_path, self.param.save_file_name) + return os.path.exists(zip_file_path) diff --git a/src/one_dragon/envs/ghproxy_service.py b/src/one_dragon/envs/ghproxy_service.py index 0b4f88d..d5ec72f 100644 --- a/src/one_dragon/envs/ghproxy_service.py +++ b/src/one_dragon/envs/ghproxy_service.py @@ -1,5 +1,6 @@ import re -import urllib.request + +import requests from one_dragon.envs.env_config import EnvConfig from one_dragon.utils.log_utils import log @@ -16,9 +17,14 @@ def update_proxy_url(self) -> None: :return: """ url = 'https://ghproxy.link/js/src_views_home_HomeView_vue.js' # 打开 https://ghproxy.link/ 后找到的js文件 + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36', + 'Referer': 'https://ghproxy.link/' + } try: - with urllib.request.urlopen(url, timeout=10) as response: - js_content: str = response.read().decode('utf-8') + response = requests.get(url, headers=headers, timeout=10) + response.raise_for_status() + js_content = response.text except Exception: log.error('自动获取免费代理地址失败', exc_info=True) return @@ -59,4 +65,4 @@ def __debug(): if __name__ == '__main__': - __debug() \ No newline at end of file + __debug() diff --git a/src/one_dragon/utils/http_utils.py b/src/one_dragon/utils/http_utils.py index 639c62f..51c37d7 100644 --- a/src/one_dragon/utils/http_utils.py +++ b/src/one_dragon/utils/http_utils.py @@ -1,18 +1,20 @@ import time import urllib.request -from typing import Optional, Callable +from collections.abc import Callable +from one_dragon.utils.i18_utils import gt from one_dragon.utils.log_utils import log def download_file(download_url: str, save_file_path: str, - proxy: Optional[str] = None, - progress_callback: Optional[Callable[[float, str], None]] = None) -> bool: + proxy: str | None = None, progress_signal: dict[str, str | None] | None = None, + progress_callback: Callable[[float, str], None] | None = None) -> bool: """ 下载文件 :param download_url: 下载的url :param save_file_path: 保存的文件路径,包含文件名 :param proxy: 使用的代理地址 + :param progress_signal: 进度信号字典,当字典中 'signal' 键的值为 'cancel' 时会取消下载 :param progress_callback: 下载进度的回调,进度发生改变时,通过该方法通知调用方。 :return: 是否下载成功 """ @@ -26,6 +28,10 @@ def download_file(download_url: str, save_file_path: str, def log_download_progress(block_num, block_size, total_size): nonlocal last_log_time + # 检查是否需要取消下载 + if progress_signal is not None and progress_signal.get('signal') == 'cancel': + raise DownloadCancelledError("下载已取消") + now = time.time() if now - last_log_time < 1: return @@ -33,25 +39,34 @@ def log_download_progress(block_num, block_size, total_size): downloaded = block_num * block_size / 1024.0 / 1024.0 total_size_mb = total_size / 1024.0 / 1024.0 progress = downloaded / total_size_mb - msg = f"正在下载 {downloaded:.2f}/{total_size_mb:.2f} MB ({progress * 100:.2f}%)" + msg = f"{gt('正在下载')} {downloaded:.2f}/{total_size_mb:.2f} MB ({progress * 100:.2f}%)" log.info(msg) if progress_callback is not None: progress_callback(progress, msg) try: - msg = f'开始下载 {download_url}' + msg = f"{gt('开始下载')} {download_url}" log.info(msg) if progress_callback is not None: progress_callback(0, msg) _, __ = urllib.request.urlretrieve(download_url, save_file_path, log_download_progress) - msg = f'下载完成 {save_file_path}' + msg = f"{gt('下载完成')} {save_file_path}" log.info(msg) if progress_callback is not None: progress_callback(1, msg) return True + except DownloadCancelledError: + msg = f"{gt('下载已取消')}" + log.info(msg) + if progress_callback is not None: + progress_callback(0, msg) + return False except Exception as e: - msg = f'下载失败 {e}' + msg = f"{gt('下载失败')} {e}" if progress_callback is not None: progress_callback(0, msg) - log.error(msg, exec=True) + log.error(msg, exc_info=True) return False + +class DownloadCancelledError(Exception): + pass From 91526f100476ea38733d0fb83af673b79cf023cb Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 01:31:57 +0800 Subject: [PATCH 16/23] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E4=BC=A0?= =?UTF-8?q?=E5=85=A5None=E7=9A=84=E6=97=B6=E5=80=99=E8=B5=B0=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/utils/http_utils.py | 55 ++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/src/one_dragon/utils/http_utils.py b/src/one_dragon/utils/http_utils.py index 51c37d7..01de465 100644 --- a/src/one_dragon/utils/http_utils.py +++ b/src/one_dragon/utils/http_utils.py @@ -1,6 +1,7 @@ import time import urllib.request from collections.abc import Callable +from pathlib import Path from one_dragon.utils.i18_utils import gt from one_dragon.utils.log_utils import log @@ -18,28 +19,32 @@ def download_file(download_url: str, save_file_path: str, :param progress_callback: 下载进度的回调,进度发生改变时,通过该方法通知调用方。 :return: 是否下载成功 """ - if proxy is not None: - proxy_handler = urllib.request.ProxyHandler( - {'http': proxy, 'https': proxy}) - opener = urllib.request.build_opener(proxy_handler) - urllib.request.install_opener(opener) + proxy_handler = ( + urllib.request.ProxyHandler({'http': proxy, 'https': proxy}) + if proxy is not None else urllib.request.ProxyHandler({}) + ) + opener = urllib.request.build_opener(proxy_handler) last_log_time = time.time() + save_path = Path(save_file_path) + save_path.parent.mkdir(parents=True, exist_ok=True) - def log_download_progress(block_num, block_size, total_size): + def log_download_progress(downloaded_bytes: int, total_size: int) -> None: nonlocal last_log_time - # 检查是否需要取消下载 - if progress_signal is not None and progress_signal.get('signal') == 'cancel': - raise DownloadCancelledError("下载已取消") - now = time.time() if now - last_log_time < 1: return last_log_time = now - downloaded = block_num * block_size / 1024.0 / 1024.0 - total_size_mb = total_size / 1024.0 / 1024.0 - progress = downloaded / total_size_mb - msg = f"{gt('正在下载')} {downloaded:.2f}/{total_size_mb:.2f} MB ({progress * 100:.2f}%)" + + downloaded_mb = downloaded_bytes / 1024.0 / 1024.0 + if total_size > 0: + total_size_mb = total_size / 1024.0 / 1024.0 + progress = downloaded_bytes / total_size + msg = f"{gt('正在下载')} {downloaded_mb:.2f}/{total_size_mb:.2f} MB ({progress * 100:.2f}%)" + else: + progress = 0 + msg = f"{gt('正在下载')} {downloaded_mb:.2f} MB" + log.info(msg) if progress_callback is not None: progress_callback(progress, msg) @@ -49,19 +54,39 @@ def log_download_progress(block_num, block_size, total_size): log.info(msg) if progress_callback is not None: progress_callback(0, msg) - _, __ = urllib.request.urlretrieve(download_url, save_file_path, log_download_progress) + + request = urllib.request.Request(download_url) + with opener.open(request, timeout=60) as response, save_path.open('wb') as file: + total_size = int(response.headers.get('Content-Length', '0') or 0) + downloaded_bytes = 0 + chunk_size = 1024 * 64 + + while True: + if progress_signal is not None and progress_signal.get('signal') == 'cancel': + raise DownloadCancelledError("下载已取消") + + chunk = response.read(chunk_size) + if not chunk: + break + + file.write(chunk) + downloaded_bytes += len(chunk) + log_download_progress(downloaded_bytes, total_size) + msg = f"{gt('下载完成')} {save_file_path}" log.info(msg) if progress_callback is not None: progress_callback(1, msg) return True except DownloadCancelledError: + save_path.unlink(missing_ok=True) msg = f"{gt('下载已取消')}" log.info(msg) if progress_callback is not None: progress_callback(0, msg) return False except Exception as e: + save_path.unlink(missing_ok=True) msg = f"{gt('下载失败')} {e}" if progress_callback is not None: progress_callback(0, msg) From 2b014b89b0c33d27e9d4f673176239d67f08f848 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 01:34:36 +0800 Subject: [PATCH 17/23] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E9=80=9A?= =?UTF-8?q?=E7=94=A8=E4=B8=8B=E8=BD=BD=E5=99=A8=E5=B9=B6=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E5=8F=96=E6=B6=88=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gui/page/editor_setting_interface.py | 35 +++++++++++++++-- .../services/github_update_service.py | 39 ++++++++++++++----- 2 files changed, 60 insertions(+), 14 deletions(-) diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index 86d0a8d..ed9834c 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -42,14 +42,22 @@ def __init__(self, service: GithubUpdateService, target_version: str): QThread.__init__(self) self.service = service self.target_version = target_version + self.progress_signal: dict[str, str | None] = {'signal': None} def run(self) -> None: - success, message = self.service.download_and_restart(self.target_version, self._on_progress) + success, message = self.service.download_and_restart( + self.target_version, + self._on_progress, + self.progress_signal, + ) self.update_finished.emit(success, message) def _on_progress(self, progress: float, message: str) -> None: self.progress_changed.emit(progress, message) + def cancel(self) -> None: + self.progress_signal['signal'] = 'cancel' + class GithubUpdateChecker(QThread): @@ -101,6 +109,10 @@ def __init__(self, ctx: ScriptChainerContext, parent=None): self.update_btn = PrimaryPushButton(text=gt('更新')) self.update_btn.clicked.connect(self._on_update_clicked) + self.cancel_btn = PushButton(gt('取消')) + self.cancel_btn.clicked.connect(self._on_cancel_clicked) + self.cancel_btn.setVisible(False) + self.version_checker = GithubUpdateChecker(ctx) self.version_checker.check_finished.connect(self._on_version_check_finished) @@ -108,7 +120,7 @@ def __init__(self, ctx: ScriptChainerContext, parent=None): MultiPushSettingCard.__init__( self, - btn_list=[self.channel_combo, self.check_btn, self.update_btn], + btn_list=[self.channel_combo, self.check_btn, self.update_btn, self.cancel_btn], icon=FluentIcon.SYNC, title='程序更新', content='检查中...', @@ -120,6 +132,8 @@ def check_and_update_display(self) -> None: if self.version_checker.isRunning() or self._is_update_running(): return + self.cancel_btn.setVisible(False) + self.cancel_btn.setEnabled(False) self.setContent(gt('正在检查 GitHub 最新版本...')) self.channel_combo.setDisabled(True) self.check_btn.setDisabled(True) @@ -195,12 +209,23 @@ def _on_update_clicked(self) -> None: self.check_btn.setDisabled(True) self.update_btn.setDisabled(True) self.update_btn.setText(gt('更新中')) + self.cancel_btn.setVisible(True) + self.cancel_btn.setEnabled(True) self.setContent(gt('正在准备 GitHub 更新...')) self.update_runner = GithubUpdateRunner(self.ctx.github_update_service, self.target_version) self.update_runner.progress_changed.connect(self._on_update_progress) self.update_runner.update_finished.connect(self._on_update_finished) self.update_runner.start() + def _on_cancel_clicked(self) -> None: + if not self._is_update_running() or self.update_runner is None: + return + + self.update_runner.cancel() + self.cancel_btn.setDisabled(True) + self.update_btn.setText(gt('取消中')) + self.setContent(gt('正在取消下载...')) + def _on_update_progress(self, progress: float, message: str) -> None: if progress > 0: self.setContent(f'{message} {progress:.0%}') @@ -208,6 +233,8 @@ def _on_update_progress(self, progress: float, message: str) -> None: self.setContent(message) def _on_update_finished(self, success: bool, message: str) -> None: + self.cancel_btn.setVisible(False) + self.cancel_btn.setEnabled(False) self.setContent(message) if success: self.update_btn.setText(gt('重启中')) @@ -216,7 +243,7 @@ def _on_update_finished(self, success: bool, message: str) -> None: self.channel_combo.setEnabled(True) self.check_btn.setEnabled(True) - self.update_btn.setText(gt('重试')) + self.update_btn.setText(gt('更新') if message == gt('下载已取消') else gt('重试')) self.update_btn.setEnabled(True) def _is_update_running(self) -> bool: @@ -277,7 +304,7 @@ def get_update_group(self) -> SettingCardGroup: self.proxy_type_opt = ComboBoxSettingCard( icon=FluentIcon.GLOBE, - title='网络代理', + title='代理类型', options_enum=ProxyTypeEnum, ) self.proxy_type_opt.value_changed.connect(self._on_proxy_type_changed) diff --git a/src/script_chainer/services/github_update_service.py b/src/script_chainer/services/github_update_service.py index 8ec92da..4e559e8 100644 --- a/src/script_chainer/services/github_update_service.py +++ b/src/script_chainer/services/github_update_service.py @@ -10,7 +10,11 @@ from pathlib import Path, PurePosixPath from typing import TYPE_CHECKING -from one_dragon.utils import http_utils, os_utils +from one_dragon.base.web.common_downloader import ( + CommonDownloader, + CommonDownloaderParam, +) +from one_dragon.utils import os_utils from one_dragon.utils.i18_utils import gt from one_dragon.utils.log_utils import log @@ -38,6 +42,7 @@ def download_and_restart( self, target_tag: str | None = None, progress_callback: ProgressCallback | None = None, + progress_signal: dict[str, str | None] | None = None, ) -> tuple[bool, str]: if not os_utils.run_in_exe(): return False, gt('当前不是发布版,无法自动更新') @@ -54,13 +59,26 @@ def download_and_restart( if progress_callback is not None: progress_callback(0, gt('正在获取 GitHub 更新版本')) latest_tag = target_tag or self.get_latest_tag() - release_zip_name = f'{RELEASE_ZIP_PREFIX}-{latest_tag}.zip' + downloader_param = self._get_downloader_param(latest_tag, update_dir) + release_zip_name = downloader_param.save_file_name zip_path = update_dir / release_zip_name - download_url = self._build_download_url(latest_tag, release_zip_name) + downloader = CommonDownloader(downloader_param) proxy = self.ctx.env_config.personal_proxy if self.ctx.env_config.is_personal_proxy else None - if not http_utils.download_file(download_url, str(zip_path), proxy, progress_callback): + ghproxy_url = self.ctx.env_config.gh_proxy_url if self.ctx.env_config.is_gh_proxy else None + if not downloader.download( + proxy_url=proxy, + ghproxy_url=ghproxy_url, + skip_if_existed=False, + progress_signal=progress_signal, + progress_callback=progress_callback, + ): + if progress_signal is not None and progress_signal.get('signal') == 'cancel': + return False, gt('下载已取消') return False, gt('下载 GitHub 更新失败') + if progress_signal is not None and progress_signal.get('signal') == 'cancel': + return False, gt('下载已取消') + if progress_callback is not None: progress_callback(1, gt('正在解压更新包')) self._extract_release(zip_path, stage_dir) @@ -138,17 +156,18 @@ def _build_opener(self): ) return urllib.request.build_opener(proxy_handler) - def _build_download_url(self, latest_tag: str, release_zip_name: str) -> str: + def _get_downloader_param(self, latest_tag: str, update_dir: Path) -> CommonDownloaderParam: + release_zip_name = f'{RELEASE_ZIP_PREFIX}-{latest_tag}.zip' download_url = ( f'{self.ctx.project_config.github_homepage}' f'/releases/download/{latest_tag}/{release_zip_name}' ) - return self._with_gh_proxy(download_url) - def _with_gh_proxy(self, url: str) -> str: - if not self.ctx.env_config.is_gh_proxy: - return url - return f'{self.ctx.env_config.gh_proxy_url.rstrip("/")}/{url}' + return CommonDownloaderParam( + save_file_path=str(update_dir), + save_file_name=release_zip_name, + github_release_download_url=download_url, + ) def _extract_release(self, zip_path: Path, stage_dir: Path) -> None: stage_root = stage_dir.resolve() From d347c06834f7cbd3ea4a5c4a5c5b8ad08ba70eb5 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 02:00:24 +0800 Subject: [PATCH 18/23] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=E8=87=AA?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/build-release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml index 430a89d..be0387c 100644 --- a/.github/workflows/build-release.yml +++ b/.github/workflows/build-release.yml @@ -313,7 +313,7 @@ jobs: ### 使用方法 1. 下载 `OneDragon-ScriptChainer-${{ needs.build.outputs.version }}.zip` - 2. 如果是更新,请先删除旧版的 `.runtime` 文件夹,再解压覆盖 + 2. 设置中可以自更新。如需手动更新,请先删除旧版的 `.runtime` 文件夹,再解压覆盖 3. 运行 `OneDragon ScriptChainer.exe` 创建和编辑脚本链 4. 运行 `OneDragon ScriptChainer Runner.exe` 执行脚本链 From 1ca4e93a69779d68358c31b7f8a6db0fc7aaf7e8 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 22:25:00 +0800 Subject: [PATCH 19/23] =?UTF-8?q?fix(one=5Fdragon):=20=E5=BC=BA=E5=8C=96?= =?UTF-8?q?=E4=BB=A3=E7=90=86=E5=88=B7=E6=96=B0=E4=B8=8E=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=A3=80=E6=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/base/web/common_downloader.py | 2 ++ src/one_dragon/base/web/zip_downloader.py | 4 ++-- src/one_dragon/envs/ghproxy_service.py | 13 +++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/one_dragon/base/web/common_downloader.py b/src/one_dragon/base/web/common_downloader.py index a837fca..32e401e 100644 --- a/src/one_dragon/base/web/common_downloader.py +++ b/src/one_dragon/base/web/common_downloader.py @@ -95,6 +95,8 @@ def is_file_existed(self) -> bool: Returns: bool: 是否都存在 """ + if not self.param.check_existed_list: + return False all_existed: bool = True for file_name in self.param.check_existed_list: if not os.path.exists(file_name): diff --git a/src/one_dragon/base/web/zip_downloader.py b/src/one_dragon/base/web/zip_downloader.py index 70bc7ec..cf2e915 100644 --- a/src/one_dragon/base/web/zip_downloader.py +++ b/src/one_dragon/base/web/zip_downloader.py @@ -60,8 +60,8 @@ def download( log.warning('疑似压缩包损毁 重新下载') continue - # 解压有可能失败 最后再判断一次文件是否已经存在了 - return self.is_file_existed() + # 解压有可能失败 最后再判断一次解压产物是否已经存在 + return CommonDownloader.is_file_existed(self) def unzip(self) -> bool: """ diff --git a/src/one_dragon/envs/ghproxy_service.py b/src/one_dragon/envs/ghproxy_service.py index d5ec72f..522f923 100644 --- a/src/one_dragon/envs/ghproxy_service.py +++ b/src/one_dragon/envs/ghproxy_service.py @@ -11,7 +11,7 @@ class GhProxyService: def __init__(self, env_config: EnvConfig): self.env_config = env_config - def update_proxy_url(self) -> None: + def update_proxy_url(self) -> bool: """ 更新免费代理的url :return: @@ -27,24 +27,24 @@ def update_proxy_url(self) -> None: js_content = response.text except Exception: log.error('自动获取免费代理地址失败', exc_info=True) - return + return False url_prefix = ' 标签 有多个时候忽略 等待后续再处理 if another_url_prefix_idx != -1: log.error('自动获取免费代理地址失败 有多个 标签') - return + return False proxy_url = js_content[url_prefix_idx + len(url_prefix):url_suffix_idx] # 判断 proxy_url 是一个 https 开头的域名 不包含任何路径 例如 https://ghfast.top @@ -53,10 +53,11 @@ def update_proxy_url(self) -> None: # 使用正则表达式匹配 if not re.match(pattern, proxy_url): log.error('自动获取免费代理地址失败 提取域名不合法 %s', proxy_url) - return + return False log.info('自动获取免费代理地址成功 %s', proxy_url) self.env_config.gh_proxy_url = proxy_url + return True def __debug(): From 749fef790150a2f409d7e512df2593cb59881dba Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 22:25:01 +0800 Subject: [PATCH 20/23] =?UTF-8?q?fix(script=5Fchainer):=20=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E9=87=8D=E5=A4=8D=E8=87=AA=E5=8A=A8=E5=88=B7=E6=96=B0?= =?UTF-8?q?=20ghproxy?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../gui/page/editor_setting_interface.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/src/script_chainer/gui/page/editor_setting_interface.py b/src/script_chainer/gui/page/editor_setting_interface.py index ed9834c..66f98d2 100644 --- a/src/script_chainer/gui/page/editor_setting_interface.py +++ b/src/script_chainer/gui/page/editor_setting_interface.py @@ -17,6 +17,7 @@ from one_dragon.envs.env_config import ProxyTypeEnum from one_dragon.utils import os_utils from one_dragon.utils.i18_utils import gt +from one_dragon.utils.log_utils import log from one_dragon_qt.services.theme_manager import ThemeManager from one_dragon_qt.widgets.column import Column from one_dragon_qt.widgets.setting_card.combo_box_setting_card import ( @@ -72,20 +73,24 @@ def run(self) -> None: latest_stable, latest_beta = self.ctx.github_update_service.get_latest_tags() self.check_finished.emit(True, latest_stable, latest_beta, '') except Exception as e: + log.error('检查 GitHub 更新失败', exc_info=True) self.check_finished.emit(False, '', '', str(e)) class GhProxyUpdateRunner(QThread): - update_finished = Signal(str) + update_finished = Signal(bool, str) def __init__(self, ctx: ScriptChainerContext): QThread.__init__(self) self.ctx = ctx def run(self) -> None: - self.ctx.gh_proxy_service.update_proxy_url() - self.update_finished.emit(self.ctx.env_config.gh_proxy_url) + success = False + try: + success = self.ctx.gh_proxy_service.update_proxy_url() + finally: + self.update_finished.emit(success, self.ctx.env_config.gh_proxy_url) class GithubUpdateCard(MultiPushSettingCard): @@ -259,6 +264,7 @@ class EditorSettingInterface(VerticalScrollInterface): def __init__(self, ctx: ScriptChainerContext, parent=None): self.ctx: ScriptChainerContext = ctx + self._gh_proxy_auto_refreshed = False self.gh_proxy_update_runner = GhProxyUpdateRunner(ctx) self.gh_proxy_update_runner.update_finished.connect(self._on_gh_proxy_update_finished) @@ -396,6 +402,8 @@ def on_fetch_gh_proxy_url_clicked(self) -> None: self.start_gh_proxy_url_refresh() def refresh_gh_proxy_url_by_config(self) -> None: + if self._gh_proxy_auto_refreshed: + return if not self.ctx.env_config.auto_fetch_gh_proxy_url: return if self.ctx.env_config.proxy_type != ProxyTypeEnum.GHPROXY.value.value: @@ -408,8 +416,9 @@ def start_gh_proxy_url_refresh(self) -> None: self.fetch_gh_proxy_url_btn.setDisabled(True) self.gh_proxy_update_runner.start() - def _on_gh_proxy_update_finished(self, _proxy_url: str) -> None: - self.gh_proxy_url_opt.setValue(_proxy_url, emit_signal=False) + def _on_gh_proxy_update_finished(self, success: bool, proxy_url: str) -> None: + self._gh_proxy_auto_refreshed = success + self.gh_proxy_url_opt.setValue(proxy_url, emit_signal=False) self.fetch_gh_proxy_url_btn.setEnabled(True) def update_proxy_ui(self) -> None: From f6d7d42fea20e45859e08d32855d1e40ca4f4af9 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Wed, 6 May 2026 22:25:01 +0800 Subject: [PATCH 21/23] =?UTF-8?q?fix(one=5Fdragon):=20=E4=BF=9D=E6=8C=81?= =?UTF-8?q?=20onedir=20=E7=9A=84=20YAML=20=E5=85=BC=E5=AE=B9=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/base/config/yaml_config.py | 32 +++++--- src/one_dragon/base/config/yaml_operator.py | 85 +++++++++++++++------ 2 files changed, 83 insertions(+), 34 deletions(-) diff --git a/src/one_dragon/base/config/yaml_config.py b/src/one_dragon/base/config/yaml_config.py index 0f94bef..4a3fb1f 100644 --- a/src/one_dragon/base/config/yaml_config.py +++ b/src/one_dragon/base/config/yaml_config.py @@ -41,16 +41,18 @@ def __init__( self._read_sample_only: bool = read_sample_only """是否只读取sample文件(即使.yml文件存在也只读sample)""" - YamlOperator.__init__(self, self._get_yaml_file_path()) + file_path, write_file_path, copy_on_write_source_path = self._get_yaml_file_paths() + YamlOperator.__init__(self, file_path) + self._write_file_path = write_file_path + self._copy_on_write_source_path = copy_on_write_source_path - def _get_yaml_file_path(self) -> str | None: + def _get_yaml_file_paths(self) -> tuple[str | None, str | None, str | None]: """ - 获取配置文件的路径 - 如果只有sample文件,就复制一个到实例文件夹下 + 获取配置文件的读取路径、写入路径 :return: """ if self.is_mock: - return None + return None, None, None sub_dir = ['config'] if self.instance_idx is not None: sub_dir.append('%02d' % self.instance_idx) @@ -64,32 +66,38 @@ def _get_yaml_file_path(self) -> str | None: # 只读sample文件模式 if self._read_sample_only and os.path.exists(sample_yml_path): - return sample_yml_path + return sample_yml_path, sample_yml_path, None # 指定文件存在时 直接使用 if os.path.exists(yml_path): - return yml_path + return yml_path, yml_path, None # 备用文件存在时 复制使用 if self.backup_module_name is not None: backup_yml_path = os.path.join(dir_path, f'{self.backup_module_name}.yml') if os.path.exists(backup_yml_path): shutil.copyfile(backup_yml_path, yml_path) - return yml_path + return yml_path, yml_path, None # 最后看是否有示例文件 if self._sample and os.path.exists(sample_yml_path): if self._copy_from_sample: shutil.copyfile(sample_yml_path, yml_path) - return yml_path - return sample_yml_path + return yml_path, yml_path, None + return sample_yml_path, yml_path, sample_yml_path # 冻结环境回退到 MEIPASS/resources frozen_path = os_utils.get_resource_path(*sub_dir, f'{self.module_name}.yml') if os.path.exists(frozen_path): - return frozen_path + return frozen_path, yml_path, frozen_path + + return yml_path, yml_path, None - return yml_path + def _get_yaml_file_path(self) -> str | None: + file_path, write_file_path, copy_on_write_source_path = self._get_yaml_file_paths() + self._write_file_path = write_file_path + self._copy_on_write_source_path = copy_on_write_source_path + return file_path @property def is_sample(self) -> bool: diff --git a/src/one_dragon/base/config/yaml_operator.py b/src/one_dragon/base/config/yaml_operator.py index 886b004..45fc0b5 100644 --- a/src/one_dragon/base/config/yaml_operator.py +++ b/src/one_dragon/base/config/yaml_operator.py @@ -1,33 +1,28 @@ import copy import os +import shutil import yaml from one_dragon.utils import yaml_utils from one_dragon.utils.log_utils import log -cached_yaml_data: dict[str, tuple[float, dict]] = {} +cached_yaml_data: dict[str, tuple[float, dict | list]] = {} -def _validate_yaml_data(file_path: str, data) -> dict: - if data is None: - return {} - if not isinstance(data, dict): - raise TypeError( - f"YAML root must be a dict in {file_path}, got {type(data).__name__}" - ) - return data - - -def read_cache_or_load(file_path: str) -> dict: +def read_cache_or_load(file_path: str) -> dict | list: cached = cached_yaml_data.get(file_path) last_modify = os.path.getmtime(file_path) if cached is not None and cached[0] == last_modify: return copy.deepcopy(cached[1]) - with open(file_path, encoding='utf-8') as file: + with open(file_path, encoding="utf-8") as file: log.debug(f"加载yaml: {file_path}") - data = _validate_yaml_data(file_path, yaml_utils.safe_load(file)) + data = yaml_utils.safe_load(file) + if data is None: + data = {} + if not isinstance(data, dict | list): + raise TypeError(f"YAML root must be a dict or list: {file_path}") cached_yaml_data[file_path] = (last_modify, data) return copy.deepcopy(data) @@ -49,7 +44,13 @@ def __init__(self, file_path: str | None = None): self.file_path: str | None = file_path """yml文件的路径""" - self.data: dict = {} + self._write_file_path: str | None = file_path + """实际写入路径 兼容 onedir 下读写路径分离""" + + self._copy_on_write_source_path: str | None = None + """首次写入前需要复制到写入路径的来源文件""" + + self.data: dict | list = {} """存放数据的地方""" self.__read_from_file() @@ -73,13 +74,42 @@ def __read_from_file(self) -> None: if self.data is None: self.data = {} + def _ensure_write_path_ready(self) -> bool: + write_path = self._get_write_path() + if write_path is None: + return False + + parent_dir = os.path.dirname(write_path) + if parent_dir: + os.makedirs(parent_dir, exist_ok=True) + + if self._copy_on_write_source_path is not None and not os.path.exists(write_path): + shutil.copyfile(self._copy_on_write_source_path, write_path) + + self._copy_on_write_source_path = None + return True + + def _get_write_path(self) -> str | None: + if self._copy_on_write_source_path is None: + return self.file_path if self.file_path is not None else self._write_file_path + return self._write_file_path if self._write_file_path is not None else self.file_path + def save(self): - if self.file_path is None: + if not self._ensure_write_path_ready(): + return + + write_path = self._get_write_path() + if write_path is None: return - with open(self.file_path, 'w', encoding='utf-8') as file: + with open(write_path, 'w', encoding='utf-8') as file: yaml.dump(self.data, file, allow_unicode=True, sort_keys=False) - invalidate_cache(self.file_path) + invalidate_cache(write_path) + + if self.file_path != write_path: + self.file_path = write_path + if hasattr(self, 'old_file_path'): + self.old_file_path = write_path def save_diy(self, text: str): """ @@ -87,18 +117,29 @@ def save_diy(self, text: str): :param text: 自定义的文本 :return: """ - if self.file_path is None: + if not self._ensure_write_path_ready(): return - with open(self.file_path, "w", encoding="utf-8") as file: + write_path = self._get_write_path() + if write_path is None: + return + + with open(write_path, "w", encoding="utf-8") as file: file.write(text) - invalidate_cache(self.file_path) + invalidate_cache(write_path) + + if self.file_path != write_path: + self.file_path = write_path + if hasattr(self, 'old_file_path'): + self.old_file_path = write_path def get(self, prop: str, value=None): + if not isinstance(self.data, dict): + return value return self.data.get(prop, value) def update(self, key: str, value, save: bool = True): - if self.data is None: + if not isinstance(self.data, dict): self.data = {} if key in self.data and not isinstance(value, list) and self.data[key] == value: return From 3117b83fc5b2409d8c3335a5b4919f2cc783a920 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Tue, 12 May 2026 04:25:20 +0800 Subject: [PATCH 22/23] =?UTF-8?q?feat(one=5Fdragon):=20=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=A1=86=E6=9E=B6=E5=8F=98=E6=9B=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/project.yml | 1 - src/one_dragon/envs/project_config.py | 9 +++++++-- .../view/setting/setting_instance_interface.py | 3 ++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/config/project.yml b/config/project.yml index d9df2b3..7899041 100644 --- a/config/project.yml +++ b/config/project.yml @@ -6,7 +6,6 @@ github_ssh_repository: "git@github.com:OneDragon-Anything/OneDragon-ScriptChaine gitee_https_repository: "https://gitee.com/OneDragon-Anything/OneDragon-ScriptChainer.git" gitee_ssh_repository: "git@gitee.com:OneDragon-Anything/OneDragon-ScriptChainer.git" project_git_branch: "main" -requirements: "requirements-prod.txt" screen_standard_width: 1920 screen_standard_height: 1080 pip_source: "https://pypi.tuna.tsinghua.edu.cn/simple" diff --git a/src/one_dragon/envs/project_config.py b/src/one_dragon/envs/project_config.py index 6059715..275d0f1 100644 --- a/src/one_dragon/envs/project_config.py +++ b/src/one_dragon/envs/project_config.py @@ -4,7 +4,7 @@ class ProjectConfig(YamlConfig): def __init__(self): - super().__init__(module_name='project') + YamlConfig.__init__(self, module_name='project') self.project_name = self.get('project_name') self.python_version = self.get('python_version') @@ -14,9 +14,14 @@ def __init__(self): self.gitee_https_repository = self.get('gitee_https_repository') self.gitee_ssh_repository = self.get('gitee_ssh_repository') self.project_git_branch = self.get('project_git_branch') - self.requirements = self.get('requirements') + self.env_archive_name = f'{self.project_name}-Environment.zip' + self.game_executable_name = self.get('game_executable_name', '') self.screen_standard_width = int(self.get('screen_standard_width')) self.screen_standard_height = int(self.get('screen_standard_height')) + self.notice_url = self.get('notice_url') self.qq_link = self.get('qq_link') + self.quick_start_link = self.get('quick_start_link') # 链接 - 快速开始 + self.home_page_link = self.get('home_page_link') # 链接 - 主页 + self.doc_link = self.get('doc_link') # 链接 - 文档 diff --git a/src/one_dragon_qt/view/setting/setting_instance_interface.py b/src/one_dragon_qt/view/setting/setting_instance_interface.py index c42e8c9..a95cd4b 100644 --- a/src/one_dragon_qt/view/setting/setting_instance_interface.py +++ b/src/one_dragon_qt/view/setting/setting_instance_interface.py @@ -374,8 +374,9 @@ def _on_instance_delete(self, idx: int) -> None: self._init_content_widget() def _on_game_path_clicked(self) -> None: + executable_name = self.ctx.project_config.game_executable_name or 'game.exe' file_path, _ = QFileDialog.getOpenFileName( - self, f"{gt('选择你的')} ZenlessZoneZero.exe", filter="Exe (*.exe)" + self, f"{gt('选择你的')} {executable_name}", filter="Exe (*.exe)" ) if file_path is not None and file_path.endswith(".exe"): log.info(f"{gt('选择路径')} {file_path}") From 8030acabc18c6818cc381b67d9fe69dc61dffe9a Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 16:02:03 +0800 Subject: [PATCH 23/23] =?UTF-8?q?feat(download):=20=E9=93=BE=E6=8E=A5?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=92=8C=E4=B8=8B=E8=BD=BD=E5=8E=9F=E5=AD=90?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/utils/http_utils.py | 48 ++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/one_dragon/utils/http_utils.py b/src/one_dragon/utils/http_utils.py index 01de465..94bb222 100644 --- a/src/one_dragon/utils/http_utils.py +++ b/src/one_dragon/utils/http_utils.py @@ -1,4 +1,6 @@ +import tempfile import time +import urllib.parse import urllib.request from collections.abc import Callable from pathlib import Path @@ -28,6 +30,7 @@ def download_file(download_url: str, save_file_path: str, last_log_time = time.time() save_path = Path(save_file_path) save_path.parent.mkdir(parents=True, exist_ok=True) + temp_path: Path | None = None def log_download_progress(downloaded_bytes: int, total_size: int) -> None: nonlocal last_log_time @@ -55,23 +58,37 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: if progress_callback is not None: progress_callback(0, msg) + url = urllib.parse.urlparse(download_url) + if url.scheme not in ('http', 'https'): + raise ValueError(f"不支持的下载协议:{download_url}") + request = urllib.request.Request(download_url) - with opener.open(request, timeout=60) as response, save_path.open('wb') as file: + with opener.open(request, timeout=60) as response: total_size = int(response.headers.get('Content-Length', '0') or 0) downloaded_bytes = 0 chunk_size = 1024 * 64 - while True: - if progress_signal is not None and progress_signal.get('signal') == 'cancel': - raise DownloadCancelledError("下载已取消") + with tempfile.NamedTemporaryFile('wb', dir=save_path.parent, delete=False) as file: + temp_path = Path(file.name) + while True: + if progress_signal is not None and progress_signal.get('signal') == 'cancel': + raise DownloadCancelledError("下载已取消") + + chunk = response.read(chunk_size) + if not chunk: + break - chunk = response.read(chunk_size) - if not chunk: - break + file.write(chunk) + downloaded_bytes += len(chunk) + log_download_progress(downloaded_bytes, total_size) - file.write(chunk) - downloaded_bytes += len(chunk) - log_download_progress(downloaded_bytes, total_size) + if total_size > 0 and downloaded_bytes != total_size: + raise DownloadIncompleteError( + f"下载不完整:{downloaded_bytes}/{total_size} bytes" + ) + + temp_path.replace(save_path) + temp_path = None msg = f"{gt('下载完成')} {save_file_path}" log.info(msg) @@ -79,19 +96,26 @@ def log_download_progress(downloaded_bytes: int, total_size: int) -> None: progress_callback(1, msg) return True except DownloadCancelledError: - save_path.unlink(missing_ok=True) + if temp_path is not None: + temp_path.unlink(missing_ok=True) msg = f"{gt('下载已取消')}" log.info(msg) if progress_callback is not None: progress_callback(0, msg) return False except Exception as e: - save_path.unlink(missing_ok=True) + if temp_path is not None: + temp_path.unlink(missing_ok=True) msg = f"{gt('下载失败')} {e}" if progress_callback is not None: progress_callback(0, msg) log.error(msg, exc_info=True) return False + class DownloadCancelledError(Exception): pass + + +class DownloadIncompleteError(Exception): + pass