fix: harden plugin dependency loading in frozen app runtime (#5015)
* fix: compare plugin versions semantically in market updates * fix: prioritize plugin site-packages for in-process pip * fix: reload starlette from plugin target site-packages * fix: harden plugin dependency import precedence in frozen runtime * fix: improve plugin dependency conflict handling * refactor: simplify plugin conflict checks and version utils * fix: expand transitive plugin dependencies for conflict checks * fix: recover conflicting plugin dependencies during module prefer * fix: reuse renderer restart flow for tray backend restart * fix: add recoverable plugin dependency conflict handling * revert: remove plugin version comparison changes * fix: add missing tray restart backend labels
This commit is contained in:
@@ -191,6 +191,7 @@ class PluginManager:
|
|||||||
await pip_installer.install(requirements_path=pth)
|
await pip_installer.install(requirements_path=pth)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
|
logger.error(f"更新插件 {p} 的依赖失败。Code: {e!s}")
|
||||||
|
return True
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None:
|
def _load_plugin_metadata(plugin_path: str, plugin_obj=None) -> StarMetadata | None:
|
||||||
@@ -644,6 +645,49 @@ class PluginManager:
|
|||||||
self.failed_plugin_info = fail_rec
|
self.failed_plugin_info = fail_rec
|
||||||
return False, fail_rec
|
return False, fail_rec
|
||||||
|
|
||||||
|
async def _cleanup_failed_plugin_install(
|
||||||
|
self,
|
||||||
|
dir_name: str,
|
||||||
|
plugin_path: str,
|
||||||
|
) -> None:
|
||||||
|
plugin = None
|
||||||
|
for star in self.context.get_all_stars():
|
||||||
|
if star.root_dir_name == dir_name:
|
||||||
|
plugin = star
|
||||||
|
break
|
||||||
|
|
||||||
|
if plugin and plugin.name and plugin.module_path:
|
||||||
|
try:
|
||||||
|
await self._terminate_plugin(plugin)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(traceback.format_exc())
|
||||||
|
try:
|
||||||
|
await self._unbind_plugin(plugin.name, plugin.module_path)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(traceback.format_exc())
|
||||||
|
|
||||||
|
if os.path.exists(plugin_path):
|
||||||
|
try:
|
||||||
|
remove_dir(plugin_path)
|
||||||
|
logger.warning(f"已清理安装失败的插件目录: {plugin_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"清理安装失败插件目录失败: {plugin_path},原因: {e!s}",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_config_path = os.path.join(
|
||||||
|
self.plugin_config_path,
|
||||||
|
f"{dir_name}_config.json",
|
||||||
|
)
|
||||||
|
if os.path.exists(plugin_config_path):
|
||||||
|
try:
|
||||||
|
os.remove(plugin_config_path)
|
||||||
|
logger.warning(f"已清理安装失败插件配置: {plugin_config_path}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"清理安装失败插件配置失败: {plugin_config_path},原因: {e!s}",
|
||||||
|
)
|
||||||
|
|
||||||
async def install_plugin(self, repo_url: str, proxy=""):
|
async def install_plugin(self, repo_url: str, proxy=""):
|
||||||
"""从仓库 URL 安装插件
|
"""从仓库 URL 安装插件
|
||||||
|
|
||||||
@@ -669,44 +713,62 @@ class PluginManager:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async with self._pm_lock:
|
async with self._pm_lock:
|
||||||
plugin_path = await self.updator.install(repo_url, proxy)
|
plugin_path = ""
|
||||||
# reload the plugin
|
dir_name = ""
|
||||||
dir_name = os.path.basename(plugin_path)
|
cleanup_required = False
|
||||||
await self.load(specified_dir_name=dir_name)
|
try:
|
||||||
|
plugin_path = await self.updator.install(repo_url, proxy)
|
||||||
|
cleanup_required = True
|
||||||
|
|
||||||
# Get the plugin metadata to return repo info
|
# reload the plugin
|
||||||
plugin = self.context.get_registered_star(dir_name)
|
dir_name = os.path.basename(plugin_path)
|
||||||
if not plugin:
|
success, error_message = await self.load(specified_dir_name=dir_name)
|
||||||
# Try to find by other name if directory name doesn't match plugin name
|
if not success:
|
||||||
for star in self.context.get_all_stars():
|
raise Exception(
|
||||||
if star.root_dir_name == dir_name:
|
error_message
|
||||||
plugin = star
|
or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
|
||||||
break
|
|
||||||
|
|
||||||
# Extract README.md content if exists
|
|
||||||
readme_content = None
|
|
||||||
readme_path = os.path.join(plugin_path, "README.md")
|
|
||||||
if not os.path.exists(readme_path):
|
|
||||||
readme_path = os.path.join(plugin_path, "readme.md")
|
|
||||||
|
|
||||||
if os.path.exists(readme_path):
|
|
||||||
try:
|
|
||||||
with open(readme_path, encoding="utf-8") as f:
|
|
||||||
readme_content = f.read()
|
|
||||||
except Exception as e:
|
|
||||||
logger.warning(
|
|
||||||
f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
plugin_info = None
|
# Get the plugin metadata to return repo info
|
||||||
if plugin:
|
plugin = self.context.get_registered_star(dir_name)
|
||||||
plugin_info = {
|
if not plugin:
|
||||||
"repo": plugin.repo,
|
# Try to find by other name if directory name doesn't match plugin name
|
||||||
"readme": readme_content,
|
for star in self.context.get_all_stars():
|
||||||
"name": plugin.name,
|
if star.root_dir_name == dir_name:
|
||||||
}
|
plugin = star
|
||||||
|
break
|
||||||
|
|
||||||
return plugin_info
|
# Extract README.md content if exists
|
||||||
|
readme_content = None
|
||||||
|
readme_path = os.path.join(plugin_path, "README.md")
|
||||||
|
if not os.path.exists(readme_path):
|
||||||
|
readme_path = os.path.join(plugin_path, "readme.md")
|
||||||
|
|
||||||
|
if os.path.exists(readme_path):
|
||||||
|
try:
|
||||||
|
with open(readme_path, encoding="utf-8") as f:
|
||||||
|
readme_content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}",
|
||||||
|
)
|
||||||
|
|
||||||
|
plugin_info = None
|
||||||
|
if plugin:
|
||||||
|
plugin_info = {
|
||||||
|
"repo": plugin.repo,
|
||||||
|
"readme": readme_content,
|
||||||
|
"name": plugin.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
return plugin_info
|
||||||
|
except Exception:
|
||||||
|
if cleanup_required and dir_name and plugin_path:
|
||||||
|
await self._cleanup_failed_plugin_install(
|
||||||
|
dir_name=dir_name,
|
||||||
|
plugin_path=plugin_path,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
async def uninstall_plugin(
|
async def uninstall_plugin(
|
||||||
self,
|
self,
|
||||||
@@ -968,6 +1030,7 @@ class PluginManager:
|
|||||||
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
|
dir_name = os.path.basename(zip_file_path).replace(".zip", "")
|
||||||
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
dir_name = dir_name.removesuffix("-master").removesuffix("-main").lower()
|
||||||
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
desti_dir = os.path.join(self.plugin_store_path, dir_name)
|
||||||
|
cleanup_required = False
|
||||||
|
|
||||||
# 第一步:检查是否已安装同目录名的插件,先终止旧插件
|
# 第一步:检查是否已安装同目录名的插件,先终止旧插件
|
||||||
existing_plugin = None
|
existing_plugin = None
|
||||||
@@ -987,74 +1050,88 @@ class PluginManager:
|
|||||||
existing_plugin.name, existing_plugin.module_path
|
existing_plugin.name, existing_plugin.module_path
|
||||||
)
|
)
|
||||||
|
|
||||||
self.updator.unzip_file(zip_file_path, desti_dir)
|
|
||||||
|
|
||||||
# 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件
|
|
||||||
try:
|
try:
|
||||||
new_metadata = self._load_plugin_metadata(desti_dir)
|
self.updator.unzip_file(zip_file_path, desti_dir)
|
||||||
if new_metadata and new_metadata.name:
|
cleanup_required = True
|
||||||
for star in self.context.get_all_stars():
|
|
||||||
if (
|
|
||||||
star.name == new_metadata.name
|
|
||||||
and star.root_dir_name != dir_name
|
|
||||||
):
|
|
||||||
logger.warning(
|
|
||||||
f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..."
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self._terminate_plugin(star)
|
|
||||||
except Exception:
|
|
||||||
logger.warning(traceback.format_exc())
|
|
||||||
if star.name and star.module_path:
|
|
||||||
await self._unbind_plugin(star.name, star.module_path)
|
|
||||||
break # 只处理第一个匹配的
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}")
|
|
||||||
|
|
||||||
# remove the zip
|
# 第二步:解压后,读取新插件的 metadata.yaml,检查是否存在同名但不同目录的插件
|
||||||
try:
|
|
||||||
os.remove(zip_file_path)
|
|
||||||
except BaseException as e:
|
|
||||||
logger.warning(f"删除插件压缩包失败: {e!s}")
|
|
||||||
# await self.reload()
|
|
||||||
await self.load(specified_dir_name=dir_name)
|
|
||||||
|
|
||||||
# Get the plugin metadata to return repo info
|
|
||||||
plugin = self.context.get_registered_star(dir_name)
|
|
||||||
if not plugin:
|
|
||||||
# Try to find by other name if directory name doesn't match plugin name
|
|
||||||
for star in self.context.get_all_stars():
|
|
||||||
if star.root_dir_name == dir_name:
|
|
||||||
plugin = star
|
|
||||||
break
|
|
||||||
|
|
||||||
# Extract README.md content if exists
|
|
||||||
readme_content = None
|
|
||||||
readme_path = os.path.join(desti_dir, "README.md")
|
|
||||||
if not os.path.exists(readme_path):
|
|
||||||
readme_path = os.path.join(desti_dir, "readme.md")
|
|
||||||
|
|
||||||
if os.path.exists(readme_path):
|
|
||||||
try:
|
try:
|
||||||
with open(readme_path, encoding="utf-8") as f:
|
new_metadata = self._load_plugin_metadata(desti_dir)
|
||||||
readme_content = f.read()
|
if new_metadata and new_metadata.name:
|
||||||
|
for star in self.context.get_all_stars():
|
||||||
|
if (
|
||||||
|
star.name == new_metadata.name
|
||||||
|
and star.root_dir_name != dir_name
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
f"检测到同名插件 {star.name} 存在于不同目录 {star.root_dir_name},正在终止..."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._terminate_plugin(star)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(traceback.format_exc())
|
||||||
|
if star.name and star.module_path:
|
||||||
|
await self._unbind_plugin(star.name, star.module_path)
|
||||||
|
break # 只处理第一个匹配的
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
|
logger.debug(f"读取新插件 metadata.yaml 失败,跳过同名检查: {e!s}")
|
||||||
|
|
||||||
plugin_info = None
|
# remove the zip
|
||||||
if plugin:
|
try:
|
||||||
plugin_info = {
|
os.remove(zip_file_path)
|
||||||
"repo": plugin.repo,
|
except BaseException as e:
|
||||||
"readme": readme_content,
|
logger.warning(f"删除插件压缩包失败: {e!s}")
|
||||||
"name": plugin.name,
|
# await self.reload()
|
||||||
}
|
success, error_message = await self.load(specified_dir_name=dir_name)
|
||||||
|
if not success:
|
||||||
if plugin.repo:
|
raise Exception(
|
||||||
asyncio.create_task(
|
error_message
|
||||||
Metric.upload(
|
or f"安装插件 {dir_name} 失败,请检查插件依赖或兼容性。"
|
||||||
et="install_star_f", # install star
|
|
||||||
repo=plugin.repo,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return plugin_info
|
# Get the plugin metadata to return repo info
|
||||||
|
plugin = self.context.get_registered_star(dir_name)
|
||||||
|
if not plugin:
|
||||||
|
# Try to find by other name if directory name doesn't match plugin name
|
||||||
|
for star in self.context.get_all_stars():
|
||||||
|
if star.root_dir_name == dir_name:
|
||||||
|
plugin = star
|
||||||
|
break
|
||||||
|
|
||||||
|
# Extract README.md content if exists
|
||||||
|
readme_content = None
|
||||||
|
readme_path = os.path.join(desti_dir, "README.md")
|
||||||
|
if not os.path.exists(readme_path):
|
||||||
|
readme_path = os.path.join(desti_dir, "readme.md")
|
||||||
|
|
||||||
|
if os.path.exists(readme_path):
|
||||||
|
try:
|
||||||
|
with open(readme_path, encoding="utf-8") as f:
|
||||||
|
readme_content = f.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"读取插件 {dir_name} 的 README.md 文件失败: {e!s}")
|
||||||
|
|
||||||
|
plugin_info = None
|
||||||
|
if plugin:
|
||||||
|
plugin_info = {
|
||||||
|
"repo": plugin.repo,
|
||||||
|
"readme": readme_content,
|
||||||
|
"name": plugin.name,
|
||||||
|
}
|
||||||
|
|
||||||
|
if plugin.repo:
|
||||||
|
asyncio.create_task(
|
||||||
|
Metric.upload(
|
||||||
|
et="install_star_f", # install star
|
||||||
|
repo=plugin.repo,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return plugin_info
|
||||||
|
except Exception:
|
||||||
|
if cleanup_required:
|
||||||
|
await self._cleanup_failed_plugin_install(
|
||||||
|
dir_name=dir_name,
|
||||||
|
plugin_path=desti_dir,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import contextlib
|
import contextlib
|
||||||
import importlib
|
import importlib
|
||||||
|
import importlib.metadata as importlib_metadata
|
||||||
|
import importlib.util
|
||||||
import io
|
import io
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||||
@@ -12,6 +17,11 @@ from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
|||||||
logger = logging.getLogger("astrbot")
|
logger = logging.getLogger("astrbot")
|
||||||
|
|
||||||
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
|
_DISTLIB_FINDER_PATCH_ATTEMPTED = False
|
||||||
|
_SITE_PACKAGES_IMPORT_LOCK = threading.RLock()
|
||||||
|
|
||||||
|
|
||||||
|
def _canonicalize_distribution_name(name: str) -> str:
|
||||||
|
return re.sub(r"[-_.]+", "-", name).strip("-").lower()
|
||||||
|
|
||||||
|
|
||||||
def _get_pip_main():
|
def _get_pip_main():
|
||||||
@@ -49,6 +59,373 @@ def _cleanup_added_root_handlers(original_handlers: list[logging.Handler]) -> No
|
|||||||
handler.close()
|
handler.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _prepend_sys_path(path: str) -> None:
|
||||||
|
normalized_target = os.path.realpath(path)
|
||||||
|
sys.path[:] = [
|
||||||
|
item for item in sys.path if os.path.realpath(item) != normalized_target
|
||||||
|
]
|
||||||
|
sys.path.insert(0, normalized_target)
|
||||||
|
|
||||||
|
|
||||||
|
def _module_exists_in_site_packages(module_name: str, site_packages_path: str) -> bool:
|
||||||
|
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||||
|
package_init = os.path.join(base_path, "__init__.py")
|
||||||
|
module_file = f"{base_path}.py"
|
||||||
|
return os.path.isfile(package_init) or os.path.isfile(module_file)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_module_loaded_from_site_packages(
|
||||||
|
module_name: str,
|
||||||
|
site_packages_path: str,
|
||||||
|
) -> bool:
|
||||||
|
module = sys.modules.get(module_name)
|
||||||
|
if module is None:
|
||||||
|
try:
|
||||||
|
module = importlib.import_module(module_name)
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
module_file = getattr(module, "__file__", None)
|
||||||
|
if not module_file:
|
||||||
|
return False
|
||||||
|
|
||||||
|
module_path = os.path.realpath(module_file)
|
||||||
|
site_packages_real = os.path.realpath(site_packages_path)
|
||||||
|
try:
|
||||||
|
return (
|
||||||
|
os.path.commonpath([module_path, site_packages_real]) == site_packages_real
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_requirement_name(raw_requirement: str) -> str | None:
|
||||||
|
line = raw_requirement.split("#", 1)[0].strip()
|
||||||
|
if not line:
|
||||||
|
return None
|
||||||
|
if line.startswith(("-r", "--requirement", "-c", "--constraint")):
|
||||||
|
return None
|
||||||
|
if line.startswith("-"):
|
||||||
|
return None
|
||||||
|
|
||||||
|
egg_match = re.search(r"#egg=([A-Za-z0-9_.-]+)", raw_requirement)
|
||||||
|
if egg_match:
|
||||||
|
return _canonicalize_distribution_name(egg_match.group(1))
|
||||||
|
|
||||||
|
candidate = re.split(r"[<>=!~;\s\[]", line, maxsplit=1)[0].strip()
|
||||||
|
if not candidate:
|
||||||
|
return None
|
||||||
|
return _canonicalize_distribution_name(candidate)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_requirement_names(requirements_path: str) -> set[str]:
|
||||||
|
names: set[str] = set()
|
||||||
|
try:
|
||||||
|
with open(requirements_path, encoding="utf-8") as requirements_file:
|
||||||
|
for line in requirements_file:
|
||||||
|
requirement_name = _extract_requirement_name(line)
|
||||||
|
if requirement_name:
|
||||||
|
names.add(requirement_name)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("读取依赖文件失败,跳过冲突检测: %s", exc)
|
||||||
|
return names
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_top_level_modules(
|
||||||
|
distribution: importlib_metadata.Distribution,
|
||||||
|
) -> set[str]:
|
||||||
|
try:
|
||||||
|
text = distribution.read_text("top_level.txt") or ""
|
||||||
|
except Exception:
|
||||||
|
return set()
|
||||||
|
|
||||||
|
modules: set[str] = set()
|
||||||
|
for line in text.splitlines():
|
||||||
|
candidate = line.strip()
|
||||||
|
if not candidate or candidate.startswith("#"):
|
||||||
|
continue
|
||||||
|
modules.add(candidate)
|
||||||
|
return modules
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_candidate_modules(
|
||||||
|
requirement_names: set[str],
|
||||||
|
site_packages_path: str,
|
||||||
|
) -> set[str]:
|
||||||
|
by_name: dict[str, list[importlib_metadata.Distribution]] = {}
|
||||||
|
try:
|
||||||
|
for distribution in importlib_metadata.distributions(path=[site_packages_path]):
|
||||||
|
distribution_name = distribution.metadata.get("Name")
|
||||||
|
if not distribution_name:
|
||||||
|
continue
|
||||||
|
canonical_name = _canonicalize_distribution_name(distribution_name)
|
||||||
|
by_name.setdefault(canonical_name, []).append(distribution)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("读取 site-packages 元数据失败,使用回退模块名: %s", exc)
|
||||||
|
|
||||||
|
expanded_requirement_names: set[str] = set()
|
||||||
|
pending = deque(requirement_names)
|
||||||
|
while pending:
|
||||||
|
requirement_name = pending.popleft()
|
||||||
|
if requirement_name in expanded_requirement_names:
|
||||||
|
continue
|
||||||
|
expanded_requirement_names.add(requirement_name)
|
||||||
|
|
||||||
|
for distribution in by_name.get(requirement_name, []):
|
||||||
|
for dependency_line in distribution.requires or []:
|
||||||
|
dependency_name = _extract_requirement_name(dependency_line)
|
||||||
|
if not dependency_name:
|
||||||
|
continue
|
||||||
|
if dependency_name in expanded_requirement_names:
|
||||||
|
continue
|
||||||
|
pending.append(dependency_name)
|
||||||
|
|
||||||
|
candidates: set[str] = set()
|
||||||
|
for requirement_name in expanded_requirement_names:
|
||||||
|
matched_distributions = by_name.get(requirement_name, [])
|
||||||
|
modules_for_requirement: set[str] = set()
|
||||||
|
for distribution in matched_distributions:
|
||||||
|
modules_for_requirement.update(_extract_top_level_modules(distribution))
|
||||||
|
|
||||||
|
if modules_for_requirement:
|
||||||
|
candidates.update(modules_for_requirement)
|
||||||
|
continue
|
||||||
|
|
||||||
|
fallback_module_name = requirement_name.replace("-", "_")
|
||||||
|
if fallback_module_name:
|
||||||
|
candidates.add(fallback_module_name)
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_preferred_modules(
|
||||||
|
module_names: set[str],
|
||||||
|
site_packages_path: str,
|
||||||
|
) -> None:
|
||||||
|
unresolved_prefer_reasons = _prefer_modules_from_site_packages(
|
||||||
|
module_names, site_packages_path
|
||||||
|
)
|
||||||
|
|
||||||
|
unresolved_modules: list[str] = []
|
||||||
|
for module_name in sorted(module_names):
|
||||||
|
if not _module_exists_in_site_packages(module_name, site_packages_path):
|
||||||
|
continue
|
||||||
|
if _is_module_loaded_from_site_packages(module_name, site_packages_path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
failure_reason = unresolved_prefer_reasons.get(module_name)
|
||||||
|
if failure_reason:
|
||||||
|
unresolved_modules.append(f"{module_name} -> {failure_reason}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
loaded_module = sys.modules.get(module_name)
|
||||||
|
loaded_from = getattr(loaded_module, "__file__", "unknown")
|
||||||
|
unresolved_modules.append(f"{module_name} -> {loaded_from}")
|
||||||
|
|
||||||
|
if unresolved_modules:
|
||||||
|
conflict_message = (
|
||||||
|
"检测到插件依赖与当前运行时发生冲突,无法安全加载该插件。"
|
||||||
|
f"冲突模块: {', '.join(unresolved_modules)}"
|
||||||
|
)
|
||||||
|
raise RuntimeError(conflict_message)
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_module_from_site_packages(
|
||||||
|
module_name: str, site_packages_path: str
|
||||||
|
) -> bool:
|
||||||
|
with _SITE_PACKAGES_IMPORT_LOCK:
|
||||||
|
base_path = os.path.join(site_packages_path, *module_name.split("."))
|
||||||
|
package_init = os.path.join(base_path, "__init__.py")
|
||||||
|
module_file = f"{base_path}.py"
|
||||||
|
|
||||||
|
module_location = None
|
||||||
|
submodule_search_locations = None
|
||||||
|
|
||||||
|
if os.path.isfile(package_init):
|
||||||
|
module_location = package_init
|
||||||
|
submodule_search_locations = [os.path.dirname(package_init)]
|
||||||
|
elif os.path.isfile(module_file):
|
||||||
|
module_location = module_file
|
||||||
|
else:
|
||||||
|
return False
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
module_name,
|
||||||
|
module_location,
|
||||||
|
submodule_search_locations=submodule_search_locations,
|
||||||
|
)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
matched_keys = [
|
||||||
|
key
|
||||||
|
for key in list(sys.modules.keys())
|
||||||
|
if key == module_name or key.startswith(f"{module_name}.")
|
||||||
|
]
|
||||||
|
original_modules = {key: sys.modules[key] for key in matched_keys}
|
||||||
|
|
||||||
|
try:
|
||||||
|
for key in matched_keys:
|
||||||
|
sys.modules.pop(key, None)
|
||||||
|
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
sys.modules[module_name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
|
||||||
|
if "." in module_name:
|
||||||
|
parent_name, child_name = module_name.rsplit(".", 1)
|
||||||
|
parent_module = sys.modules.get(parent_name)
|
||||||
|
if parent_module is not None:
|
||||||
|
setattr(parent_module, child_name, module)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Loaded %s from plugin site-packages: %s",
|
||||||
|
module_name,
|
||||||
|
module_location,
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
failed_keys = [
|
||||||
|
key
|
||||||
|
for key in list(sys.modules.keys())
|
||||||
|
if key == module_name or key.startswith(f"{module_name}.")
|
||||||
|
]
|
||||||
|
for key in failed_keys:
|
||||||
|
sys.modules.pop(key, None)
|
||||||
|
sys.modules.update(original_modules)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_conflicting_module_name(exc: Exception) -> str | None:
|
||||||
|
if isinstance(exc, ModuleNotFoundError):
|
||||||
|
missing_name = getattr(exc, "name", None)
|
||||||
|
if missing_name:
|
||||||
|
return missing_name.split(".", 1)[0]
|
||||||
|
|
||||||
|
message = str(exc)
|
||||||
|
from_match = re.search(r"from '([A-Za-z0-9_.]+)'", message)
|
||||||
|
if from_match:
|
||||||
|
return from_match.group(1).split(".", 1)[0]
|
||||||
|
|
||||||
|
no_module_match = re.search(r"No module named '([A-Za-z0-9_.]+)'", message)
|
||||||
|
if no_module_match:
|
||||||
|
return no_module_match.group(1).split(".", 1)[0]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_module_with_dependency_recovery(
|
||||||
|
module_name: str,
|
||||||
|
site_packages_path: str,
|
||||||
|
max_attempts: int = 3,
|
||||||
|
) -> bool:
|
||||||
|
recovered_dependencies: set[str] = set()
|
||||||
|
|
||||||
|
for _ in range(max_attempts):
|
||||||
|
try:
|
||||||
|
return _prefer_module_from_site_packages(module_name, site_packages_path)
|
||||||
|
except Exception as exc:
|
||||||
|
dependency_name = _extract_conflicting_module_name(exc)
|
||||||
|
if (
|
||||||
|
not dependency_name
|
||||||
|
or dependency_name == module_name
|
||||||
|
or dependency_name in recovered_dependencies
|
||||||
|
):
|
||||||
|
raise
|
||||||
|
|
||||||
|
recovered_dependencies.add(dependency_name)
|
||||||
|
recovered = _prefer_module_from_site_packages(
|
||||||
|
dependency_name,
|
||||||
|
site_packages_path,
|
||||||
|
)
|
||||||
|
if not recovered:
|
||||||
|
raise
|
||||||
|
logger.info(
|
||||||
|
"Recovered dependency %s while preferring %s from plugin site-packages.",
|
||||||
|
dependency_name,
|
||||||
|
module_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _prefer_modules_from_site_packages(
|
||||||
|
module_names: set[str],
|
||||||
|
site_packages_path: str,
|
||||||
|
) -> dict[str, str]:
|
||||||
|
pending_modules = sorted(module_names)
|
||||||
|
unresolved_reasons: dict[str, str] = {}
|
||||||
|
max_rounds = max(2, min(6, len(pending_modules) + 1))
|
||||||
|
|
||||||
|
for _ in range(max_rounds):
|
||||||
|
if not pending_modules:
|
||||||
|
break
|
||||||
|
|
||||||
|
next_round_pending: list[str] = []
|
||||||
|
round_progress = False
|
||||||
|
|
||||||
|
for module_name in pending_modules:
|
||||||
|
try:
|
||||||
|
loaded = _prefer_module_with_dependency_recovery(
|
||||||
|
module_name,
|
||||||
|
site_packages_path,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
unresolved_reasons[module_name] = str(exc)
|
||||||
|
next_round_pending.append(module_name)
|
||||||
|
continue
|
||||||
|
|
||||||
|
unresolved_reasons.pop(module_name, None)
|
||||||
|
if loaded:
|
||||||
|
round_progress = True
|
||||||
|
else:
|
||||||
|
logger.debug(
|
||||||
|
"Module %s not found in plugin site-packages: %s",
|
||||||
|
module_name,
|
||||||
|
site_packages_path,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not next_round_pending:
|
||||||
|
pending_modules = []
|
||||||
|
break
|
||||||
|
|
||||||
|
if not round_progress and len(next_round_pending) == len(pending_modules):
|
||||||
|
pending_modules = next_round_pending
|
||||||
|
break
|
||||||
|
|
||||||
|
pending_modules = next_round_pending
|
||||||
|
|
||||||
|
final_unresolved = {
|
||||||
|
module_name: unresolved_reasons.get(module_name, "unknown import error")
|
||||||
|
for module_name in pending_modules
|
||||||
|
}
|
||||||
|
for module_name, reason in final_unresolved.items():
|
||||||
|
logger.warning(
|
||||||
|
"Failed to prefer module %s from plugin site-packages: %s",
|
||||||
|
module_name,
|
||||||
|
reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
return final_unresolved
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_plugin_dependencies_preferred(
|
||||||
|
target_site_packages: str,
|
||||||
|
requested_requirements: set[str],
|
||||||
|
) -> None:
|
||||||
|
if not requested_requirements:
|
||||||
|
return
|
||||||
|
|
||||||
|
candidate_modules = _collect_candidate_modules(
|
||||||
|
requested_requirements,
|
||||||
|
target_site_packages,
|
||||||
|
)
|
||||||
|
if not candidate_modules:
|
||||||
|
return
|
||||||
|
|
||||||
|
_ensure_preferred_modules(candidate_modules, target_site_packages)
|
||||||
|
|
||||||
|
|
||||||
def _get_loader_for_package(package: object) -> object | None:
|
def _get_loader_for_package(package: object) -> object | None:
|
||||||
loader = getattr(package, "__loader__", None)
|
loader = getattr(package, "__loader__", None)
|
||||||
if loader is not None:
|
if loader is not None:
|
||||||
@@ -73,7 +450,7 @@ def _try_register_distlib_finder(
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
register_finder(loader_type, resource_finder)
|
register_finder(loader, resource_finder)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Failed to patch pip distlib finder for loader %s (%s): %s",
|
"Failed to patch pip distlib finder for loader %s (%s): %s",
|
||||||
@@ -165,10 +542,15 @@ class PipInstaller:
|
|||||||
mirror: str | None = None,
|
mirror: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
args = ["install"]
|
args = ["install"]
|
||||||
|
requested_requirements: set[str] = set()
|
||||||
if package_name:
|
if package_name:
|
||||||
args.append(package_name)
|
args.append(package_name)
|
||||||
|
requirement_name = _extract_requirement_name(package_name)
|
||||||
|
if requirement_name:
|
||||||
|
requested_requirements.add(requirement_name)
|
||||||
elif requirements_path:
|
elif requirements_path:
|
||||||
args.extend(["-r", requirements_path])
|
args.extend(["-r", requirements_path])
|
||||||
|
requested_requirements = _extract_requirement_names(requirements_path)
|
||||||
|
|
||||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||||
@@ -177,7 +559,9 @@ class PipInstaller:
|
|||||||
if is_packaged_electron_runtime():
|
if is_packaged_electron_runtime():
|
||||||
target_site_packages = get_astrbot_site_packages_path()
|
target_site_packages = get_astrbot_site_packages_path()
|
||||||
os.makedirs(target_site_packages, exist_ok=True)
|
os.makedirs(target_site_packages, exist_ok=True)
|
||||||
|
_prepend_sys_path(target_site_packages)
|
||||||
args.extend(["--target", target_site_packages])
|
args.extend(["--target", target_site_packages])
|
||||||
|
args.extend(["--upgrade", "--force-reinstall"])
|
||||||
|
|
||||||
if self.pip_install_arg:
|
if self.pip_install_arg:
|
||||||
args.extend(self.pip_install_arg.split())
|
args.extend(self.pip_install_arg.split())
|
||||||
@@ -188,8 +572,12 @@ class PipInstaller:
|
|||||||
if result_code != 0:
|
if result_code != 0:
|
||||||
raise Exception(f"安装失败,错误码:{result_code}")
|
raise Exception(f"安装失败,错误码:{result_code}")
|
||||||
|
|
||||||
if target_site_packages and target_site_packages not in sys.path:
|
if target_site_packages:
|
||||||
sys.path.insert(0, target_site_packages)
|
_prepend_sys_path(target_site_packages)
|
||||||
|
_ensure_plugin_dependencies_preferred(
|
||||||
|
target_site_packages,
|
||||||
|
requested_requirements,
|
||||||
|
)
|
||||||
importlib.invalidate_caches()
|
importlib.invalidate_caches()
|
||||||
|
|
||||||
async def _run_pip_in_process(self, args: list[str]) -> int:
|
async def _run_pip_in_process(self, args: list[str]) -> int:
|
||||||
|
|||||||
+28
-1
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterView></RouterView>
|
<RouterView></RouterView>
|
||||||
|
<WaitingForRestart ref="globalWaitingRef" />
|
||||||
|
|
||||||
<!-- 全局唯一 snackbar -->
|
<!-- 全局唯一 snackbar -->
|
||||||
<v-snackbar v-if="toastStore.current" v-model="snackbarShow" :color="toastStore.current.color"
|
<v-snackbar v-if="toastStore.current" v-model="snackbarShow" :color="toastStore.current.color"
|
||||||
@@ -14,10 +15,14 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { RouterView } from 'vue-router';
|
import { RouterView } from 'vue-router';
|
||||||
import { computed } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useToastStore } from '@/stores/toast'
|
import { useToastStore } from '@/stores/toast'
|
||||||
|
import WaitingForRestart from '@/components/shared/WaitingForRestart.vue'
|
||||||
|
import { restartAstrBot } from '@/utils/restartAstrBot'
|
||||||
|
|
||||||
const toastStore = useToastStore()
|
const toastStore = useToastStore()
|
||||||
|
const globalWaitingRef = ref(null)
|
||||||
|
let disposeTrayRestartListener = null
|
||||||
|
|
||||||
const snackbarShow = computed({
|
const snackbarShow = computed({
|
||||||
get: () => !!toastStore.current,
|
get: () => !!toastStore.current,
|
||||||
@@ -25,4 +30,26 @@ const snackbarShow = computed({
|
|||||||
if (!val) toastStore.shift()
|
if (!val) toastStore.shift()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const desktopBridge = window.astrbotDesktop
|
||||||
|
if (!desktopBridge?.isElectron || !desktopBridge.onTrayRestartBackend) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
disposeTrayRestartListener = desktopBridge.onTrayRestartBackend(async () => {
|
||||||
|
try {
|
||||||
|
await restartAstrBot(globalWaitingRef.value)
|
||||||
|
} catch (error) {
|
||||||
|
globalWaitingRef.value?.stop?.()
|
||||||
|
console.error('Tray restart backend failed:', error)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (disposeTrayRestartListener) {
|
||||||
|
disposeTrayRestartListener()
|
||||||
|
disposeTrayRestartListener = null
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ declare global {
|
|||||||
ok: boolean;
|
ok: boolean;
|
||||||
reason: string | null;
|
reason: string | null;
|
||||||
}>;
|
}>;
|
||||||
|
onTrayRestartBackend?: (callback: () => void) => () => void;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ function getShellTexts(locale) {
|
|||||||
trayHide: '隐藏 AstrBot',
|
trayHide: '隐藏 AstrBot',
|
||||||
trayShow: '显示 AstrBot',
|
trayShow: '显示 AstrBot',
|
||||||
trayReload: '重新加载',
|
trayReload: '重新加载',
|
||||||
|
trayRestartBackend: '重启后端',
|
||||||
trayQuit: '退出',
|
trayQuit: '退出',
|
||||||
startupFailTitle: 'AstrBot 启动失败',
|
startupFailTitle: 'AstrBot 启动失败',
|
||||||
startupFailMessage: 'AstrBot 后端不可达。',
|
startupFailMessage: 'AstrBot 后端不可达。',
|
||||||
@@ -62,6 +63,7 @@ function getShellTexts(locale) {
|
|||||||
trayHide: 'Hide AstrBot',
|
trayHide: 'Hide AstrBot',
|
||||||
trayShow: 'Show AstrBot',
|
trayShow: 'Show AstrBot',
|
||||||
trayReload: 'Reload',
|
trayReload: 'Reload',
|
||||||
|
trayRestartBackend: 'Restart Backend',
|
||||||
trayQuit: 'Quit',
|
trayQuit: 'Quit',
|
||||||
startupFailTitle: 'AstrBot startup failed',
|
startupFailTitle: 'AstrBot startup failed',
|
||||||
startupFailMessage: 'AstrBot backend is not reachable.',
|
startupFailMessage: 'AstrBot backend is not reachable.',
|
||||||
|
|||||||
@@ -116,6 +116,29 @@ function updateTrayMenu() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: shellTexts.trayRestartBackend,
|
||||||
|
click: async () => {
|
||||||
|
if (!backendManager) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
|
showWindow();
|
||||||
|
const currentUrl = mainWindow.webContents.getURL();
|
||||||
|
if (currentUrl.startsWith(backendManager.getBackendUrl())) {
|
||||||
|
mainWindow.webContents.send('astrbot-desktop:tray-restart-backend');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await backendManager.restartBackend();
|
||||||
|
if (!result.ok) {
|
||||||
|
logElectron(
|
||||||
|
`Tray restart backend fallback failed: ${result.reason || 'unknown reason'}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
{ type: 'separator' },
|
{ type: 'separator' },
|
||||||
{
|
{
|
||||||
label: shellTexts.trayQuit,
|
label: shellTexts.trayQuit,
|
||||||
|
|||||||
@@ -9,4 +9,14 @@ contextBridge.exposeInMainWorld('astrbotDesktop', {
|
|||||||
restartBackend: (authToken) =>
|
restartBackend: (authToken) =>
|
||||||
ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken),
|
ipcRenderer.invoke('astrbot-desktop:restart-backend', authToken),
|
||||||
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
|
stopBackend: () => ipcRenderer.invoke('astrbot-desktop:stop-backend'),
|
||||||
|
onTrayRestartBackend: (callback) => {
|
||||||
|
const listener = () => {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ipcRenderer.on('astrbot-desktop:tray-restart-backend', listener);
|
||||||
|
return () =>
|
||||||
|
ipcRenderer.removeListener('astrbot-desktop:tray-restart-backend', listener);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user