-
Notifications
You must be signed in to change notification settings - Fork 20
Expand file tree
/
Copy pathmain.py
More file actions
395 lines (325 loc) · 14.1 KB
/
main.py
File metadata and controls
395 lines (325 loc) · 14.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
# This file is part of MFW-ChainFlow Assistant.
# MFW-ChainFlow Assistant is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation, either version 3 of the License,
# or (at your option) any later version.
# MFW-ChainFlow Assistant is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty
# of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See
# the GNU General Public License for more details.
# You should have received a copy of the GNU General Public License
# along with MFW-ChainFlow Assistant. If not, see <https://www.gnu.org/licenses/>.
# Contact: err.overflow@gmail.com
# Copyright (C) 2024-2025 MFW-ChainFlow Assistant. All rights reserved.
"""
MFW-ChainFlow Assistant
MFW-ChainFlow Assistant 启动文件
作者:overflow65537
"""
import os
import sys
import argparse
import atexit
import traceback
from pathlib import Path
def _install_anchor_path() -> str:
"""
用于定位发行根目录(interface、config 等)及单实例锁的路径。
- PyInstaller: sys.frozen 为真,锚点为 sys.executable(旁路布局)。
- Nuitka onefile: 无 sys.frozen,__file__ 在临时解压目录;优先 __compiled__.onefile_argv0,
否则为启动时 sys.argv[0](指向用户启动的 .exe)。
- 源码运行: 锚点为 main.py 所在目录。
"""
if getattr(sys, "frozen", False):
return sys.executable
compiled = globals().get("__compiled__")
if compiled is not None:
return getattr(compiled, "onefile_argv0", None) or sys.argv[0]
return __file__
# 设置工作目录为可执行文件 / main.py 所在目录(避免 Nuitka onefile 留在 Temp 解压目录)
_install_root = Path(_install_anchor_path()).resolve().parent
os.chdir(_install_root)
# 打包版:MaaFramework 等原生库放在发行根下的 maafw/(见 CI move_maa_bin_to_maafw、PyInstaller build.py)
if getattr(sys, "frozen", False) or globals().get("__compiled__") is not None:
_maafw = (_install_root / "maafw").resolve()
os.environ["MAAFW_BINARY_PATH"] = str(_maafw)
if sys.platform == "win32":
try:
os.add_dll_directory(str(_maafw))
_pl = _maafw / "plugins"
if _pl.is_dir():
os.add_dll_directory(str(_pl))
except (AttributeError, OSError, ValueError):
pass
def _show_fatal_startup_error(exc_type, exc_value, exc_traceback) -> None:
"""显示启动阶段致命错误,优先使用项目内 Fluent 弹窗。"""
try:
from app.utils.startup_dialog import show_startup_failure_dialog
show_startup_failure_dialog(exc_type, exc_value, exc_traceback)
except Exception:
tb_text = "".join(traceback.format_exception(exc_type, exc_value, exc_traceback))
print("程序启动失败。", file=sys.stderr)
print(tb_text, file=sys.stderr)
def _run() -> int:
from qasync import QEventLoop, asyncio
# 应用 qasync Windows 平台补丁
import app.utils.qasync_patch
from qfluentwidgets import FluentTranslator
from PySide6.QtCore import Qt, QTranslator
from PySide6.QtWidgets import QApplication
from app.common.__version__ import __version__
from app.common.config import Language, cfg, init_language_on_first_run
from app.common.theme_manager import apply_theme_from_config
from app.utils.crypto import crypto_manager
from app.utils.logger import logger
from app.utils.single_instance import SingleInstanceGuard, is_instance_running
# 启动参数解析(单实例检查前处理 -f,通过套接字请求旧实例优雅退出)
parser = argparse.ArgumentParser(
description="MFW-ChainFlow Assistant", add_help=True
)
parser.add_argument(
"-d", "--direct-run", action="store_true", help="启动后直接运行任务流"
)
parser.add_argument(
"-c", "--config", dest="config_id", help="启动后切换到指定配置ID"
)
parser.add_argument(
"-dev", "--dev", dest="enable_dev", action="store_true", help="显示测试页面"
)
parser.add_argument(
"-f",
"--force-restart",
action="store_true",
help="请求同安装目录下正在运行的 MFW 停止任务并退出后启动本进程",
)
args, qt_extra = parser.parse_known_args(sys.argv[1:])
qt_argv = [sys.argv[0]] + qt_extra
instance_key = str(Path(_install_anchor_path()).resolve())
# DPI缩放配置(-f 等待弹窗也需要)
if cfg.get(cfg.dpiScale) != "Auto":
os.environ["QT_ENABLE_HIGHDPI_SCALING"] = "0"
os.environ["QT_SCALE_FACTOR"] = str(cfg.get(cfg.dpiScale))
init_language_on_first_run()
if args.force_restart and is_instance_running(instance_key):
from app.utils.startup_dialog import run_force_restart_shutdown_flow
early_app = QApplication(qt_argv)
early_app.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings)
apply_theme_from_config()
if not run_force_restart_shutdown_flow(instance_key):
return 1
single_instance = SingleInstanceGuard(instance_key)
if not single_instance.acquire():
# 已有实例:仅尝试前置已有窗口,不弹窗
single_instance.notify_existing_instance()
return 0
atexit.register(single_instance.release)
logger.info(f"MFW 版本:{__version__}")
logger.info(f"当前工作目录: {os.getcwd()}")
import faulthandler
log_dir = Path("debug")
log_dir.mkdir(exist_ok=True)
crash_log = open(log_dir / "crash.log", "a", encoding="utf-8")
faulthandler.enable(file=crash_log, all_threads=True)
# 检查并加载密钥
crypto_manager.ensure_key_exists()
# 全局异常钩子
def global_except_hook(exc_type, exc_value, exc_traceback):
logger.exception(
"未捕获的全局异常:", exc_info=(exc_type, exc_value, exc_traceback)
)
# 显示异常弹窗
try:
from app.utils.startup_dialog import show_uncaught_exception_dialog
show_uncaught_exception_dialog(exc_type, exc_value, exc_traceback)
except Exception as dialog_err:
# 弹窗失败时仅记录日志,避免递归
logger.error(f"显示异常弹窗失败: {dialog_err}")
sys.excepthook = global_except_hook
# 创建Qt应用实例(-f 等待阶段可能已创建)
app = QApplication.instance()
if app is None or not isinstance(app, QApplication):
app = QApplication(qt_argv)
app.setAttribute(Qt.ApplicationAttribute.AA_DontCreateNativeWidgetSiblings)
apply_theme_from_config()
if single_instance.start_activation_server(app):
atexit.register(single_instance.stop_activation_server)
else:
logger.warning("单实例激活服务启动失败,重复启动时将无法自动前置已有窗口")
window_holder = {"window": None}
pending_force_shutdown = {"requested": False}
pending_activation = {"requested": False}
def _activate_existing_window() -> bool:
window = window_holder["window"]
if window is None:
pending_activation["requested"] = True
return True
if not window.isVisible():
window.show()
if window.windowState() & Qt.WindowState.WindowMinimized:
window.showNormal()
window.raise_()
window.activateWindow()
if os.name == "nt":
try:
import ctypes
user32 = ctypes.windll.user32
hwnd = int(window.winId())
if user32.IsIconic(hwnd):
user32.ShowWindow(hwnd, 9) # SW_RESTORE
else:
user32.ShowWindow(hwnd, 5) # SW_SHOW
# SetForegroundWindow 常因系统前台策略失败,但窗口已恢复/显示即视为成功
user32.SetForegroundWindow(hwnd)
except Exception:
logger.debug("Windows 前置已有实例失败", exc_info=True)
return True
single_instance.set_activation_callback(_activate_existing_window)
# 国际化配置(须在 -f 等待弹窗之后安装,以便弹窗也能翻译)
locale = cfg.get(cfg.language)
translator = FluentTranslator(locale.value)
galleryTranslator = QTranslator()
i18n_dir = os.path.join(".", "app", "i18n")
def _try_load_qm(translator: QTranslator, filenames: tuple[str, ...]) -> bool:
for name in filenames:
path = os.path.join(i18n_dir, name)
if os.path.isfile(path) and translator.load(path):
return True
return False
# 确定语言代码(与 interface_manager / 资源包 languages 键一致)
language_code = "zh_cn"
if locale == Language.CHINESE_SIMPLIFIED:
_try_load_qm(galleryTranslator, ("i18n.zh_CN.qm",))
language_code = "zh_cn"
logger.info("加载简体中文翻译")
elif locale == Language.CHINESE_TRADITIONAL:
if _try_load_qm(galleryTranslator, ("i18n.zh_TW.qm",)):
logger.info("加载繁体中文翻译")
else:
logger.warning("未找到繁体 .qm:i18n.zh_TW.qm")
language_code = "zh_tw"
elif locale == Language.JAPANESE:
_try_load_qm(galleryTranslator, ("i18n.ja_JP.qm",))
language_code = "ja_jp"
logger.info("加载日语翻译")
elif locale == Language.ENGLISH:
language_code = "en_us"
logger.info("加载英文翻译")
app.installTranslator(translator)
app.installTranslator(galleryTranslator)
# 尝试导入 maa 库,检测是否缺少 VC++ Redistributable
try:
import maa
from maa.context import Context
from maa.custom_action import CustomAction
from maa.custom_recognition import CustomRecognition
except (ImportError, OSError) as e:
error_msg = str(e).lower()
# 检测是否是 DLL 加载失败或 VC++ 相关错误
if any(
keyword in error_msg
for keyword in [
"dll",
"vcruntime",
"msvcp",
"api-ms-win",
"找不到指定的模块",
"specified module could not be found",
"failed to load",
"cannot load",
]
):
from app.utils.startup_dialog import show_vcredist_missing_dialog
show_vcredist_missing_dialog()
else:
# 其他导入错误,正常抛出
raise
# 异步事件循环初始化
loop = QEventLoop(app)
# 异步异常处理
def handle_async_exception(loop, context):
logger.exception("异步任务异常:", exc_info=context.get("exception"))
loop.set_exception_handler(handle_async_exception)
asyncio.set_event_loop(loop)
def _schedule_graceful_shutdown(window) -> None:
async def _stop_and_close() -> None:
try:
task_runner = window.service_coordinator.task_runner
if task_runner.is_running or task_runner.maafw.has_active_runtime():
await window.service_coordinator.stop_task(manual=True)
except Exception:
logger.exception("收到 -f 关闭请求后停止任务失败")
window._allow_window_close = True
from PySide6.QtCore import QTimer
QTimer.singleShot(0, window.close)
try:
asyncio.ensure_future(_stop_and_close(), loop=loop)
except Exception:
logger.exception("调度优雅关闭失败")
window._allow_window_close = True
window.close()
def _handle_force_shutdown_request() -> bool:
window = window_holder["window"]
if window is None:
pending_force_shutdown["requested"] = True
return True
_schedule_graceful_shutdown(window)
return True
single_instance.set_shutdown_callback(_handle_force_shutdown_request)
# 初始化 GPU 信息缓存
try:
from app.utils.gpu_cache import gpu_cache
gpu_cache.initialize()
except Exception as e:
logger.warning(f"GPU 信息缓存初始化失败,忽略: {e}")
# 创建主窗口
from app.view.main_window.main_window import MainWindow
w = MainWindow(
loop=loop,
auto_run=args.direct_run,
switch_config_id=args.config_id,
force_enable_test=args.enable_dev,
)
window_holder["window"] = w
if pending_force_shutdown["requested"]:
_schedule_graceful_shutdown(w)
elif pending_activation["requested"]:
from PySide6.QtCore import QTimer
QTimer.singleShot(0, _activate_existing_window)
w.show()
# 连接应用退出信号到事件循环停止
app.aboutToQuit.connect(loop.stop)
# 运行事件循环
with loop:
loop.run_forever()
logger.debug("关闭异步任务完成")
# Cancel all pending tasks before closing the loop
try:
# Get and cancel all pending tasks
pending = asyncio.all_tasks(loop)
for task in pending:
task.cancel()
# Wait for all tasks to be cancelled (gather handles empty list safely)
if pending:
loop.run_until_complete(
asyncio.gather(*pending, return_exceptions=True)
)
# Double-check for any remaining tasks created during cancellation
remaining = asyncio.all_tasks(loop)
if remaining:
logger.warning(f"发现 {len(remaining)} 个未取消的任务,正在强制取消")
for task in remaining:
task.cancel()
loop.run_until_complete(
asyncio.gather(*remaining, return_exceptions=True)
)
except Exception as e:
logger.warning(f"取消待处理任务时出错: {e}")
return 0
if __name__ == "__main__":
try:
sys.exit(_run())
except SystemExit:
raise
except Exception:
_show_fatal_startup_error(*sys.exc_info())
sys.exit(1)