use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use std::time::{Duration, SystemTime};
use anyhow::{Context, Result, bail};
use crate::config;
use crate::tools::{self, Tools};
const CODESIGN: &str = "/usr/bin/codesign";
const INFO_PLIST: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleIdentifier</key>
<string>io.github.erictran308.tuitube</string>
<key>CFBundleName</key>
<string>tuitube</string>
<key>CFBundleDisplayName</key>
<string>tuitube</string>
<key>CFBundleExecutable</key>
<string>mpv</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
</dict>
</plist>
"#;
const LEFT_FOR: Duration = Duration::from_secs(3600);
pub fn put_mpv_in(tools: &mut Tools, data_dir: &Path) -> Option<String> {
if !cfg!(target_os = "macos") {
return None;
}
let mpv = tools.mpv.clone()?;
match program(data_dir, &mpv) {
Ok(program) => {
tools.mpv = Some(program);
None
}
Err(e) => Some(crate::text::clean(&format!("{e:#}"))),
}
}
fn program(data_dir: &Path, mpv: &Path) -> Result<PathBuf> {
let dir = data_dir.join("player");
config::private_dir(&dir)?;
let key = key(mpv)?;
let ready = dir.join(&key);
let program = executable(&ready);
if tools::usable(&program) {
return Ok(program);
}
let building = dir.join(format!(".{key}.{}", std::process::id()));
let _ = std::fs::remove_dir_all(&building);
let made = build(&building, mpv).and_then(|()| {
let _ = std::fs::remove_dir_all(&ready);
std::fs::rename(&building, &ready)
.with_context(|| format!("cannot move it to {}", config::shown(&ready)))
});
if let Err(e) = made {
let _ = std::fs::remove_dir_all(&building);
return Err(e);
}
prune(&dir, &key);
Ok(program)
}
fn executable(dir: &Path) -> PathBuf {
dir.join("tuitube.app/Contents/MacOS/mpv")
}
fn key(mpv: &Path) -> Result<String> {
let real = std::fs::canonicalize(mpv)
.with_context(|| format!("cannot find {}", config::shown(mpv)))?;
let meta = std::fs::metadata(&real)
.with_context(|| format!("cannot read {}", config::shown(&real)))?;
let changed = meta
.modified()
.ok()
.and_then(|t| t.duration_since(SystemTime::UNIX_EPOCH).ok())
.unwrap_or_default();
let mut hash = ring::digest::Context::new(&ring::digest::SHA256);
hash.update(real.to_string_lossy().as_bytes());
hash.update(&meta.len().to_le_bytes());
hash.update(&changed.as_nanos().to_le_bytes());
hash.update(INFO_PLIST.as_bytes());
let digest = hash.finish();
Ok(digest.as_ref()[..8]
.iter()
.map(|b| format!("{b:02x}"))
.collect())
}
fn is_key(name: &str) -> bool {
name.len() == 16 && name.bytes().all(|b| b.is_ascii_hexdigit())
}
fn build(dir: &Path, mpv: &Path) -> Result<()> {
let app = dir.join("tuitube.app");
let program = executable(dir);
let folder = program.parent().context("no folder")?;
std::fs::create_dir_all(folder)
.with_context(|| format!("cannot create {}", config::shown(folder)))?;
std::fs::write(app.join("Contents/Info.plist"), INFO_PLIST)
.context("cannot write its Info.plist")?;
std::fs::copy(mpv, &program).with_context(|| format!("cannot copy {}", config::shown(mpv)))?;
let mut sign = Command::new(CODESIGN);
sign.args(["--force", "--sign", "-", "--"]).arg(&app);
run(&mut sign).context("codesign failed")?;
run(Command::new(&program).args(["--no-config", "--version"]))
.context("the copy of mpv doesn't run")?;
Ok(())
}
fn run(command: &mut Command) -> Result<()> {
let out = command
.env_clear()
.stdin(Stdio::null())
.output()
.context("cannot start it")?;
if !out.status.success() {
let said = String::from_utf8_lossy(&out.stderr);
bail!("{}", crate::text::clean(said.lines().next().unwrap_or("")));
}
Ok(())
}
fn prune(dir: &Path, keep: &str) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
let Ok(meta) = entry.path().symlink_metadata() else {
continue;
};
let old = meta
.modified()
.ok()
.and_then(|t| t.elapsed().ok())
.is_some_and(|age| age > LEFT_FOR);
let other = is_key(name) && name != keep;
let left =
name.starts_with('.') && is_key(name[1..].split('.').next().unwrap_or("")) && old;
if meta.is_dir() && (other || left) {
let _ = std::fs::remove_dir_all(entry.path());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const ID: &str = "io.github.erictran308.tuitube";
#[test]
fn the_description_names_the_bundle() {
assert!(INFO_PLIST.contains(&format!("<string>{ID}</string>")));
assert!(INFO_PLIST.contains("<key>CFBundleExecutable</key>\n\t<string>mpv</string>"));
assert!(is_key("0123456789abcdef"));
assert!(!is_key("0123456789abcde"));
assert!(!is_key("0123456789abcdeg"));
}
#[test]
fn elsewhere_mpv_runs_as_itself() {
if cfg!(target_os = "macos") {
return;
}
let mpv = PathBuf::from("/usr/bin/mpv");
let mut tools = Tools {
mpv: Some(mpv.clone()),
..Tools::default()
};
assert_eq!(put_mpv_in(&mut tools, Path::new("/nowhere")), None);
assert_eq!(tools.mpv, Some(mpv));
}
#[cfg(target_os = "macos")]
fn data_dir(name: &str) -> PathBuf {
let dir =
std::env::temp_dir().join(format!("tuitube-bundle-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
config::private_dir(&dir).unwrap();
dir
}
#[cfg(target_os = "macos")]
fn fake_mpv(dir: &Path, works: bool) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("mpv");
let status = if works { 0 } else { 3 };
std::fs::write(&path, format!("#!/bin/sh\necho 'mpv v0'\nexit {status}\n")).unwrap();
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
path
}
#[cfg(target_os = "macos")]
fn signed_as(app: &Path) -> String {
let out = Command::new(CODESIGN).arg("-dv").arg(app).output().unwrap();
let said = String::from_utf8_lossy(&out.stderr).into_owned();
said.lines()
.find_map(|l| l.strip_prefix("Identifier="))
.unwrap_or_default()
.to_string()
}
#[test]
#[cfg(target_os = "macos")]
fn mpv_runs_from_a_signed_bundle_made_again_when_mpv_changes() {
let data = data_dir("made");
let source = data_dir("source");
let mpv = fake_mpv(&source, true);
let mut tools = Tools {
mpv: Some(mpv.clone()),
..Tools::default()
};
assert_eq!(put_mpv_in(&mut tools, &data), None);
let program = tools.mpv.clone().unwrap();
assert!(program.starts_with(data.join("player")));
assert!(program.ends_with("tuitube.app/Contents/MacOS/mpv"));
let app = program.ancestors().nth(3).unwrap().to_path_buf();
assert_eq!(signed_as(&app), ID);
let made = std::fs::metadata(&program).unwrap().modified().unwrap();
assert_eq!(program, super::program(&data, &mpv).unwrap());
assert_eq!(
std::fs::metadata(&program).unwrap().modified().unwrap(),
made
);
std::fs::write(&mpv, "#!/bin/sh\necho 'mpv v1'\n").unwrap();
let newer = super::program(&data, &mpv).unwrap();
assert_ne!(newer, program);
assert!(newer.exists() && !program.exists());
let left: Vec<_> = std::fs::read_dir(data.join("player")).unwrap().collect();
assert_eq!(left.len(), 1, "only the bundle in use");
let _ = std::fs::remove_dir_all(&data);
let _ = std::fs::remove_dir_all(&source);
}
#[test]
#[cfg(target_os = "macos")]
fn an_mpv_whose_copy_doesnt_run_runs_as_itself() {
let data = data_dir("broken");
let source = data_dir("broken-source");
let mpv = fake_mpv(&source, false);
let mut tools = Tools {
mpv: Some(mpv.clone()),
..Tools::default()
};
let why = put_mpv_in(&mut tools, &data).expect("a reason");
assert!(why.contains("doesn't run"), "{why}");
assert_eq!(tools.mpv, Some(mpv));
let left: Vec<_> = std::fs::read_dir(data.join("player")).unwrap().collect();
assert!(left.is_empty(), "nothing half-made kept");
let _ = std::fs::remove_dir_all(&data);
let _ = std::fs::remove_dir_all(&source);
}
}