399 lines
16 KiB
Rust
399 lines
16 KiB
Rust
//! 便携 Python 运行时安装:流式下载 → 解压 → _pth 补丁 → get-pip 引导 → musicdl 安装。
|
||
//! 子模块通过 `impl super::MusicManager` 追加方法。
|
||
//!
|
||
//! 依赖下载源(已验证):
|
||
//! - https://www.python.org/ftp/python/{ver}/python-{ver}-embed-amd64.zip
|
||
//! - https://bootstrap.pypa.io/get-pip.py
|
||
//!
|
||
//! 取消:`MusicManager::cancel()` 置标志 + taskkill 当前 pip/python 子进程。
|
||
|
||
use std::fs;
|
||
use std::io::{Read, Write};
|
||
use std::path::{Path, PathBuf};
|
||
use std::sync::Arc;
|
||
|
||
use futures_util::StreamExt;
|
||
use tauri::{AppHandle, Emitter};
|
||
|
||
use super::{MusicInstallProgress, MusicManager, PIP_INDEX_URL};
|
||
use crate::constants::events::MUSIC_RUNTIME_INSTALL_PROGRESS;
|
||
|
||
/// 用户主动取消安装的标记错误信息(前端据此静默处理)
|
||
pub(crate) const RUNTIME_CANCELLED: &str = "安装已取消";
|
||
|
||
impl MusicManager {
|
||
/// 安装便携 Python + musicdl(幂等:已就绪的步骤自动跳过),全程推送进度事件。
|
||
pub async fn install_runtime(&self, app: &AppHandle) -> Result<(), String> {
|
||
self.runtime_cancel.store(false, std::sync::atomic::Ordering::SeqCst);
|
||
let result = self.install_runtime_inner(app).await;
|
||
if let Err(ref e) = result {
|
||
if e != RUNTIME_CANCELLED {
|
||
let _ = app.emit(
|
||
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||
MusicInstallProgress {
|
||
stage: "error".into(),
|
||
percent: 0,
|
||
downloaded_bytes: 0,
|
||
total_bytes: None,
|
||
message: e.clone(),
|
||
},
|
||
);
|
||
}
|
||
}
|
||
result
|
||
}
|
||
|
||
async fn install_runtime_inner(&self, app: &AppHandle) -> Result<(), String> {
|
||
let runtime_dir = self.runtime_dir();
|
||
fs::create_dir_all(&runtime_dir).map_err(|e| e.to_string())?;
|
||
|
||
let python_exe = self.bundled_python_exe();
|
||
|
||
// ---------- 1. 便携 Python 已就绪则跳过下载/解压 ----------
|
||
if !(python_exe.exists() && super::run_python_version(&python_exe).is_some()) {
|
||
self.emit_progress(app, "download", 2, 0, None, "开始下载便携 Python...").await;
|
||
|
||
// 下载 embeddable zip(~11MB,流式写入)
|
||
let zip_path = runtime_dir.join(format!("python-{}-embed.zip", super::BUNDLED_PY_VERSION));
|
||
let zip_url = format!(
|
||
"https://www.python.org/ftp/python/{}/python-{}-embed-amd64.zip",
|
||
super::BUNDLED_PY_VERSION,
|
||
super::BUNDLED_PY_VERSION
|
||
);
|
||
self.download_stream(app, &zip_url, &zip_path).await?;
|
||
|
||
// 解压到 runtime/python/
|
||
self.emit_progress(app, "extract", 42, 0, None, "正在解压便携 Python...").await;
|
||
let python_dir = runtime_dir.join("python");
|
||
if python_dir.exists() {
|
||
fs::remove_dir_all(&python_dir).ok();
|
||
}
|
||
fs::create_dir_all(&python_dir).map_err(|e| e.to_string())?;
|
||
self.extract_zip(&zip_path, &python_dir)?;
|
||
fs::remove_file(&zip_path).ok();
|
||
|
||
// _pth 补丁:启用 site(否则无法识别 site-packages 与 pip)
|
||
self.emit_progress(app, "patch", 52, 0, None, "正在配置 Python 环境...").await;
|
||
self.patch_pth(&python_dir)?;
|
||
|
||
// 下载 get-pip.py 并引导 pip(只装 pip 本体,走官方源,包很小)
|
||
self.emit_progress(app, "pip", 54, 0, None, "正在引导 pip...").await;
|
||
let get_pip = runtime_dir.join("get-pip.py");
|
||
self.download_bytes(&"https://bootstrap.pypa.io/get-pip.py".to_string(), &get_pip)
|
||
.await?;
|
||
let pip_py = python_exe.clone();
|
||
let pip_py2 = pip_py.clone(); // 供 setuptools 步骤闭包使用(先于 move 克隆)
|
||
let get_pip2 = get_pip.clone();
|
||
self.run_blocking_step(
|
||
app,
|
||
"pip",
|
||
55,
|
||
60,
|
||
move |pid| {
|
||
let mut cmd = std::process::Command::new(&pip_py);
|
||
cmd.arg(&get_pip2).args(["--no-warn-script-location"]);
|
||
run_cmd_blocking(cmd, pid)
|
||
},
|
||
"正在安装 pip...",
|
||
)
|
||
.await?;
|
||
fs::remove_file(&get_pip).ok();
|
||
|
||
// 安装 setuptools(musicdl 的 setup.py 构建依赖 setuptools.build_meta,
|
||
// 必须先于 musicdl 就位,否则构建阶段报 BackendUnavailable)
|
||
self.run_blocking_step(
|
||
app,
|
||
"pip",
|
||
62,
|
||
66,
|
||
move |pid| {
|
||
let mut cmd = std::process::Command::new(&pip_py2);
|
||
cmd.args([
|
||
"-m", "pip", "install", "--no-warn-script-location",
|
||
"--timeout", "60",
|
||
"--index-url", PIP_INDEX_URL,
|
||
"setuptools",
|
||
]);
|
||
run_cmd_blocking(cmd, pid)
|
||
},
|
||
"正在安装 setuptools...",
|
||
)
|
||
.await?;
|
||
} else {
|
||
self.emit_progress(app, "check", 2, 0, None, "便携 Python 已就绪").await;
|
||
}
|
||
|
||
// ---------- 2. 安装 musicdl(幂等:已安装则跳过) ----------
|
||
if !self.musicdl_ready(&python_exe).await {
|
||
let pip_py = python_exe.clone();
|
||
self.run_blocking_step(
|
||
app,
|
||
"musicdl",
|
||
68,
|
||
95,
|
||
move |pid| {
|
||
let mut cmd = std::process::Command::new(&pip_py);
|
||
cmd.args([
|
||
"-m", "pip", "install", "--no-warn-script-location",
|
||
"--timeout", "60",
|
||
"--index-url", PIP_INDEX_URL,
|
||
&format!("musicdl=={}", super::MUSICDL_VERSION),
|
||
]);
|
||
run_cmd_blocking(cmd, pid)
|
||
},
|
||
"正在安装 musicdl(下载依赖较多,可能需几分钟)...",
|
||
)
|
||
.await?;
|
||
}
|
||
|
||
self.emit_progress(app, "done", 100, 0, None, "环境就绪").await;
|
||
crate::logger::log_info("music", "便携 Python + musicdl 安装完成");
|
||
Ok(())
|
||
}
|
||
|
||
/// 检查便携 Python 能否导入 musicdl
|
||
async fn musicdl_ready(&self, python_exe: &PathBuf) -> bool {
|
||
let exe = python_exe.clone();
|
||
let (ok, _) = super::check_musicdl(&exe);
|
||
ok
|
||
}
|
||
|
||
// ---------- 阶段工具 ----------
|
||
async fn emit_progress(
|
||
&self,
|
||
app: &AppHandle,
|
||
stage: &str,
|
||
percent: u32,
|
||
downloaded_bytes: u64,
|
||
total_bytes: Option<u64>,
|
||
message: &str,
|
||
) {
|
||
let _ = app.emit(
|
||
MUSIC_RUNTIME_INSTALL_PROGRESS,
|
||
MusicInstallProgress {
|
||
stage: stage.into(),
|
||
percent,
|
||
downloaded_bytes,
|
||
total_bytes,
|
||
message: message.into(),
|
||
},
|
||
);
|
||
}
|
||
|
||
/// 流式下载(带取消 + 进度事件,percent 0-40 区间),复用内核下载模式
|
||
async fn download_stream(
|
||
&self,
|
||
app: &AppHandle,
|
||
url: &str,
|
||
dest: &Path,
|
||
) -> Result<(), String> {
|
||
let resp = self
|
||
.client
|
||
.get(url)
|
||
.timeout(std::time::Duration::from_secs(300))
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||
let status = resp.status();
|
||
if !status.is_success() {
|
||
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||
}
|
||
let total: Option<u64> = resp.content_length();
|
||
let mut file = fs::File::create(dest).map_err(|e| format!("创建文件失败: {}", e))?;
|
||
let mut stream = resp.bytes_stream();
|
||
let mut downloaded: u64 = 0;
|
||
let mut last_percent: u32 = 0;
|
||
while let Some(chunk) = stream.next().await {
|
||
if self.is_cancelled() {
|
||
drop(file);
|
||
fs::remove_file(dest).ok();
|
||
return Err(RUNTIME_CANCELLED.to_string());
|
||
}
|
||
let chunk = chunk.map_err(|e| format!("下载中断: {}", e))?;
|
||
file.write_all(&chunk).map_err(|e| format!("写入文件失败: {}", e))?;
|
||
downloaded += chunk.len() as u64;
|
||
let percent = total
|
||
.filter(|t| *t > 0)
|
||
.map(|t| ((downloaded as f64 / t as f64) * 38.0) as u32)
|
||
.unwrap_or(0)
|
||
.min(38);
|
||
if percent >= last_percent + 1 {
|
||
last_percent = percent;
|
||
self.emit_progress(
|
||
app,
|
||
"download",
|
||
percent,
|
||
downloaded,
|
||
total,
|
||
&format!("正在下载便携 Python ({:.1} MB)", downloaded as f64 / 1048576.0),
|
||
)
|
||
.await;
|
||
}
|
||
}
|
||
file.flush().ok();
|
||
self.emit_progress(app, "download", 40, downloaded, total, "下载完成").await;
|
||
Ok(())
|
||
}
|
||
|
||
/// 小文件整体下载(get-pip.py),无进度
|
||
async fn download_bytes(&self, url: &str, dest: &Path) -> Result<(), String> {
|
||
let resp = self
|
||
.client
|
||
.get(url)
|
||
.timeout(std::time::Duration::from_secs(120))
|
||
.send()
|
||
.await
|
||
.map_err(|e| format!("请求下载失败: {}", e))?;
|
||
let status = resp.status();
|
||
if !status.is_success() {
|
||
return Err(format!("下载返回 HTTP {}", status.as_u16()));
|
||
}
|
||
let bytes = resp.bytes().await.map_err(|e| format!("读取响应失败: {}", e))?;
|
||
fs::write(dest, &bytes).map_err(|e| format!("写入文件失败: {}", e))?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 解压 zip(复用内核解压逻辑)
|
||
fn extract_zip(&self, zip_path: &Path, dest: &Path) -> Result<(), String> {
|
||
let file = fs::File::open(zip_path).map_err(|e| format!("打开 zip 失败: {}", e))?;
|
||
let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("读取 zip 失败: {}", e))?;
|
||
for i in 0..archive.len() {
|
||
let mut entry = archive
|
||
.by_index(i)
|
||
.map_err(|e| format!("读取条目失败: {}", e))?;
|
||
let outpath = match entry.enclosed_name() {
|
||
Some(p) => dest.join(p),
|
||
None => continue,
|
||
};
|
||
if entry.is_dir() {
|
||
fs::create_dir_all(&outpath).map_err(|e| e.to_string())?;
|
||
} else {
|
||
if let Some(parent) = outpath.parent() {
|
||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||
}
|
||
let mut outfile = fs::File::create(&outpath).map_err(|e| e.to_string())?;
|
||
let mut buf = [0u8; 8192];
|
||
loop {
|
||
let n = entry.read(&mut buf).map_err(|e| e.to_string())?;
|
||
if n == 0 {
|
||
break;
|
||
}
|
||
outfile.write_all(&buf[..n]).map_err(|e| e.to_string())?;
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// 修改 pythonXY._pth:启用 `import site`(embeddable 默认注释掉,
|
||
/// 不启用则无法识别 site-packages / pip 安装的包)
|
||
fn patch_pth(&self, python_dir: &Path) -> Result<(), String> {
|
||
let entries = fs::read_dir(python_dir).map_err(|e| e.to_string())?;
|
||
let pth = entries
|
||
.filter_map(|e| e.ok())
|
||
.map(|e| e.path())
|
||
.find(|p| {
|
||
p.extension()
|
||
.and_then(|e| e.to_str())
|
||
.map(|e| e.eq_ignore_ascii_case("_pth"))
|
||
.unwrap_or(false)
|
||
})
|
||
.ok_or_else(|| "解压目录中未找到 ._pth 文件".to_string())?;
|
||
|
||
let content = fs::read_to_string(&pth).map_err(|e| e.to_string())?;
|
||
let mut patched = content.replace("#import site", "import site");
|
||
if !patched.contains("import site") {
|
||
patched.push_str("import site\n");
|
||
}
|
||
// 显式把 site-packages 加入 sys.path(pip 默认安装位置)
|
||
if !patched.contains("Lib\\site-packages") && !patched.contains("Lib/site-packages") {
|
||
patched.push_str("Lib\\site-packages\n");
|
||
}
|
||
fs::write(&pth, patched).map_err(|e| format!("写入 _pth 失败: {}", e))?;
|
||
crate::logger::log_info("music", &format!("已补丁 _pth: {}", pth.display()));
|
||
Ok(())
|
||
}
|
||
|
||
/// 执行一个阻塞子进程步骤(get-pip / setuptools / musicdl),带进度推送、取消检查与超时看门狗。
|
||
/// start_percent / end_percent:本步骤的进度区间(完成前推进到 end_percent)。
|
||
/// run 闭包接收「子进程 pid 记录器」,供取消时 taskkill。
|
||
async fn run_blocking_step<F>(
|
||
&self,
|
||
app: &AppHandle,
|
||
stage: &str,
|
||
start_percent: u32,
|
||
end_percent: u32,
|
||
run: F,
|
||
msg: &str,
|
||
) -> Result<(), String>
|
||
where
|
||
F: FnOnce(Arc<std::sync::atomic::AtomicU64>) -> Result<(), String> + Send + 'static,
|
||
{
|
||
if self.is_cancelled() {
|
||
return Err(RUNTIME_CANCELLED.to_string());
|
||
}
|
||
let pid_ref = self.install_pid.clone();
|
||
self.emit_progress(app, stage, start_percent, 0, None, msg).await;
|
||
let result = tauri::async_runtime::spawn_blocking(move || run(pid_ref.clone()))
|
||
.await
|
||
.map_err(|e| format!("任务执行失败: {}", e))?;
|
||
self.install_pid.store(0, std::sync::atomic::Ordering::SeqCst);
|
||
if self.is_cancelled() {
|
||
return Err(RUNTIME_CANCELLED.to_string());
|
||
}
|
||
result?;
|
||
self.emit_progress(app, stage, end_percent, 0, None, "完成").await;
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// 同步运行子进程,带超时看门狗(超时 taskkill),并记录 pid 供取消。
|
||
/// 输出(stdout+stderr 尾部)写入日志;失败返回错误信息。
|
||
fn run_cmd_blocking(
|
||
mut cmd: std::process::Command,
|
||
pid_ref: Arc<std::sync::atomic::AtomicU64>,
|
||
) -> Result<(), String> {
|
||
cmd.stdout(std::process::Stdio::piped())
|
||
.stderr(std::process::Stdio::piped())
|
||
.stdin(std::process::Stdio::null());
|
||
crate::process_manager::setup_creation_flags(&mut cmd);
|
||
|
||
let child = cmd.spawn().map_err(|e| format!("启动子进程失败: {}", e))?;
|
||
pid_ref.store(child.id() as u64, std::sync::atomic::Ordering::SeqCst);
|
||
|
||
// 超时看门狗:30 分钟后仍未结束则强杀(慢网络下 pip 装 musicdl 依赖可能超过 10 分钟)
|
||
let pid = child.id();
|
||
let (tx, rx) = std::sync::mpsc::channel::<()>();
|
||
let watcher = std::thread::spawn(move || {
|
||
if rx.recv_timeout(std::time::Duration::from_secs(1800)).is_err() {
|
||
let _ = std::process::Command::new("taskkill")
|
||
.args(["/F", "/T", "/PID", &pid.to_string()])
|
||
.stdout(std::process::Stdio::null())
|
||
.stderr(std::process::Stdio::null())
|
||
.status();
|
||
}
|
||
});
|
||
|
||
let output = child.wait_with_output();
|
||
let _ = tx.send(());
|
||
let _ = watcher.join();
|
||
|
||
match output {
|
||
Ok(out) => {
|
||
let text = format!(
|
||
"{}{}",
|
||
String::from_utf8_lossy(&out.stdout),
|
||
String::from_utf8_lossy(&out.stderr)
|
||
);
|
||
if out.status.success() {
|
||
Ok(())
|
||
} else {
|
||
// 截取尾部 800 字符,便于定位 pip 报错
|
||
let tail: String = text.chars().rev().take(800).collect::<String>().chars().rev().collect();
|
||
Err(format!("子进程退出码 {}: {}", out.status.code().unwrap_or(-1), tail))
|
||
}
|
||
}
|
||
Err(e) => Err(format!("读取子进程输出失败: {}", e)),
|
||
}
|
||
}
|