perf: 稳定源码与 Electron 打包环境下的 pip 安装行为,并修复非 Electron 环境下点击 WebUI 更新按钮时出现跳转对话框的问题 (#4996)
* fix: handle pip install execution in frozen runtime * fix: harden pip subprocess fallback handling * fix: scope global data root to packaged electron runtime * refactor: inline frozen runtime check for electron guard * fix: prefer current interpreter for source pip installs * fix: avoid resolving venv python symlink for pip * refactor: share runtime environment detection utilities * fix: improve error message when pip module is unavailable * fix: raise ImportError when pip module is unavailable * fix: preserve ImportError semantics for missing pip * fix: 修复非electron app环境更新时仍然显示electron更新对话框的问题 --------- Co-authored-by: Soulter <905617992@qq.com>
This commit is contained in:
@@ -15,6 +15,8 @@ Skills 目录路径:固定为数据目录下的 skills 目录
|
||||
|
||||
import os
|
||||
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
|
||||
|
||||
def get_astrbot_path() -> str:
|
||||
"""获取Astrbot项目路径"""
|
||||
@@ -27,6 +29,8 @@ def get_astrbot_root() -> str:
|
||||
"""获取Astrbot根目录路径"""
|
||||
if path := os.environ.get("ASTRBOT_ROOT"):
|
||||
return os.path.realpath(path)
|
||||
if is_packaged_electron_runtime():
|
||||
return os.path.realpath(os.path.join(os.path.expanduser("~"), ".astrbot"))
|
||||
return os.path.realpath(os.getcwd())
|
||||
|
||||
|
||||
|
||||
@@ -2,67 +2,30 @@ import asyncio
|
||||
import contextlib
|
||||
import importlib
|
||||
import io
|
||||
import locale
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from astrbot.core.utils.astrbot_path import get_astrbot_site_packages_path
|
||||
from astrbot.core.utils.runtime_env import is_packaged_electron_runtime
|
||||
|
||||
logger = logging.getLogger("astrbot")
|
||||
|
||||
|
||||
def _robust_decode(line: bytes) -> str:
|
||||
"""解码字节流,兼容不同平台的编码"""
|
||||
try:
|
||||
return line.decode("utf-8").strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
try:
|
||||
return line.decode(locale.getpreferredencoding(False)).strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
if sys.platform.startswith("win"):
|
||||
try:
|
||||
return line.decode("gbk").strip()
|
||||
except UnicodeDecodeError:
|
||||
pass
|
||||
return line.decode("utf-8", errors="replace").strip()
|
||||
|
||||
|
||||
def _is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def _get_pip_subprocess_executable() -> str | None:
|
||||
candidates = [
|
||||
getattr(sys, "_base_executable", None),
|
||||
sys.executable,
|
||||
shutil.which("python3"),
|
||||
shutil.which("python"),
|
||||
]
|
||||
|
||||
for candidate in candidates:
|
||||
if not candidate:
|
||||
continue
|
||||
|
||||
candidate_path = Path(candidate)
|
||||
with contextlib.suppress(OSError):
|
||||
candidate_path = candidate_path.resolve()
|
||||
|
||||
if candidate_path.is_file() and os.access(candidate_path, os.X_OK):
|
||||
return str(candidate_path)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_pip_main():
|
||||
try:
|
||||
from pip._internal.cli.main import main as pip_main
|
||||
except ImportError:
|
||||
from pip import main as pip_main
|
||||
try:
|
||||
from pip import main as pip_main
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"pip module is unavailable "
|
||||
f"(sys.executable={sys.executable}, "
|
||||
f"frozen={getattr(sys, 'frozen', False)}, "
|
||||
f"ASTRBOT_ELECTRON_CLIENT={os.environ.get('ASTRBOT_ELECTRON_CLIENT')})"
|
||||
) from exc
|
||||
|
||||
return pip_main
|
||||
|
||||
|
||||
@@ -102,11 +65,10 @@ class PipInstaller:
|
||||
args.extend(["-r", requirements_path])
|
||||
|
||||
index_url = mirror or self.pypi_index_url or "https://pypi.org/simple"
|
||||
|
||||
args.extend(["--trusted-host", "mirrors.aliyun.com", "-i", index_url])
|
||||
|
||||
target_site_packages = None
|
||||
if _is_frozen_runtime():
|
||||
if is_packaged_electron_runtime():
|
||||
target_site_packages = get_astrbot_site_packages_path()
|
||||
os.makedirs(target_site_packages, exist_ok=True)
|
||||
args.extend(["--target", target_site_packages])
|
||||
@@ -115,27 +77,7 @@ class PipInstaller:
|
||||
args.extend(self.pip_install_arg.split())
|
||||
|
||||
logger.info(f"Pip 包管理器: pip {' '.join(args)}")
|
||||
result_code = None
|
||||
|
||||
subprocess_executable = _get_pip_subprocess_executable()
|
||||
if subprocess_executable:
|
||||
try:
|
||||
result_code = await self._run_pip_subprocess(
|
||||
subprocess_executable, args
|
||||
)
|
||||
except OSError as exc:
|
||||
logger.warning(
|
||||
"Failed to launch pip subprocess (%r). Falling back to in-process pip: %s",
|
||||
subprocess_executable,
|
||||
exc,
|
||||
)
|
||||
else:
|
||||
logger.debug(
|
||||
"No suitable Python executable found for pip subprocess; using in-process pip"
|
||||
)
|
||||
|
||||
if result_code is None:
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
result_code = await self._run_pip_in_process(args)
|
||||
|
||||
if result_code != 0:
|
||||
raise Exception(f"安装失败,错误码:{result_code}")
|
||||
@@ -144,25 +86,9 @@ class PipInstaller:
|
||||
sys.path.insert(0, target_site_packages)
|
||||
importlib.invalidate_caches()
|
||||
|
||||
async def _run_pip_subprocess(self, executable: str, args: list[str]) -> int:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
executable,
|
||||
"-m",
|
||||
"pip",
|
||||
*args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.STDOUT,
|
||||
)
|
||||
|
||||
assert process.stdout is not None
|
||||
async for line in process.stdout:
|
||||
logger.info(_robust_decode(line))
|
||||
|
||||
await process.wait()
|
||||
return process.returncode
|
||||
|
||||
async def _run_pip_in_process(self, args: list[str]) -> int:
|
||||
pip_main = _get_pip_main()
|
||||
|
||||
original_handlers = list(logging.getLogger().handlers)
|
||||
result_code, output = await asyncio.to_thread(
|
||||
_run_pip_main_with_output, pip_main, args
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def is_frozen_runtime() -> bool:
|
||||
return bool(getattr(sys, "frozen", False))
|
||||
|
||||
|
||||
def is_packaged_electron_runtime() -> bool:
|
||||
return is_frozen_runtime() and os.environ.get("ASTRBOT_ELECTRON_CLIENT") == "1"
|
||||
@@ -45,7 +45,9 @@ let version = ref('');
|
||||
let releases = ref([]);
|
||||
let updatingDashboardLoading = ref(false);
|
||||
let installLoading = ref(false);
|
||||
const isElectronApp = ref(false);
|
||||
const isElectronApp = ref(
|
||||
typeof window !== 'undefined' && !!window.astrbotDesktop?.isElectron
|
||||
);
|
||||
const redirectConfirmDialog = ref(false);
|
||||
const pendingRedirectUrl = ref('');
|
||||
const resolvingReleaseTarget = ref(false);
|
||||
@@ -235,7 +237,9 @@ function checkUpdate() {
|
||||
} else {
|
||||
updateStatus.value = res.data.message;
|
||||
}
|
||||
dashboardHasNewVersion.value = res.data.data.dashboard_has_new_version;
|
||||
dashboardHasNewVersion.value = isElectronApp.value
|
||||
? false
|
||||
: res.data.data.dashboard_has_new_version;
|
||||
})
|
||||
.catch((err) => {
|
||||
if (err.response && err.response.status == 401) {
|
||||
@@ -381,7 +385,9 @@ onMounted(async () => {
|
||||
} catch {
|
||||
isElectronApp.value = false;
|
||||
}
|
||||
isElectronApp.value = true
|
||||
if (isElectronApp.value) {
|
||||
dashboardHasNewVersion.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -426,7 +432,7 @@ onMounted(async () => {
|
||||
<small v-if="hasNewVersion">
|
||||
{{ t('core.header.version.hasNewVersion') }}
|
||||
</small>
|
||||
<small v-else-if="dashboardHasNewVersion">
|
||||
<small v-else-if="dashboardHasNewVersion && !isElectronApp">
|
||||
{{ t('core.header.version.dashboardHasNewVersion') }}
|
||||
</small>
|
||||
</div>
|
||||
@@ -509,7 +515,7 @@ onMounted(async () => {
|
||||
<v-icon>mdi-arrow-up-circle</v-icon>
|
||||
</template>
|
||||
<v-list-item-title>{{ t('core.header.updateDialog.title') }}</v-list-item-title>
|
||||
<template v-slot:append v-if="hasNewVersion || dashboardHasNewVersion">
|
||||
<template v-slot:append v-if="hasNewVersion || (dashboardHasNewVersion && !isElectronApp)">
|
||||
<v-chip size="x-small" color="primary" variant="tonal" class="ml-2">!</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -255,6 +255,9 @@ class BackendManager {
|
||||
...process.env,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
};
|
||||
if (this.app.isPackaged) {
|
||||
env.ASTRBOT_ELECTRON_CLIENT = '1';
|
||||
}
|
||||
if (backendConfig.rootDir) {
|
||||
env.ASTRBOT_ROOT = backendConfig.rootDir;
|
||||
const logsDir = path.join(backendConfig.rootDir, 'logs');
|
||||
|
||||
Reference in New Issue
Block a user