//! 便携 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, 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 = 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( &self, app: &AppHandle, stage: &str, start_percent: u32, end_percent: u32, run: F, msg: &str, ) -> Result<(), String> where F: FnOnce(Arc) -> 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, ) -> 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::().chars().rev().collect(); Err(format!("子进程退出码 {}: {}", out.status.code().unwrap_or(-1), tail)) } } Err(e) => Err(format!("读取子进程输出失败: {}", e)), } }