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:
エイカク
2026-02-10 00:12:18 +09:00
committed by GitHub
parent da4bb6549c
commit 1addd5b2ab
5 changed files with 42 additions and 93 deletions
+4
View File
@@ -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())
+14 -88
View File
@@ -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
+10
View File
@@ -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>
+3
View File
@@ -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');