From 4be74bb995ef820d23afc30a7b964406415afa1b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Mon, 11 May 2026 18:39:31 +0800 Subject: [PATCH 01/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=8B?= =?UTF-8?q?=E9=A3=8E=E7=90=B4=E5=8D=A1=E5=9C=A8=E4=B8=80=E4=BA=9B=E5=B8=83?= =?UTF-8?q?=E5=B1=80=E6=8A=96=E5=8A=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../view/setting/setting_push_interface.py | 18 +- .../setting_card/expand_setting_card_group.py | 48 ++- ...ti_value_display_combo_box_setting_card.py | 314 ++++++++++++++++++ ...display_editable_combo_box_setting_card.py | 12 +- 4 files changed, 370 insertions(+), 22 deletions(-) create mode 100644 src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py diff --git a/src/one_dragon_qt/view/setting/setting_push_interface.py b/src/one_dragon_qt/view/setting/setting_push_interface.py index e026467..c5d8f46 100644 --- a/src/one_dragon_qt/view/setting/setting_push_interface.py +++ b/src/one_dragon_qt/view/setting/setting_push_interface.py @@ -1,7 +1,7 @@ import json from PySide6.QtWidgets import QWidget -from qfluentwidgets import FluentIcon, InfoBar, InfoBarPosition, PushButton, SettingCard +from qfluentwidgets import FluentIcon, InfoBar, InfoBarPosition, PushButton from one_dragon.base.config.config_item import ConfigItem from one_dragon.base.controller.pc_clipboard import PcClipboard @@ -35,7 +35,6 @@ ) 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.setting_card.yaml_config_adapter import YamlConfigAdapter from one_dragon_qt.widgets.vertical_scroll_interface import VerticalScrollInterface @@ -103,9 +102,12 @@ def get_content_widget(self) -> QWidget: ) self.notification_method_opt.value_changed.connect(self._update_notification_ui) - channel_group = ExpandSettingCardGroup(icon=FluentIcon.MESSAGE, title='通知方式') + channel_group = ExpandSettingCardGroup( + icon=FluentIcon.MESSAGE, + title='通知方式', + initial_expand=True, + ) channel_group.addHeaderWidget(self.notification_method_opt.combo_box) - channel_group.setExpand(True) content_widget.add_widget(channel_group) # 预创建特殊卡片(稍后按渠道分配) @@ -273,17 +275,17 @@ def _on_email_service_selected(self, text): smtp_port = config.get("port", 465) smtp_ssl = str(config.get("secure", True)).lower() if "secure" in config else "true" # 找到对应的TextSettingCard并赋值 - server_card: SettingCard = getattr(self, "smtp_server_push_card", None) + server_card = getattr(self, "smtp_server_push_card", None) if server_card is not None: host = f"{smtp_server}:{smtp_port}" server_card.setValue(host) - adapter: YamlConfigAdapter = getattr(server_card, "adapter", None) + adapter = getattr(server_card, "adapter", None) if adapter is not None: adapter.set_value(host) - ssl_card: SettingCard = getattr(self, "smtp_ssl_push_card", None) + ssl_card = getattr(self, "smtp_ssl_push_card", None) if ssl_card is not None: ssl_card.setValue(smtp_ssl) - adapter: YamlConfigAdapter = getattr(ssl_card, "adapter", None) + adapter = getattr(ssl_card, "adapter", None) if adapter is not None: adapter.set_value(smtp_ssl) diff --git a/src/one_dragon_qt/widgets/setting_card/expand_setting_card_group.py b/src/one_dragon_qt/widgets/setting_card/expand_setting_card_group.py index 3a3f384..d580acd 100644 --- a/src/one_dragon_qt/widgets/setting_card/expand_setting_card_group.py +++ b/src/one_dragon_qt/widgets/setting_card/expand_setting_card_group.py @@ -1,7 +1,9 @@ -from PySide6.QtCore import QEvent, QObject +from typing import Any + +from PySide6.QtCore import QAbstractAnimation, QEvent, QObject, QTimer from PySide6.QtGui import QIcon from PySide6.QtWidgets import QWidget -from qfluentwidgets import ExpandSettingCard, FluentIconBase +from qfluentwidgets import ExpandSettingCard, FluentIcon from qfluentwidgets.components.settings.expand_setting_card import GroupSeparator from one_dragon.utils.i18_utils import gt @@ -15,9 +17,10 @@ class ExpandSettingCardGroup(ExpandSettingCard): def __init__( self, - icon: FluentIconBase | QIcon | str, + icon: str | QIcon | FluentIcon, title: str, content: str | None = None, + initial_expand: bool = False, parent: QWidget | None = None, ): super().__init__(icon, gt(title), parent=parent) @@ -26,6 +29,10 @@ def __init__( self.viewLayout.setContentsMargins(0, 0, 0, 0) self.viewLayout.setSpacing(0) self._card_sep_pairs: list[tuple[QWidget, GroupSeparator | None]] = [] + if initial_expand: + self.setExpand(True) + self.expandAni.valueChanged.connect(self._sync_parent_layout) + self.expandAni.finished.connect(self._refresh_view_size) def addHeaderWidget(self, widget: QWidget) -> None: """在头部 expandButton 左侧添加操作组件""" @@ -38,7 +45,11 @@ def addSettingCard(self, card: QWidget) -> None: sep = GroupSeparator(self.view) self.viewLayout.addWidget(sep) - card.paintEvent = lambda _e: None + def paint_event_override(_e) -> None: + return None + + paint_event_override_any: Any = paint_event_override + card.paintEvent = paint_event_override_any card.setParent(self.view) self.viewLayout.addWidget(card) self._card_sep_pairs.append((card, sep)) @@ -50,10 +61,14 @@ def addSettingCards(self, cards: list[QWidget]) -> None: for card in cards: self.addSettingCard(card) - def eventFilter(self, obj: QObject, event: QEvent) -> bool: - if event.type() in (QEvent.Type.Show, QEvent.Type.Hide): - self._update_separators() - return super().eventFilter(obj, event) + def eventFilter(self, arg__1: QObject, arg__2: QEvent) -> bool: + if arg__2.type() in (QEvent.Type.Show, QEvent.Type.Hide): + QTimer.singleShot(0, self._update_separators) + elif arg__2.type() in (QEvent.Type.Resize, QEvent.Type.LayoutRequest): + if self.expandAni.state() == QAbstractAnimation.State.Running: + return super().eventFilter(arg__1, arg__2) + QTimer.singleShot(0, self._refresh_view_size) + return super().eventFilter(arg__1, arg__2) def _update_separators(self) -> None: """根据卡片可见性更新分隔线:仅当当前卡片可见且前面存在可见卡片时才显示分隔线""" @@ -63,4 +78,21 @@ def _update_separators(self) -> None: sep.setVisible(card.isVisible() and has_visible_before) if card.isVisible(): has_visible_before = True + self._refresh_view_size() + + def _refresh_view_size(self) -> None: + """同步重算展开区域尺寸,确保子卡高度变化能传递到手风琴容器。""" self._adjustViewSize() + self._sync_parent_layout() + + def _sync_parent_layout(self, *_args) -> None: + """在动画帧内同步父布局,避免手风琴底部与后续卡片之间出现瞬时 gap/重叠。""" + self.updateGeometry() + + parent = self.parentWidget() + while parent is not None: + parent.updateGeometry() + layout = parent.layout() + if layout is not None: + layout.activate() + parent = parent.parentWidget() diff --git a/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py b/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py new file mode 100644 index 0000000..b5a9358 --- /dev/null +++ b/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py @@ -0,0 +1,314 @@ +from collections.abc import Iterable +from enum import Enum + +from PySide6.QtCore import Signal +from PySide6.QtGui import QColor, QIcon +from PySide6.QtWidgets import QHBoxLayout, QVBoxLayout, QWidget +from qfluentwidgets import ( + CaptionLabel, + FluentIcon, + FluentIconBase, + LineEdit, + PushButton, + ToolButton, +) + +from one_dragon.base.config.config_item import ConfigItem +from one_dragon.utils.i18_utils import gt +from one_dragon_qt.utils.layout_utils import IconSize, Margins +from one_dragon_qt.widgets.adapter_init_mixin import AdapterInitMixin +from one_dragon_qt.widgets.combo_box import ComboBox +from one_dragon_qt.widgets.setting_card.setting_card_base import SettingCardBase + + +class MultiValueDisplayComboBoxSettingCard(SettingCardBase, AdapterInitMixin): + """多个单行输入配合预设下拉的设置卡片,适合列表值配置。""" + + value_changed = Signal(list) + + def __init__(self, + icon: str | QIcon | FluentIconBase, title: str, content: str | None = None, + icon_size: IconSize = IconSize(16, 16), + margins: Margins = Margins(16, 16, 0, 16), + options_enum: Iterable[Enum] | None = None, + options_list: list[ConfigItem] | None = None, + input_placeholder: str | None = None, + input_width: int = 360, + combo_width: int = 220, + add_button_text: str | None = None, + preset_placeholder: str | None = None, + parent: QWidget | None = None): + + SettingCardBase.__init__( + self, + icon=icon, + title=title, + content=content, + icon_size=icon_size, + margins=margins, + parent=parent, + ) + AdapterInitMixin.__init__(self) + self.vBoxLayout.setSpacing(8) + + self._fixed_content = content or '' + self._input_placeholder = input_placeholder or '' + self._input_width = input_width + self._opts_list: list[ConfigItem] = [] + self._error_message: str | None = None + + self.main_layout = QVBoxLayout() + self.main_layout.setContentsMargins(0, 0, 0, 0) + self.main_layout.setSpacing(8) + self.hBoxLayout.addLayout(self.main_layout) + + self.header_layout = QHBoxLayout() + self.header_layout.setContentsMargins(0, 0, 0, 0) + self.header_layout.setSpacing(8) + self.main_layout.addLayout(self.header_layout) + + self.combo_box = ComboBox(self) + self.combo_box.setPlaceholderText(preset_placeholder or '自定义') + self.combo_box.setMinimumWidth(combo_width) + self.header_layout.addStretch(1) + self.header_layout.addWidget(self.combo_box) + self.header_layout.addSpacing(16) + + self.value_layout = QVBoxLayout() + self.value_layout.setContentsMargins(0, 0, 0, 0) + self.value_layout.setSpacing(8) + self.main_layout.addLayout(self.value_layout) + + self.add_btn = PushButton(FluentIcon.ADD, gt(add_button_text or '新增'), self) + self.add_btn.clicked.connect(lambda: self._add_row()) + + btn_layout = QHBoxLayout() + btn_layout.setContentsMargins(0, 0, 0, 0) + btn_layout.addWidget(self.add_btn) + btn_layout.addSpacing(16) + self.main_layout.addLayout(btn_layout) + + self.error_label = CaptionLabel('', self) + self.error_label.setTextColor('#cf1010', QColor(255, 28, 32)) + self.error_label.hide() + self.main_layout.addWidget(self.error_label) + + self._initialize_options(options_enum, options_list) + self.combo_box.currentIndexChanged.connect(self._on_preset_changed) + + self._add_row(emit_signal=False) + self._update_height() + + def _initialize_options( + self, + options_enum: Iterable[Enum] | None, + options_list: list[ConfigItem] | None, + ) -> None: + if options_list is not None: + self.set_options_by_list(options_list) + elif options_enum is not None: + self.set_options_by_list([ + opt.value + for opt in options_enum + if isinstance(opt.value, ConfigItem) + ]) + else: + self.set_options_by_list([]) + + def set_options_by_list(self, options: list[ConfigItem]) -> None: + self._opts_list = list(options) + self.combo_box.blockSignals(True) + self.combo_box.clear() + for opt in self._opts_list: + self.combo_box.addItem(opt.ui_text, userData=opt.value) + self.combo_box.blockSignals(False) + self._sync_preset_selection(self.getValue()) + + def _add_row( + self, + value: str = '', + emit_signal: bool = True, + refresh_layout: bool = True, + ) -> None: + row_widget = QWidget(self) + row_layout = QHBoxLayout(row_widget) + row_layout.setContentsMargins(0, 0, 0, 0) + row_layout.setSpacing(8) + + input_container = QWidget(self) + input_container.setProperty('_original_style_sheet', input_container.styleSheet()) + input_layout = QHBoxLayout(input_container) + input_layout.setContentsMargins(1, 1, 1, 1) + input_layout.setSpacing(0) + + value_edit = LineEdit(self) + value_edit.setPlaceholderText(self._input_placeholder) + value_edit.setMinimumWidth(self._input_width) + value_edit.setText(value) + value_edit.setProperty('_original_style_sheet', value_edit.styleSheet()) + value_edit.setProperty('_error_container', input_container) + value_edit.editingFinished.connect(self._on_value_changed) + input_layout.addWidget(value_edit) + + remove_btn = ToolButton(FluentIcon.DELETE, self) + remove_btn.setFixedSize(30, 30) + remove_btn.clicked.connect(lambda: self._remove_row(row_widget)) + + row_layout.addWidget(input_container) + row_layout.addWidget(remove_btn) + row_layout.addSpacing(16) + + self.value_layout.addWidget(row_widget) + self._apply_error_state_to_line_edit(value_edit) + if refresh_layout: + self._refresh_layout() + + if emit_signal and value: + self._on_value_changed() + + def _remove_row(self, row_widget: QWidget) -> None: + if self.value_layout.count() <= 1: + return + + self.value_layout.removeWidget(row_widget) + row_widget.deleteLater() + self._on_value_changed() + self._refresh_layout() + + def _clear_rows(self) -> None: + while self.value_layout.count(): + child = self.value_layout.takeAt(0) + if child.widget(): + child.widget().deleteLater() + + def _update_height(self) -> None: + min_height = 110 + error_height = 24 if self.error_label.isVisible() else 0 + content_height = self.value_layout.count() * 40 + 110 + error_height + self.setFixedHeight(max(min_height, content_height)) + + def _update_all_remove_buttons(self) -> None: + should_enable = self.value_layout.count() > 1 + for i in range(self.value_layout.count()): + row_widget = self.value_layout.itemAt(i).widget() + if row_widget: + remove_btn = row_widget.findChild(ToolButton) + if remove_btn: + remove_btn.setEnabled(should_enable) + + def _refresh_layout(self) -> None: + self._update_height() + self._update_all_remove_buttons() + self.main_layout.activate() + self.hBoxLayout.activate() + self.updateGeometry() + self.update() + + def _block_signals(self, value: bool) -> None: + self.combo_box.blockSignals(value) + for i in range(self.value_layout.count()): + row_widget = self.value_layout.itemAt(i).widget() + if row_widget: + for line_edit in row_widget.findChildren(LineEdit): + line_edit.blockSignals(value) + + def _on_preset_changed(self, index: int) -> None: + if index < 0: + return + values = self.combo_box.itemData(index) + self.setValue(values) + + def _on_value_changed(self) -> None: + values = self.getValue() + self._sync_preset_selection(values) + self.setContent(self._fixed_content) + + if self.adapter is not None: + self.adapter.set_value(values) + + self.value_changed.emit(values) + + def _sync_preset_selection(self, values: list[str]) -> None: + matched_index = -1 + for idx, opt in enumerate(self._opts_list): + if list(opt.value) == values: + matched_index = idx + break + + self.combo_box.blockSignals(True) + self.combo_box.setCurrentIndex(matched_index) + self.combo_box.blockSignals(False) + + def getValue(self) -> list[str]: + values: list[str] = [] + for i in range(self.value_layout.count()): + row_widget = self.value_layout.itemAt(i).widget() + if row_widget: + line_edits = list(row_widget.findChildren(LineEdit)) + if line_edits: + value = line_edits[0].text().strip() + if value: + values.append(value) + return values + + def setValue(self, value: list[str] | None, emit_signal: bool = True): + values = list(value or []) + if not emit_signal: + self._block_signals(True) + + self.setUpdatesEnabled(False) + try: + self._clear_rows() + if values: + for item in values: + self._add_row(str(item), emit_signal=False, refresh_layout=False) + else: + self._add_row(emit_signal=False, refresh_layout=False) + + self._sync_preset_selection(self.getValue()) + self._refresh_layout() + self.setContent(self._fixed_content) + finally: + self.setUpdatesEnabled(True) + + if not emit_signal: + self._block_signals(False) + + def set_error_message(self, message: str | None) -> None: + if self._error_message == message: + return + self._error_message = message + self.error_label.setText(message or '') + self.error_label.setVisible(bool(message)) + for i in range(self.value_layout.count()): + row_widget = self.value_layout.itemAt(i).widget() + if row_widget: + line_edits = list(row_widget.findChildren(LineEdit)) + if line_edits: + self._apply_error_state_to_line_edit(line_edits[0]) + self._refresh_layout() + + def _apply_error_state_to_line_edit(self, line_edit: LineEdit) -> None: + original_style = line_edit.property('_original_style_sheet') + if original_style is None: + original_style = line_edit.styleSheet() + line_edit.setProperty('_original_style_sheet', original_style) + error_container = line_edit.property('_error_container') + if error_container is None: + error_container = line_edit.parentWidget() + + if self._error_message: + line_edit.setError(True) + line_edit.setStyleSheet(str(original_style or '')) + if isinstance(error_container, QWidget): + error_container.setStyleSheet( + 'QWidget { border: 1px solid #cf1010; border-radius: 6px; }' + ) + line_edit.setToolTip(self._error_message) + else: + line_edit.setError(False) + line_edit.setStyleSheet(str(original_style or '')) + if isinstance(error_container, QWidget): + original_container_style = error_container.property('_original_style_sheet') + error_container.setStyleSheet(str(original_container_style or '')) + line_edit.setToolTip('') diff --git a/src/one_dragon_qt/widgets/setting_card/value_display_editable_combo_box_setting_card.py b/src/one_dragon_qt/widgets/setting_card/value_display_editable_combo_box_setting_card.py index d063252..2aacc42 100644 --- a/src/one_dragon_qt/widgets/setting_card/value_display_editable_combo_box_setting_card.py +++ b/src/one_dragon_qt/widgets/setting_card/value_display_editable_combo_box_setting_card.py @@ -51,10 +51,9 @@ def __init__( self.display_label.setAlignment( Qt.AlignmentFlag.AlignRight | Qt.AlignmentFlag.AlignVCenter ) - self.display_label.setAttribute( - Qt.WidgetAttribute.WA_TransparentForMouseEvents - ) + self.display_label.setCursor(Qt.CursorShape.PointingHandCursor) self.display_label.hide() + self.display_label.mousePressEvent = self._on_display_label_mouse_press drop_button_index = self.combo_box.hBoxLayout.indexOf(self.combo_box.dropButton) self.combo_box.hBoxLayout.insertWidget( @@ -134,9 +133,6 @@ def _apply_value(self, value: object) -> None: self._update_desc() def _sync_display_name(self, value: object) -> None: - if not hasattr(self, 'display_label'): - return - display = '' for item in self._opts_list: if item.value == value: @@ -188,3 +184,7 @@ def _normalize_value(self, text: str) -> str: if item.ui_text == text: return str(item.value) return text + + def _on_display_label_mouse_press(self, event) -> None: + self.combo_box._toggleComboMenu() + event.accept() From 3a6b3d77338c1d718d649b39ea84fe9c6807d76e Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 16 May 2026 21:20:51 +0800 Subject: [PATCH 02/10] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E5=99=A8=E6=A8=A1=E5=BC=8F=E5=92=8C=E5=A4=9A=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将脚本进程名配置迁移为候选进程列表 - 运行器区分直接启动和启动器启动场景 - 进程匹配统一规范化名称并避免匹配启动前已有进程 --- src/script_chainer/config/script_config.py | 162 +++++++++++++++--- .../services/process_manager.py | 88 +++++++--- .../utils/process_name_utils.py | 60 +++++++ src/script_chainer/win_exe/script_runner.py | 100 +++++++++-- 4 files changed, 342 insertions(+), 68 deletions(-) create mode 100644 src/script_chainer/utils/process_name_utils.py diff --git a/src/script_chainer/config/script_config.py b/src/script_chainer/config/script_config.py index bf4431f..3e2a186 100644 --- a/src/script_chainer/config/script_config.py +++ b/src/script_chainer/config/script_config.py @@ -1,10 +1,15 @@ from contextlib import suppress from dataclasses import asdict, dataclass, field, fields from enum import Enum -from pathlib import Path +from pathlib import Path, PurePath from one_dragon.base.config.config_item import ConfigItem, get_config_item_from_enum from one_dragon.base.config.yaml_config import YamlConfig +from script_chainer.utils.process_name_utils import ( + normalize_process_name, + normalize_process_names, + process_name_equals, +) class CheckDoneMethods(Enum): @@ -14,16 +19,22 @@ class CheckDoneMethods(Enum): GAME_OR_SCRIPT_CLOSED = ConfigItem(label='游戏或脚本被关闭', value='game_or_script_closed', desc='游戏或脚本被关闭时 认为任务完成') +class ScriptLaunchMethod(Enum): + + DIRECT = ConfigItem(label='直接启动', value=False, desc='将自动监控脚本路径对应的程序,无需填写脚本进程名称') + LAUNCHER = ConfigItem(label='启动器启动', value=True, desc='脚本路径是启动器,需要填写启动后实际运行的目标进程') + + class ScriptProcessName(Enum): - ONE_DRAGON_LAUNCHER = ConfigItem(label='一条龙', value='python.exe') - ONE_DRAGON_RUNTIME_LAUNCHER = ConfigItem(label='一条龙・集成', value='OneDragon-RuntimeLauncher.exe') - BGI = ConfigItem(label='BetterGI', value='BetterGI.exe') - March7th = ConfigItem(label='三月七小助手', value='March7th Assistant.exe') - MAA_BBB = ConfigItem(label='识宝小助手', value='MFAAvalonia.exe') - SRA = ConfigItem(label='StarRailAssistant', value='SRA-cli.exe') - MAA_END = ConfigItem(label='MaaEnd', value='MaaEnd.exe') - MAA_GF2 = ConfigItem(label='MaaGF2', value='MaaGF2Exilium.exe') + ONE_DRAGON_LAUNCHER = ConfigItem(label='一条龙', value=['python.exe', 'pythonw.exe']) + ONE_DRAGON_RUNTIME_LAUNCHER = ConfigItem(label='一条龙・集成', value=['OneDragon-RuntimeLauncher.exe']) + BGI = ConfigItem(label='BetterGI', value=['BetterGI.exe']) + March7th = ConfigItem(label='三月七小助手', value=['March7th Assistant.exe']) + MAA_BBB = ConfigItem(label='识宝小助手', value=['MFAAvalonia.exe']) + SRA = ConfigItem(label='StarRailAssistant', value=['SRA-cli.exe']) + MAA_END = ConfigItem(label='MaaEnd', value=['MaaEnd.exe']) + MAA_GF2 = ConfigItem(label='MaaGF2', value=['MaaGF2Exilium.exe']) class GameProcessName(Enum): @@ -48,14 +59,77 @@ class AttachDirection: POST = 'post' +def _find_process_config_item(enum_cls: type[Enum], process_names: list[str]) -> ConfigItem | None: + normalized = normalize_process_names(process_names) + for enum_item in enum_cls: + if ( + isinstance(enum_item.value, ConfigItem) + and normalize_process_names(enum_item.value.value) == normalized + ): + return enum_item.value + return None + + +def _migrate_legacy_script_process_names(process_names: str | list[str] | None) -> list[str]: + normalized = normalize_process_names(process_names) + if not normalized: + return [] + + matched = _find_process_config_item(ScriptProcessName, normalized) + if matched is not None: + return normalize_process_names(matched.value) + + normalized_set = {name.lower() for name in normalized} + if normalized_set.issubset({'python.exe', 'pythonw.exe'}): + return normalize_process_names(ScriptProcessName.ONE_DRAGON_LAUNCHER.value.value) + + return normalized + + +def _normalize_game_process_name(process_name: object) -> str: + if isinstance(process_name, str): + return normalize_process_name(process_name) + return '' + + +def _migrate_legacy_config_data(data: dict) -> dict: + """集中处理旧配置的兼容迁移。""" + normalized = dict(data) + normalized['script_process_name'] = _migrate_legacy_script_process_names( + normalized.get('script_process_name') + ) + normalized['game_process_name'] = _normalize_game_process_name( + normalized.get('game_process_name', '') + ) + normalized['launcher_mode'] = _infer_launcher_mode( + normalized, + normalized['script_process_name'], + ) + return normalized + + +def _infer_launcher_mode(data: dict, script_process_names: list[str]) -> bool: + if 'launcher_mode' in data: + return bool(data.get('launcher_mode')) + + script_path = str(data.get('script_path') or '').strip() + if not script_path or not script_process_names: + return False + + launch_name = PurePath(script_path).name + return any(not process_name_equals(name, launch_name) for name in script_process_names) + + @dataclass class ScriptConfig: display_name: str = '' + game_label: str = '' script_type: str = ScriptType.EXTERNAL script_path: str = '' - script_process_name: str = '' + script_process_name: list[str] = field(default_factory=list) game_process_name: str = '' + launcher_mode: bool = False run_timeout_seconds: int = 3600 check_done: str = '' kill_script_after_done: bool = True @@ -82,7 +156,10 @@ def to_dict(self) -> dict: def from_dict(cls, data: dict) -> 'ScriptConfig': """从字典反序列化。""" valid = {f.name for f in fields(cls)} - {'idx'} - return cls(**{k: v for k, v in data.items() if k in valid}) + normalized = _migrate_legacy_config_data( + {k: v for k, v in data.items() if k in valid} + ) + return cls(**normalized) @classmethod def create_default(cls) -> 'ScriptConfig': @@ -114,8 +191,44 @@ def script_display_name(self) -> str: @property def game_display_name(self) -> str: - game_process_enum = [i for i in GameProcessName if i.value.value == self.game_process_name] - return game_process_enum[0].value.label if len(game_process_enum) > 0 else self.game_process_name + if self.game_label: + return self.game_label + config = get_config_item_from_enum( + GameProcessName, + normalize_process_name(self.game_process_name), + ) + if config is not None: + return config.label + if self.game_process_name: + return self.game_process_name + return '自定义游戏' + + @property + def script_process_display_name(self) -> str: + config = _find_process_config_item(ScriptProcessName, self.script_process_name) + if config is not None: + return config.label + return ' / '.join(normalize_process_names(self.script_process_name)) + + @property + def launch_program_name(self) -> str: + if not self.script_path: + return '' + return PurePath(self.script_path).name + + @property + def launcher_mode_invalid_message(self) -> str | None: + if not self.launcher_mode: + return None + launch_name = self.launch_program_name + if not launch_name: + return None + if any( + process_name_equals(item, launch_name) + for item in normalize_process_names(self.script_process_name) + ): + return f'启动后实际运行的程序不能包含启动程序本体 {launch_name}' + return None @property def check_done_display_name(self) -> str: @@ -144,16 +257,19 @@ def invalid_message(self) -> str | None: (self.check_done == CheckDoneMethods.GAME_OR_SCRIPT_CLOSED.value.value or self.check_done == CheckDoneMethods.GAME_CLOSED.value.value or self.kill_game_after_done) - and (self.game_process_name is None or len(self.game_process_name) == 0) + and len(normalize_process_name(self.game_process_name)) == 0 ): return '游戏进程名称为空' elif ( - (self.check_done == CheckDoneMethods.GAME_OR_SCRIPT_CLOSED.value.value - or self.check_done == CheckDoneMethods.SCRIPT_CLOSED.value.value - or self.kill_script_after_done) - and (self.script_process_name is None or len(self.script_process_name) == 0) + self.launcher_mode + and (self.check_done == CheckDoneMethods.GAME_OR_SCRIPT_CLOSED.value.value + or self.check_done == CheckDoneMethods.SCRIPT_CLOSED.value.value + or self.kill_script_after_done) + and len(normalize_process_names(self.script_process_name)) == 0 ): - return '脚本进程名称为空' + return '启动后实际运行的程序为空' + elif self.launcher_mode_invalid_message is not None: + return self.launcher_mode_invalid_message elif self.run_timeout_seconds <= 0: return '运行超时时间必须大于0' @@ -168,11 +284,11 @@ def __init__(self, module_name: str, is_mock: bool = False): is_mock=is_mock, sample=False, copy_from_sample=False, ) - self.script_list: list[ScriptConfig] = [ - ScriptConfig.from_dict(i) - for i in self.get('script_list', []) - ] + raw_script_list = self.get('script_list', []) + self.script_list = [ScriptConfig.from_dict(i) for i in raw_script_list] self.init_idx() + if raw_script_list != [i.to_dict() for i in self.script_list]: + self.save() def _get_script_chain_dir(self) -> Path: return Path(self.file_path).parent diff --git a/src/script_chainer/services/process_manager.py b/src/script_chainer/services/process_manager.py index d8ecff3..45bb30e 100644 --- a/src/script_chainer/services/process_manager.py +++ b/src/script_chainer/services/process_manager.py @@ -50,6 +50,10 @@ import psutil from one_dragon.utils.encoding_utils import decode_bytes, get_console_encoding +from script_chainer.utils.process_name_utils import ( + normalize_process_names, + process_name_equals, +) from script_chainer.utils.process_utils import graceful_kill_popen, graceful_kill_psutil # Windows 下隐藏控制台窗口的标志 @@ -117,7 +121,7 @@ def match_process(proc: psutil.Process, target: ProcessInfo) -> bool: try: if target.pid is not None and proc.pid != target.pid: return False - if target.name is not None and proc.name() != target.name: + if target.name is not None and not process_name_equals(proc.name(), target.name): return False if target.exe is not None: try: @@ -132,38 +136,59 @@ def match_process(proc: psutil.Process, target: ProcessInfo) -> bool: return True -def find_process_by_info(target: ProcessInfo) -> psutil.Process | None: - """根据 ProcessInfo 查找第一个匹配的进程。 - - Args: - target: 目标进程匹配条件。 +def find_process_by_infos( + targets: list[ProcessInfo], + exclude_pids: set[int] | None = None, +) -> psutil.Process | None: + """根据多个 ProcessInfo 条件查找第一个匹配的进程。""" + if not targets: + return None - Returns: - 匹配的进程对象,未找到返回 None。 - """ + excluded = exclude_pids or set() for proc in psutil.process_iter(['pid', 'name']): try: - if match_process(proc, target): - return proc + if proc.pid in excluded: + continue + for target in targets: + if match_process(proc, target): + return proc except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): continue return None -def is_process_existed(process_name: str | None) -> bool: +def collect_matching_process_pids(targets: list[ProcessInfo]) -> set[int]: + """收集当前已存在的候选进程 PID,用于启动后的全局搜索去重。""" + matched: set[int] = set() + if not targets: + return matched + + for proc in psutil.process_iter(['pid', 'name']): + try: + for target in targets: + if match_process(proc, target): + matched.add(proc.pid) + break + except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): + continue + return matched + + +def build_process_infos(process_name: str | list[str] | None) -> list[ProcessInfo]: + """将配置中的进程名转换为多个可匹配的 ProcessInfo。""" + return [ProcessInfo(name=name) for name in normalize_process_names(process_name)] + + +def is_process_existed(process_name: str | list[str] | None) -> bool: """判断指定名称的进程是否存在。 Args: - process_name: 进程名称。 + process_name: 进程名称列表。 Returns: 进程是否存在。 """ - if not process_name: - return False - if sys.platform == 'win32' and not process_name.endswith('.exe'): - process_name = f'{process_name}.exe' - return find_process_by_info(ProcessInfo(name=process_name)) is not None + return find_process_by_infos(build_process_infos(process_name)) is not None class ProcessManager: @@ -184,6 +209,7 @@ def __init__(self): self.process: subprocess.Popen | None = None self.target_process: psutil.Process | None = None self._stdout_thread: threading.Thread | None = None + self._target_pid_snapshot: set[int] = set() @property def main_pid(self) -> int | None: @@ -210,7 +236,7 @@ def open_process( program: str, args: list[str] | None = None, cwd: str | None = None, - target_process: ProcessInfo | None = None, + target_process: list[ProcessInfo] | None = None, search_timeout: float = 60, stdout_callback: Callable[[str], None] | None = None, ) -> bool: @@ -220,7 +246,7 @@ def open_process( program: 可执行文件路径。 args: 启动参数列表。 cwd: 工作目录,默认为 program 所在目录。 - target_process: 目标进程信息(用于追踪 launcher 启动的子进程)。 + target_process: 目标进程信息列表(用于追踪 launcher 启动的子进程)。 search_timeout: 搜索目标进程的超时时间(秒)。 stdout_callback: 子进程输出回调,每行调用一次。为 None 时不捕获输出。 @@ -232,6 +258,9 @@ def open_process( else: self.clear() + if target_process is not None: + self._target_pid_snapshot = collect_matching_process_pids(target_process) + command = [program] if args: command.extend(args) @@ -279,7 +308,7 @@ def open_process( def search_process( self, - target: ProcessInfo, + target: list[ProcessInfo], timeout: float = 60, poll_interval: float = 0.5, ) -> bool: @@ -288,7 +317,7 @@ def search_process( 优先从已启动子进程的进程树中搜索,找不到时再进行全局搜索。 Args: - target: 目标进程信息。 + target: 目标进程信息列表。 timeout: 超时时间(秒)。 poll_interval: 轮询间隔(秒)。 @@ -306,18 +335,21 @@ def search_process( found = self._search_in_children(target) if found is None: # fallback: 全局搜索 - found = find_process_by_info(target) + found = find_process_by_infos( + target, + exclude_pids=self._target_pid_snapshot, + ) if found is not None: self.target_process = found return True time.sleep(poll_interval) return False - def _search_in_children(self, target: ProcessInfo) -> psutil.Process | None: + def _search_in_children(self, targets: list[ProcessInfo]) -> psutil.Process | None: """从已启动子进程的后代中搜索匹配的目标进程。 Args: - target: 目标进程匹配条件。 + targets: 目标进程匹配条件列表。 Returns: 匹配的进程对象,未找到返回 None。 @@ -328,8 +360,9 @@ def _search_in_children(self, target: ProcessInfo) -> psutil.Process | None: parent = psutil.Process(self.process.pid) for child in parent.children(recursive=True): with suppress(psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess): - if match_process(child, target): - return child + for target in targets: + if match_process(child, target): + return child except (psutil.NoSuchProcess, psutil.AccessDenied): pass return None @@ -399,6 +432,7 @@ def clear(self) -> None: self.process = None self.target_process = None self._stdout_thread = None + self._target_pid_snapshot = set() # ------------------------------------------------------------------ # Windows Job Object — 父进程退出时自动清理所有子进程 diff --git a/src/script_chainer/utils/process_name_utils.py b/src/script_chainer/utils/process_name_utils.py new file mode 100644 index 0000000..a0a8c6c --- /dev/null +++ b/src/script_chainer/utils/process_name_utils.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import re +import sys +from collections.abc import Iterable + + +def normalize_process_name(name: str) -> str: + """规范化单个进程名,自动补齐 Windows 下的 `.exe` 后缀。""" + normalized = name.strip() + if not normalized: + return '' + if sys.platform == 'win32' and not normalized.lower().endswith('.exe'): + normalized = f'{normalized}.exe' + return normalized + + +def normalize_process_names(value: str | Iterable[str] | None) -> list[str]: + """规范化进程名列表。""" + if value is None: + return [] + + raw_items: list[str] = [] + if isinstance(value, str): + raw_items.extend(re.split(r'[\r\n]+', value)) + else: + for item in value: + if item is None: + continue + if isinstance(item, str): + raw_items.extend(re.split(r'[\r\n]+', item)) + else: + raw_items.append(str(item)) + + result: list[str] = [] + seen: set[str] = set() + for raw_name in raw_items: + name = normalize_process_name(raw_name) + if not name: + continue + dedupe_key = name.lower() if sys.platform == 'win32' else name + if dedupe_key in seen: + continue + seen.add(dedupe_key) + result.append(name) + return result + + +def process_name_equals(left: str | None, right: str | None) -> bool: + """判断两个进程名是否相等,Windows 下按不区分大小写处理。""" + if left is None or right is None: + return left == right + if sys.platform == 'win32': + return normalize_process_name(left).lower() == normalize_process_name(right).lower() + return normalize_process_name(left) == normalize_process_name(right) + + +def format_process_names_for_text(process_names: Iterable[str] | None) -> str: + """将进程名列表格式化为多行文本。""" + return '\n'.join(normalize_process_names(process_names)) diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 49a1990..7e18fe9 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -11,6 +11,7 @@ from contextlib import suppress from dataclasses import dataclass from pathlib import Path, PurePath +from typing import TextIO from colorama import Fore, Style, init @@ -27,10 +28,15 @@ LauncherExitError, ProcessInfo, ProcessManager, - find_process_by_info, + build_process_infos, + find_process_by_infos, is_process_existed, ) from script_chainer.utils.console_close_utils import force_exit_on_console_close +from script_chainer.utils.process_name_utils import ( + normalize_process_names, + process_name_equals, +) from script_chainer.utils.runtime_group_utils import ( build_runtime_selection, resolve_runtime_groups, @@ -61,7 +67,7 @@ class _RunMonitorState: class _TeeWriter: """包装 stdout,将每行输出同时写入 LogNotifier。""" - def __init__(self, original: object, notifier: LogNotifier) -> None: + def __init__(self, original: TextIO, notifier: LogNotifier) -> None: self._original = original self._notifier = notifier @@ -79,6 +85,25 @@ def __getattr__(self, name: str): return getattr(self._original, name) +def _get_target_process_infos(script_path: str, configured_process_name: list[str]) -> list[ProcessInfo]: + """获取需要追踪的目标进程列表。 + + 若某个候选名称与启动文件同名,则它属于直接启动进程, + 不应再作为 launcher 的追踪目标。 + """ + launcher_name = PurePath(script_path).name + return [ + info + for info in build_process_infos(configured_process_name) + if info.name is not None and not process_name_equals(info.name, launcher_name) + ] + + +def _format_process_hint(field_label: str, process_names: str | list[str] | None) -> str: + display = ' / '.join(normalize_process_names(process_names)) or '(空)' + return f'如长时间未检测到进程,请检查“{field_label}”填写是否正确:{display}' + + class _RunnerExitController: """管理 runner 的退出状态和普通信号处理。""" @@ -175,6 +200,7 @@ def _on_stdout(line: str) -> None: def _launch_script( script_config: ScriptConfig, + target_process_infos: list[ProcessInfo] | None = None, log_notifier: LogNotifier | None = None, state: _RunMonitorState | None = None, ) -> ProcessManager: @@ -200,18 +226,13 @@ def _launch_script( if script_config.script_arguments and script_config.script_arguments.strip(): args_list = shlex.split(script_config.script_arguments, posix=False) - # 如果配置了脚本进程名称,则追踪目标进程(launcher 场景) - target = None - if script_config.script_process_name: - target = ProcessInfo(name=script_config.script_process_name) - pm = ProcessManager() try: display_name = script_config.game_display_name or script_config.script_display_name or PurePath(script_path).name success = pm.open_process( program=script_path, args=args_list, - target_process=target, + target_process=target_process_infos, search_timeout=30, stdout_callback=_make_stdout_callback(display_name, log_notifier, state), ) @@ -238,6 +259,7 @@ def _wait_for_subprocess_ready( state: _RunMonitorState, timeout: float = 20, expect_target: bool = False, + missing_process_hint: str | None = None, ) -> bool: """等待子进程就绪,确保进程已经成功启动并运行了一段时间。 @@ -246,6 +268,7 @@ def _wait_for_subprocess_ready( script_path: 脚本路径(用于日志)。 timeout: 等待超时时间(秒)。 expect_target: 是否期望追踪到目标进程(launcher 场景)。 + missing_process_hint: 未找到目标进程时的提示语。 Returns: 子进程是否就绪。 @@ -287,21 +310,28 @@ def _wait_for_subprocess_ready( if _exit_controller.wait(1): break + if expect_target and missing_process_hint: + print_message(missing_process_hint, level='ERROR') + return False def _monitor_script_done( script_config: ScriptConfig, state: _RunMonitorState, + pm: ProcessManager, ) -> None: """监控脚本运行状态,等待完成条件满足。 Args: script_config: 脚本配置。 state: 运行监控状态(跨 _wait_for_subprocess_ready 持久化的进程存在标志)。 + pm: 当前脚本对应的进程管理器。 """ start_time = time.time() last_status: str = '' + game_hint_printed = False + script_hint_printed = False no_log_timeout = script_config.no_log_timeout_seconds @@ -327,12 +357,37 @@ def _monitor_script_done( # 仅在状态变化时打印 if status != last_status: print_message(status, level='PASS' if state.game_ever_existed else 'INFO') + if not state.game_ever_existed and not game_hint_printed and script_config.game_process_name: + print_message( + _format_process_hint('游戏进程名称', script_config.game_process_name), + level='INFO', + ) + game_hint_printed = True last_status = status # 检查脚本进程状态 - script_current_existed = is_process_existed(script_config.script_process_name) + if script_config.launcher_mode: + script_current_existed = is_process_existed(script_config.script_process_name) + else: + script_current_existed = pm.is_running() script_closed = state.script_ever_existed and not script_current_existed state.script_ever_existed = state.script_ever_existed or script_current_existed + if ( + script_config.launcher_mode + and + not state.script_ever_existed + and not script_hint_printed + and script_config.script_process_name + and script_config.check_done in ( + CheckDoneMethods.GAME_OR_SCRIPT_CLOSED.value.value, + CheckDoneMethods.SCRIPT_CLOSED.value.value, + ) + ): + print_message( + _format_process_hint('启动后实际运行的程序', script_config.script_process_name), + level='INFO', + ) + script_hint_printed = True # 判断完成条件 if script_config.check_done == CheckDoneMethods.GAME_OR_SCRIPT_CLOSED.value.value: @@ -399,7 +454,7 @@ def _cleanup_processes(script_config: ScriptConfig, pm: ProcessManager, force_sc if game_name: print_message(f'尝试关闭游戏进程 {game_name}') try: - proc = find_process_by_info(ProcessInfo(name=game_name)) + proc = find_process_by_infos(build_process_infos(game_name)) if proc is not None: proc.terminate() try: @@ -442,19 +497,28 @@ def _run_script_once( script_path = script_config.script_path no_log_timeout = script_config.no_log_timeout_seconds + target_process_infos = None + if script_config.launcher_mode: + target_process_infos = _get_target_process_infos( + script_path, + script_config.script_process_name, + ) or None # 1. 启动脚本子进程 state = _RunMonitorState() - pm = _launch_script(script_config, log_notifier, state) + pm = _launch_script(script_config, target_process_infos, log_notifier, state) _active_pm = pm try: # 2. 等待子进程就绪 - # 仅当脚本进程名与启动文件名不同时才期望追踪目标进程(launcher 场景) - expect_target = ( - bool(script_config.script_process_name) - and script_config.script_process_name.lower() != PurePath(script_path).name.lower() - ) - if not _wait_for_subprocess_ready(pm, script_path, state, expect_target=expect_target): + # 仅在启动器模式下,且存在与启动文件不同名的候选进程时,才期望追踪目标进程 + expect_target = bool(target_process_infos) + if not _wait_for_subprocess_ready( + pm, + script_path, + state, + expect_target=expect_target, + missing_process_hint=_format_process_hint('启动后实际运行的程序', script_config.script_process_name), + ): print_message(f'子进程创建失败 {script_path}', level='ERROR') pm.kill() return @@ -465,7 +529,7 @@ def _run_script_once( # 3. 监控脚本运行状态 try: - _monitor_script_done(script_config, state) + _monitor_script_done(script_config, state, pm) except _NoLogTimeoutError: _cleanup_processes(script_config, pm, force_script=True) raise From 79a30f5d67758c922bdff902c7787f31015eae37 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 16 May 2026 21:21:07 +0800 Subject: [PATCH 03/10] =?UTF-8?q?feat:=20=E5=9C=A8=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E7=BC=96=E8=BE=91=E9=A1=B5=E6=B7=BB=E5=8A=A0=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=99=A8=E9=80=89=E9=A1=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增启动进阶折叠区和启动器模式开关 - 支持填写多个启动后实际运行的候选程序 - 将启动器模式校验错误定位到进阶区域 --- .../gui/page/script_edit_interface.py | 83 +++++++++++++++++-- 1 file changed, 75 insertions(+), 8 deletions(-) diff --git a/src/script_chainer/gui/page/script_edit_interface.py b/src/script_chainer/gui/page/script_edit_interface.py index cb83104..20d23c7 100644 --- a/src/script_chainer/gui/page/script_edit_interface.py +++ b/src/script_chainer/gui/page/script_edit_interface.py @@ -1,6 +1,6 @@ import os -from PySide6.QtCore import Signal +from PySide6.QtCore import QEvent, QObject, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import QFileDialog, QHBoxLayout, QWidget from qfluentwidgets import ( @@ -19,9 +19,15 @@ from one_dragon_qt.widgets.setting_card.combo_box_setting_card import ( ComboBoxSettingCard, ) +from one_dragon_qt.widgets.setting_card.expand_setting_card_group import ( + ExpandSettingCardGroup, +) from one_dragon_qt.widgets.setting_card.multi_push_setting_card import ( MultiPushSettingCard, ) +from one_dragon_qt.widgets.setting_card.multi_value_display_combo_box_setting_card import ( + MultiValueDisplayComboBoxSettingCard, +) from one_dragon_qt.widgets.setting_card.push_setting_card import PushSettingCard from one_dragon_qt.widgets.setting_card.text_setting_card import TextSettingCard from one_dragon_qt.widgets.setting_card.value_display_editable_combo_box_setting_card import ( @@ -34,6 +40,7 @@ ScriptConfig, ScriptProcessName, ) +from script_chainer.utils.process_name_utils import normalize_process_names class ScriptEditInterface(VerticalScrollInterface): @@ -77,14 +84,33 @@ def get_content_widget(self) -> QWidget: self.script_path_opt.clicked.connect(self.on_script_path_clicked) content_widget.add_widget(self.script_path_opt) - self.script_process_name_opt = ValueDisplayEditableComboBoxSettingCard( + self.launch_advanced_group = ExpandSettingCardGroup( + icon=FluentIcon.COMMAND_PROMPT, + title='启动进阶', + content='默认直接监控脚本路径对应程序;只有启动器场景才需要修改', + initial_expand=bool(self.config.launcher_mode), + ) + + self.launcher_mode_switch = SwitchButton() + self.launcher_mode_switch.setOnText('') + self.launcher_mode_switch.setOffText('') + self.launcher_mode_switch.setToolTip('启动后会再打开一个主程序') + self.launcher_mode_switch.checkedChanged.connect(self._on_launch_method_changed) + self.launcher_mode_switch.installEventFilter(self) + self.launch_advanced_group.addHeaderWidget(self.launcher_mode_switch) + + self.script_process_name_opt = MultiValueDisplayComboBoxSettingCard( icon=FluentIcon.GAME, - title='脚本进程名称', - content='需要监听脚本关闭时填入', + title='启动后实际运行的程序', + content='通常只填主程序;检测不到时再添加备用候选,可省略 .exe', options_enum=ScriptProcessName, - input_placeholder='选择或输入脚本进程名', + input_placeholder='填写程序名,例如 BetterGI', + add_button_text='添加备用候选', + preset_placeholder='选择常见程序', ) - content_widget.add_widget(self.script_process_name_opt) + self.script_process_name_opt.value_changed.connect(self._on_script_process_names_changed) + self.launch_advanced_group.addSettingCard(self.script_process_name_opt) + content_widget.add_widget(self.launch_advanced_group) self.game_process_name_opt = ValueDisplayEditableComboBoxSettingCard( icon=FluentIcon.GAME, @@ -221,6 +247,9 @@ def init_by_config(self, config: ScriptConfig): self.config = config.copy() self.script_path_opt.setContent(config.script_path) + self.launcher_mode_switch.blockSignals(True) + self.launcher_mode_switch.setChecked(config.launcher_mode) + self.launcher_mode_switch.blockSignals(False) self.script_process_name_opt.setValue(config.script_process_name, emit_signal=False) self.game_process_name_opt.setValue(config.game_process_name, emit_signal=False) self.run_timeout_seconds_opt.setValue(str(config.run_timeout_seconds), emit_signal=False) @@ -254,6 +283,13 @@ def init_by_config(self, config: ScriptConfig): self.no_log_max_retries_input.setValue(max(1, config.no_log_max_retries)) self.no_log_max_retries_input.blockSignals(False) self.no_log_max_retries_input.setEnabled(no_log_enabled) + self._sync_launch_method_ui() + self._sync_launch_advanced_summary() + + def eventFilter(self, watched: QObject, event: QEvent) -> bool: + if watched is getattr(self, 'launcher_mode_switch', None): + event.accept() + return super().eventFilter(watched, event) def _on_notify_log_toggled(self, checked: bool) -> None: """日志推送开关切换时启用/禁用间隔输入框""" @@ -274,14 +310,39 @@ def on_script_path_chosen(self, file_path) -> None: self.script_path_opt.setContent(file_path) def _get_editable_combo_value(self, card: ValueDisplayEditableComboBoxSettingCard) -> str: - """获取可编辑下拉框的值,优先取 itemData,否则取用户输入的文本""" val = card.getValue() return '' if val is None else str(val).strip() + def _on_launch_method_changed(self, _value: bool) -> None: + self._sync_launch_method_ui(sync_expand=True) + self._sync_launch_advanced_summary() + + def _on_script_process_names_changed(self, *_args) -> None: + self._sync_launch_advanced_summary() + + def _sync_launch_method_ui(self, sync_expand: bool = False) -> None: + launcher_mode = self.launcher_mode_switch.isChecked() + self.script_process_name_opt.setEnabled(launcher_mode) + if sync_expand and launcher_mode: + self.launch_advanced_group.setExpand(True) + + def _sync_launch_advanced_summary(self, *_args) -> None: + config = self.get_config_value() + if config.launcher_mode: + target_name = config.script_process_display_name + if target_name: + summary = f'当前会等待启动后的主程序:{target_name}' + else: + summary = '已开启启动器场景;请填写启动后实际运行的程序' + else: + summary = '默认直接监控脚本路径对应程序;只有启动器场景才需要修改' + self.launch_advanced_group.card.setContent(summary) + def get_config_value(self) -> ScriptConfig: config = self.config.copy() config.script_path = self.config.script_path - config.script_process_name = self._get_editable_combo_value(self.script_process_name_opt) + config.script_process_name = normalize_process_names(self.script_process_name_opt.getValue()) + config.launcher_mode = self.launcher_mode_switch.isChecked() config.game_process_name = self._get_editable_combo_value(self.game_process_name_opt) config.run_timeout_seconds = int(self.run_timeout_seconds_opt.getValue()) config.check_done = str(self.check_done_opt.getValue()) @@ -310,9 +371,15 @@ def validate(self) -> bool: config = self.get_config_value() invalid_message = config.invalid_message if invalid_message is not None: + if invalid_message in ('启动后实际运行的程序为空', config.launcher_mode_invalid_message): + self.script_process_name_opt.set_error_message(invalid_message) + self.launch_advanced_group.setExpand(True) + self.error_label.hide() + return False self.error_label.setText(invalid_message) self.error_label.show() return False else: + self.script_process_name_opt.set_error_message(None) self.error_label.hide() return True From 7a396ff6b9c051ab6722652d7d48cf287d1fc347 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Sat, 16 May 2026 21:21:22 +0800 Subject: [PATCH 04/10] =?UTF-8?q?refactor:=20=E7=AE=80=E5=8C=96=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=A4=87=E6=B3=A8=E5=BC=B9=E7=AA=97=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除多余的 QDialog 枚举判断 - 直接使用弹窗 exec 结果判断是否保存备注 --- src/script_chainer/gui/page/script_setting_cards.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/script_chainer/gui/page/script_setting_cards.py b/src/script_chainer/gui/page/script_setting_cards.py index 37e571b..548b2bd 100644 --- a/src/script_chainer/gui/page/script_setting_cards.py +++ b/src/script_chainer/gui/page/script_setting_cards.py @@ -2,7 +2,7 @@ from PySide6.QtCore import Signal from PySide6.QtGui import QIcon -from PySide6.QtWidgets import QDialog, QHBoxLayout +from PySide6.QtWidgets import QHBoxLayout from qfluentwidgets import Dialog, FluentIcon, SwitchButton, TransparentToolButton from one_dragon.utils.os_utils import reveal_in_file_manager @@ -89,7 +89,7 @@ def on_delete_clicked(self) -> None: def _on_rename(self) -> None: dialog = ScriptRenameDialog(self.config.display_name, parent=self.window()) - if dialog.exec() == QDialog.DialogCode.Accepted: + if dialog.exec(): self.config.display_name = dialog.get_new_name() self._update_display() self.value_changed.emit(self.config) @@ -330,7 +330,7 @@ def on_edit_clicked(self) -> None: initial_code=code, script_path=path, ) - if dialog.exec() == QDialog.DialogCode.Accepted: + if dialog.exec(): self.chain_config.save_python_script(self.config.idx, dialog.get_code()) self._update_display() From 360aa369634ddf029842bb8bd6218a0dfe41e71a Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 05:01:58 +0800 Subject: [PATCH 05/10] =?UTF-8?q?feat:=20=E5=88=A0=E9=99=A4=E5=A4=9A?= =?UTF-8?q?=E4=BD=99=E8=84=9A=E6=9C=AC=E8=BF=9B=E7=A8=8B=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/config/script_config.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/script_chainer/config/script_config.py b/src/script_chainer/config/script_config.py index 3e2a186..e9246c5 100644 --- a/src/script_chainer/config/script_config.py +++ b/src/script_chainer/config/script_config.py @@ -28,13 +28,6 @@ class ScriptLaunchMethod(Enum): class ScriptProcessName(Enum): ONE_DRAGON_LAUNCHER = ConfigItem(label='一条龙', value=['python.exe', 'pythonw.exe']) - ONE_DRAGON_RUNTIME_LAUNCHER = ConfigItem(label='一条龙・集成', value=['OneDragon-RuntimeLauncher.exe']) - BGI = ConfigItem(label='BetterGI', value=['BetterGI.exe']) - March7th = ConfigItem(label='三月七小助手', value=['March7th Assistant.exe']) - MAA_BBB = ConfigItem(label='识宝小助手', value=['MFAAvalonia.exe']) - SRA = ConfigItem(label='StarRailAssistant', value=['SRA-cli.exe']) - MAA_END = ConfigItem(label='MaaEnd', value=['MaaEnd.exe']) - MAA_GF2 = ConfigItem(label='MaaGF2', value=['MaaGF2Exilium.exe']) class GameProcessName(Enum): From 86ffba33f1114be7011e18d7545751dd2f8f2179 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 05:09:24 +0800 Subject: [PATCH 06/10] =?UTF-8?q?fix:=20=E7=94=B1=E8=BF=9B=E7=A8=8B?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=99=A8=E7=AE=A1=E7=90=86=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=99=A8=E8=BF=9B=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/win_exe/script_runner.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/script_chainer/win_exe/script_runner.py b/src/script_chainer/win_exe/script_runner.py index 7e18fe9..94153ee 100644 --- a/src/script_chainer/win_exe/script_runner.py +++ b/src/script_chainer/win_exe/script_runner.py @@ -299,6 +299,7 @@ def _wait_for_subprocess_ready( # launcher 退出但目标进程未就绪,继续等待 print_message(f'启动器已退出 (rc=0),等待目标进程 {script_path}') else: + state.script_ever_existed = True print_message(f'启动器已退出 (rc=0) {script_path}') return True else: @@ -366,10 +367,7 @@ def _monitor_script_done( last_status = status # 检查脚本进程状态 - if script_config.launcher_mode: - script_current_existed = is_process_existed(script_config.script_process_name) - else: - script_current_existed = pm.is_running() + script_current_existed = pm.is_running() script_closed = state.script_ever_existed and not script_current_existed state.script_ever_existed = state.script_ever_existed or script_current_existed if ( From 1759ffe6711ca297935c6f73cdbf581ec20e013b Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 05:23:49 +0800 Subject: [PATCH 07/10] =?UTF-8?q?fix:=20=E5=90=8C=E6=AD=A5=E5=A4=9A?= =?UTF-8?q?=E5=80=BC=E9=A2=84=E8=AE=BE=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../multi_value_display_combo_box_setting_card.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py b/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py index b5a9358..5d3ed12 100644 --- a/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py +++ b/src/one_dragon_qt/widgets/setting_card/multi_value_display_combo_box_setting_card.py @@ -271,7 +271,9 @@ def setValue(self, value: list[str] | None, emit_signal: bool = True): finally: self.setUpdatesEnabled(True) - if not emit_signal: + if emit_signal: + self._on_value_changed() + else: self._block_signals(False) def set_error_message(self, message: str | None) -> None: From 056658abce11afa0d43b7edeee38b610702d8352 Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 05:24:13 +0800 Subject: [PATCH 08/10] =?UTF-8?q?refactor:=20=E5=90=AF=E5=8A=A8=E6=97=B6?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=84=9A=E6=9C=AC=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/config/script_config.py | 59 +++++++++++++------ .../context/script_chainer_context.py | 12 ++++ 2 files changed, 53 insertions(+), 18 deletions(-) diff --git a/src/script_chainer/config/script_config.py b/src/script_chainer/config/script_config.py index e9246c5..d6271dc 100644 --- a/src/script_chainer/config/script_config.py +++ b/src/script_chainer/config/script_config.py @@ -1,7 +1,7 @@ from contextlib import suppress from dataclasses import asdict, dataclass, field, fields from enum import Enum -from pathlib import Path, PurePath +from pathlib import Path, PureWindowsPath from one_dragon.base.config.config_item import ConfigItem, get_config_item_from_enum from one_dragon.base.config.yaml_config import YamlConfig @@ -85,8 +85,8 @@ def _normalize_game_process_name(process_name: object) -> str: return '' -def _migrate_legacy_config_data(data: dict) -> dict: - """集中处理旧配置的兼容迁移。""" +def _migrate_legacy_script_config_data(data: dict) -> dict: + """将旧版脚本配置迁移到当前结构。""" normalized = dict(data) normalized['script_process_name'] = _migrate_legacy_script_process_names( normalized.get('script_process_name') @@ -101,15 +101,33 @@ def _migrate_legacy_config_data(data: dict) -> dict: return normalized +def _migrate_legacy_script_list(raw_script_list: object) -> tuple[list[dict], bool]: + """迁移脚本列表配置,返回迁移后的数据和是否发生变更。""" + if not isinstance(raw_script_list, list): + return [], raw_script_list != [] + + migrated: list[dict] = [] + changed = False + for raw_item in raw_script_list: + if not isinstance(raw_item, dict): + changed = True + continue + migrated_item = _migrate_legacy_script_config_data(raw_item) + if migrated_item != raw_item: + changed = True + migrated.append(migrated_item) + return migrated, changed + + def _infer_launcher_mode(data: dict, script_process_names: list[str]) -> bool: if 'launcher_mode' in data: - return bool(data.get('launcher_mode')) + return data.get('launcher_mode') is True script_path = str(data.get('script_path') or '').strip() if not script_path or not script_process_names: return False - launch_name = PurePath(script_path).name + launch_name = PureWindowsPath(script_path).name return any(not process_name_equals(name, launch_name) for name in script_process_names) @@ -145,15 +163,6 @@ def to_dict(self) -> dict: d.pop('idx', None) return d - @classmethod - def from_dict(cls, data: dict) -> 'ScriptConfig': - """从字典反序列化。""" - valid = {f.name for f in fields(cls)} - {'idx'} - normalized = _migrate_legacy_config_data( - {k: v for k, v in data.items() if k in valid} - ) - return cls(**normalized) - @classmethod def create_default(cls) -> 'ScriptConfig': """创建默认配置。""" @@ -170,7 +179,7 @@ def create_python_default(cls) -> 'ScriptConfig': def copy(self) -> 'ScriptConfig': """深拷贝(保留 idx)。""" - new = self.from_dict(self.to_dict()) + new = ScriptConfig(**self.to_dict()) new.idx = self.idx return new @@ -207,7 +216,7 @@ def script_process_display_name(self) -> str: def launch_program_name(self) -> str: if not self.script_path: return '' - return PurePath(self.script_path).name + return PureWindowsPath(self.script_path).name @property def launcher_mode_invalid_message(self) -> str | None: @@ -269,6 +278,16 @@ def invalid_message(self) -> str | None: class ScriptChainConfig(YamlConfig): + _script_config_fields = {f.name for f in fields(ScriptConfig)} - {'idx'} + + @classmethod + def _load_script_config(cls, data: dict) -> ScriptConfig: + return ScriptConfig(**{ + k: v + for k, v in data.items() + if k in cls._script_config_fields + }) + def __init__(self, module_name: str, is_mock: bool = False): YamlConfig.__init__( self, @@ -278,9 +297,13 @@ def __init__(self, module_name: str, is_mock: bool = False): ) raw_script_list = self.get('script_list', []) - self.script_list = [ScriptConfig.from_dict(i) for i in raw_script_list] + migrated_script_list, migrated = _migrate_legacy_script_list(raw_script_list) + self.script_list = [ + self._load_script_config(i) + for i in migrated_script_list + ] self.init_idx() - if raw_script_list != [i.to_dict() for i in self.script_list]: + if migrated or migrated_script_list != [i.to_dict() for i in self.script_list]: self.save() def _get_script_chain_dir(self) -> Path: diff --git a/src/script_chainer/context/script_chainer_context.py b/src/script_chainer/context/script_chainer_context.py index ad78074..2496feb 100644 --- a/src/script_chainer/context/script_chainer_context.py +++ b/src/script_chainer/context/script_chainer_context.py @@ -29,12 +29,24 @@ def init(self) -> None: return try: + self.migrate_script_chain_configs() self.push_service.init_push_channels() except Exception: log.error('初始化出错', exc_info=True) finally: self._init_lock.release() + def migrate_script_chain_configs(self) -> None: + """启动时触发所有脚本链配置的一次性迁移。""" + config_dir = self.script_chain_config_dir() + if not os.path.isdir(config_dir): + return + + for file_name in os.listdir(config_dir): + if not file_name.endswith('.yml'): + continue + ScriptChainConfig(module_name=file_name[:-4]) + def get_all_script_chain_config(self) -> list[ScriptChainConfig]: config_list: list[ScriptChainConfig] = [] config_dir = self.script_chain_config_dir() From 6c0c8f94a6468b76ff1f9c855b06a50969fedcef Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 05:24:28 +0800 Subject: [PATCH 09/10] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E8=BF=9B=E9=98=B6=E7=BC=96=E8=BE=91=E8=81=94=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/script_chainer/gui/page/script_edit_interface.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/script_chainer/gui/page/script_edit_interface.py b/src/script_chainer/gui/page/script_edit_interface.py index 20d23c7..7c50e4f 100644 --- a/src/script_chainer/gui/page/script_edit_interface.py +++ b/src/script_chainer/gui/page/script_edit_interface.py @@ -327,9 +327,11 @@ def _sync_launch_method_ui(self, sync_expand: bool = False) -> None: self.launch_advanced_group.setExpand(True) def _sync_launch_advanced_summary(self, *_args) -> None: - config = self.get_config_value() - if config.launcher_mode: - target_name = config.script_process_display_name + launcher_mode = self.launcher_mode_switch.isChecked() + target_name = ' / '.join( + normalize_process_names(self.script_process_name_opt.getValue()) + ) + if launcher_mode: if target_name: summary = f'当前会等待启动后的主程序:{target_name}' else: @@ -376,6 +378,7 @@ def validate(self) -> bool: self.launch_advanced_group.setExpand(True) self.error_label.hide() return False + self.script_process_name_opt.set_error_message(None) self.error_label.setText(invalid_message) self.error_label.show() return False From 56e8a4c39818335a5924093fd0bec8eb12cd787d Mon Sep 17 00:00:00 2001 From: ShadowLemoon <119576779+ShadowLemoon@users.noreply.github.com> Date: Thu, 21 May 2026 16:00:35 +0800 Subject: [PATCH 10/10] =?UTF-8?q?perf:=20log=E4=BD=BF=E7=94=A8pathlib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/one_dragon/utils/log_utils.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/one_dragon/utils/log_utils.py b/src/one_dragon/utils/log_utils.py index 0364b5d..7fb0344 100644 --- a/src/one_dragon/utils/log_utils.py +++ b/src/one_dragon/utils/log_utils.py @@ -1,8 +1,8 @@ import logging -import os from contextlib import suppress from dataclasses import dataclass from logging.handlers import TimedRotatingFileHandler +from pathlib import Path from one_dragon.utils import os_utils @@ -139,12 +139,13 @@ def get_log_file_path(log_file_path: str | None = None, default_name: str = 'log configured = (log_file_path or '').strip() if not configured: configured = default_name - if os.path.isabs(configured): - return configured - return os.path.join(os_utils.get_path_under_work_dir('.log'), configured) + path = Path(configured) + if path.is_absolute(): + return str(path) + return str(Path(os_utils.get_path_under_work_dir('.log')) / path) -def get_logger(): +def get_logger() -> logging.Logger: """获取框架默认 logger。 若尚未初始化,则按默认配置初始化一次;若已经存在框架默认 handler,则直接复用。