Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,10 @@ def __init__(self) -> None:
self.Info_MaxWaitTime = ConfigItem(
"Info", "MaxWaitTime", 300, RangeValidator(1, 9999), legacy_group="Data"
)
## 关闭 MuMu 时强力清理残留进程
self.Info_ForceKillOnClose = ConfigItem(
"Info", "ForceKillOnClose", True, BoolValidator()
)

super().__init__()

Expand Down
3 changes: 3 additions & 0 deletions app/models/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,9 @@ class EmulatorConfig_Info(BaseModel):
Path: Optional[str] = Field(default=None, description="模拟器路径")
BossKey: Optional[str] = Field(default=None, description="老板键快捷键配置")
MaxWaitTime: Optional[int] = Field(default=None, description="最大等待时间(秒)")
ForceKillOnClose: Optional[bool] = Field(
default=None, description="关闭 MuMu 时强力清理残留进程"
)


class EmulatorConfig(BaseModel):
Expand Down
109 changes: 81 additions & 28 deletions app/utils/emulator/mumu.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,12 @@

logger = get_logger("MuMu模拟器管理")

MUMU_FORCE_KILL_KEYWORDS = (
"mumunxdevice",
"mumunxmain",
"mumuvmmheadless",
)


class MumuManager(DeviceBase):
"""
Expand Down Expand Up @@ -117,38 +123,85 @@ async def open(self, idx: str, package_name: str = "") -> DeviceInfo:
raise RuntimeError(f"模拟器 {idx} 启动超时, 当前状态码: {status}")

async def close(self, idx: str) -> DeviceStatus:
status = await self.getStatus(idx)
if status not in [DeviceStatus.ONLINE, DeviceStatus.STARTING]:
logger.warning(f"设备{idx}未在线,当前状态: {status}")
return status
try:
status = await self.getStatus(idx)
if status not in [DeviceStatus.ONLINE, DeviceStatus.STARTING]:
logger.warning(f"设备{idx}未在线,当前状态: {status}")
return status

result = await ProcessRunner.run_process(
self.emulator_path,
"control",
"-v",
idx,
"shutdown",
timeout=self.config.get("Info", "MaxWaitTime"),
if_merge_std=True,
)
# 参考命令 MuMuManager.exe control -v 2 shutdown

if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result.stdout}")

t = datetime.now()
while datetime.now() - t < timedelta(
seconds=self.config.get("Info", "MaxWaitTime")
):
status = await self.getStatus(idx)
if status == DeviceStatus.OFFLINE:
return DeviceStatus.OFFLINE
await asyncio.sleep(0.1)

else:
if status in [DeviceStatus.ERROR, DeviceStatus.UNKNOWN]:
raise RuntimeError(f"模拟器 {idx} 关闭失败, 状态码: {status}")
raise RuntimeError(f"模拟器 {idx} 关闭超时, 当前状态码: {status}")
finally:
if self.config.get("Info", "ForceKillOnClose"):
self._force_kill_mumu_processes()

def _force_kill_mumu_processes(self) -> None:
"""按 MuMu 固定进程白名单清理关闭后的残留进程。"""

killed_count = 0
for proc in psutil.process_iter(["pid", "name", "exe"]):
try:
proc_name = proc.info.get("name") or ""
proc_exe = proc.info.get("exe") or ""
if not self._is_mumu_force_kill_target(proc_name, proc_exe):
continue

result = await ProcessRunner.run_process(
self.emulator_path,
"control",
"-v",
idx,
"shutdown",
timeout=self.config.get("Info", "MaxWaitTime"),
if_merge_std=True,
)
# 参考命令 MuMuManager.exe control -v 2 shutdown
killed_count += self._kill_process_tree(proc)
except (psutil.NoSuchProcess, psutil.AccessDenied, OSError) as e:
logger.warning(f"强力清理 MuMu 残留进程失败: {e}")

if result.returncode != 0:
raise RuntimeError(f"命令执行失败: {result.stdout}")
if killed_count > 0:
logger.info(f"MuMu 残留进程清理完成,共结束 {killed_count} 个进程")
else:
logger.info("未发现需要强力清理的 MuMu 残留进程")

t = datetime.now()
while datetime.now() - t < timedelta(
seconds=self.config.get("Info", "MaxWaitTime")
):
status = await self.getStatus(idx)
if status == DeviceStatus.OFFLINE:
return DeviceStatus.OFFLINE
await asyncio.sleep(0.1)
def _kill_process_tree(self, proc: psutil.Process) -> int:
killed_count = 0
for child in proc.children(recursive=True):
killed_count += self._kill_process(child)
killed_count += self._kill_process(proc)
return killed_count

else:
if status in [DeviceStatus.ERROR, DeviceStatus.UNKNOWN]:
raise RuntimeError(f"模拟器 {idx} 关闭失败, 状态码: {status}")
raise RuntimeError(f"模拟器 {idx} 关闭超时, 当前状态码: {status}")
@staticmethod
def _kill_process(proc: psutil.Process) -> int:
try:
proc.kill()
return 1
except psutil.NoSuchProcess:
return 0
except (psutil.AccessDenied, OSError) as e:
logger.warning(f"强力清理 MuMu 残留进程失败: {e}")
return 0

@staticmethod
def _is_mumu_force_kill_target(proc_name: str, proc_exe: str) -> bool:
target_text = f"{proc_name} {proc_exe}".lower()
return any(keyword in target_text for keyword in MUMU_FORCE_KILL_KEYWORDS)

async def getStatus(self, idx: str, data: str | None = None) -> DeviceStatus:
if data is None:
Expand Down
4 changes: 4 additions & 0 deletions frontend/src/api/models/EmulatorConfig_Info.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,5 +23,9 @@ export type EmulatorConfig_Info = {
* 最大等待时间(秒)
*/
MaxWaitTime?: (number | null);
/**
* 关闭 MuMu 时强力清理残留进程
*/
ForceKillOnClose?: (boolean | null);
};

76 changes: 39 additions & 37 deletions frontend/src/views/Emulator.vue
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ interface EmulatorInfo {
path: string
max_wait_time: number
boss_keys: string[]
force_kill_on_close: boolean
}

// 安全的 JSON 解析函数
Expand Down Expand Up @@ -187,17 +188,20 @@ const canStopDevice = (status: number) => {
return status === DeviceStatus.ONLINE || status === DeviceStatus.STARTING
}

const buildEditingData = (configData: any): EmulatorInfo => ({
name: configData?.Info?.Name || '',
type: configData?.Info?.Type || '',
path: configData?.Info?.Path || '',
max_wait_time: configData?.Info?.MaxWaitTime || 300,
boss_keys: safeJsonParse(configData?.Info?.BossKey, []),
force_kill_on_close: configData?.Info?.ForceKillOnClose !== false,
})

// 获取当前模拟器的编辑数据
const getEditingData = (uuid: string): EmulatorInfo => {
if (!editingDataMap.value.has(uuid)) {
const configData = emulatorData.value[uuid]
editingDataMap.value.set(uuid, {
name: configData?.Info?.Name || '',
type: configData?.Info?.Type || '',
path: configData?.Info?.Path || '',
max_wait_time: configData?.Info?.MaxWaitTime || 300,
boss_keys: safeJsonParse(configData?.Info?.BossKey, []),
})
editingDataMap.value.set(uuid, buildEditingData(configData))
}
return editingDataMap.value.get(uuid)!
}
Expand All @@ -224,17 +228,11 @@ const loadEmulators = async () => {
// 初始化所有模拟器的编辑数据
emulatorIndex.value.forEach(item => {
const configData = emulatorData.value[item.uid]
const bossKeys = safeJsonParse(configData?.Info?.BossKey, [])
editingDataMap.value.set(item.uid, {
name: configData?.Info?.Name || '',
type: configData?.Info?.Type || '',
path: configData?.Info?.Path || '',
max_wait_time: configData?.Info?.MaxWaitTime || 300,
boss_keys: bossKeys,
})
const editData = buildEditingData(configData)
editingDataMap.value.set(item.uid, editData)
// 同步 boss_keys 到输入框显示
if (bossKeys.length > 0) {
bossKeyInputMap.value[item.uid] = bossKeys[0]
if (editData.boss_keys.length > 0) {
bossKeyInputMap.value[item.uid] = editData.boss_keys[0]
}
})
} else {
Expand Down Expand Up @@ -293,17 +291,11 @@ const refreshEmulatorConfig = async (uuid?: string) => {

// 更新编辑数据
const configData = updatedData[uuid]
const bossKeys = safeJsonParse(configData?.Info?.BossKey, [])
editingDataMap.value.set(uuid, {
name: configData?.Info?.Name || '',
type: configData?.Info?.Type || '',
path: configData?.Info?.Path || '',
max_wait_time: configData?.Info?.MaxWaitTime || 300,
boss_keys: bossKeys,
})
const editData = buildEditingData(configData)
editingDataMap.value.set(uuid, editData)
// 同步 boss_keys 到输入框显示
if (bossKeys.length > 0) {
bossKeyInputMap.value[uuid] = bossKeys[0]
if (editData.boss_keys.length > 0) {
bossKeyInputMap.value[uuid] = editData.boss_keys[0]
}
}
} else {
Expand All @@ -314,17 +306,11 @@ const refreshEmulatorConfig = async (uuid?: string) => {
// 更新编辑数据
emulatorIndex.value.forEach(item => {
const configData = emulatorData.value[item.uid]
const bossKeys = safeJsonParse(configData?.Info?.BossKey, [])
editingDataMap.value.set(item.uid, {
name: configData?.Info?.Name || '',
type: configData?.Info?.Type || '',
path: configData?.Info?.Path || '',
max_wait_time: configData?.Info?.MaxWaitTime || 300,
boss_keys: bossKeys,
})
const editData = buildEditingData(configData)
editingDataMap.value.set(item.uid, editData)
// 同步 boss_keys 到输入框显示
if (bossKeys.length > 0) {
bossKeyInputMap.value[item.uid] = bossKeys[0]
if (editData.boss_keys.length > 0) {
bossKeyInputMap.value[item.uid] = editData.boss_keys[0]
}
})
}
Expand Down Expand Up @@ -355,6 +341,8 @@ const handleSaveChange = async (uuid: string, key: string, value: any) => {
configData = { Info: { MaxWaitTime: value } }
} else if (key === 'boss_keys') {
configData = { Info: { BossKey: JSON.stringify(value) } }
} else if (key === 'force_kill_on_close') {
configData = { Info: { ForceKillOnClose: value } }
}

const response = await Service.updateEmulatorApiEmulatorUpdatePost({
Expand Down Expand Up @@ -946,6 +934,20 @@ const handleBossKeyInputChange = (uuid: string) => {
MuMu模拟器无需配置老板键
</span>
</a-descriptions-item>
<a-descriptions-item v-if="getEditingData(element.uid).type === 'mumu'">
<template #label>
<span>强力关闭</span>
<a-tooltip title="按进程名清理 MuMu 残留进程,可能影响其他实例,多开慎用">
<QuestionCircleOutlined style="margin-left: 4px" />
</a-tooltip>
</template>
<a-switch
v-model:checked="getEditingData(element.uid).force_kill_on_close"
checked-children="开"
un-checked-children="关"
@change="handleSaveChange(element.uid, 'force_kill_on_close', $event)"
/>
</a-descriptions-item>
</a-descriptions>
</div>
</div>
Expand Down
3 changes: 2 additions & 1 deletion res/version.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"okww专项 优化用户配置页面的样式",
"okww专项 调度台展示游戏配置与启动流程日志",
"okww专项 重构配置优化",
"MAAEND专项 配置重构,优化样式"
"MAAEND专项 配置重构,优化样式",
"模拟器管理 添加强力清理MuMu功能(默认开启)"
],
"修复BUG": [
"修复静默模式未对M9A本体生效的问题",
Expand Down
Loading