Files
Thing/src-tauri/src/music/runtime.rs
T
2026-09-12 11:05:26 +08:00

399 lines
16 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 便携 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();
// 安装 setuptoolsmusicdl 的 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.pathpip 默认安装位置)
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)),
}
}