use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result, bail};
use serde_json::{Value, json};
use uuid::Uuid;
use super::transcode;
const NAMESPACE: Uuid = Uuid::from_bytes([
0x70, 0x68, 0x6f, 0x6e, 0x74, 0x6f, 0x77, 0x70, 0x70, 0x72, 0x6f, 0x6a, 0x65, 0x63, 0x74, 0x21,
]);
const PHONTO_CATEGORY_ID: &str = "8C75F1C2-7E7E-4B5C-9C5C-50484F4E544F";
const PHONTO_SUBCATEGORY_ID: &str = "8C75F1C2-7E7E-4B5C-9C5C-535542434154";
pub fn run(video: PathBuf, name: Option<String>, remove: bool) -> Result<()> {
let video = if remove {
video
.canonicalize()
.or_else(|_| std::path::absolute(&video))
.with_context(|| format!("could not resolve path: {}", video.display()))?
} else {
video
.canonicalize()
.with_context(|| format!("video not found: {}", video.display()))?
};
let home = std::env::var("HOME").context("HOME not set")?;
let aerials =
PathBuf::from(&home).join("Library/Application Support/com.apple.wallpaper/aerials");
let videos_dir = aerials.join("videos");
let thumbnails_dir = aerials.join("thumbnails");
let manifest = aerials.join("manifest/entries.json");
if !manifest.exists() {
bail!(
"manifest not found at {} — WallpaperAgent has never initialised it on this Mac",
manifest.display()
);
}
let asset_id = Uuid::new_v5(&NAMESPACE, video.to_string_lossy().as_bytes())
.hyphenated()
.to_string()
.to_uppercase();
if remove {
remove_entry(&manifest, &asset_id)?;
let _ = fs::remove_file(videos_dir.join(format!("{asset_id}.mov")));
let _ = fs::remove_file(thumbnails_dir.join(format!("{asset_id}.png")));
kick_aerials();
println!("removed {asset_id}");
return Ok(());
}
fs::create_dir_all(&videos_dir)?;
fs::create_dir_all(&thumbnails_dir)?;
let display_name = name.unwrap_or_else(|| {
video
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| "Phonto Custom".to_string())
});
let target_video = videos_dir.join(format!("{asset_id}.mov"));
log::info!("transcoding → HEVC Main10 (2 temporal sub-layers)...");
transcode::transcode(&video, &target_video)?;
let target_thumb = thumbnails_dir.join(format!("{asset_id}.png"));
extract_thumbnail(&video, &target_thumb).context("extracting thumbnail (qlmanage)")?;
log::info!("thumbnail → {}", target_thumb.display());
upsert_entry(
&manifest,
&asset_id,
&display_name,
&target_video,
&target_thumb,
)?;
kick_aerials();
println!();
println!("Installed '{display_name}' (id {asset_id}).");
println!(
"Open System Settings → Wallpaper, pick the 'Phonto' section, click \
this entry, then lock the screen (Apple menu → Lock Screen) to verify \
multi-cycle playback."
);
Ok(())
}
fn extract_thumbnail(video: &Path, dest: &Path) -> Result<()> {
let tmp_dir = tempfile::Builder::new().prefix("phonto-thumb").tempdir()?;
let output = Command::new("/usr/bin/qlmanage")
.args(["-t", "-s", "640", "-o"])
.arg(tmp_dir.path())
.arg(video)
.output()
.context("running qlmanage")?;
log_subprocess_output("qlmanage", &output.stdout, &output.stderr);
if !output.status.success() {
bail!("qlmanage exited with {}", output.status);
}
let generated = tmp_dir.path().join(format!(
"{}.png",
video.file_name().unwrap().to_string_lossy()
));
if !generated.exists() {
bail!("qlmanage produced no thumbnail at {}", generated.display());
}
fs::copy(&generated, dest)?;
Ok(())
}
fn upsert_entry(
manifest: &Path,
asset_id: &str,
display_name: &str,
video_path: &Path,
thumb_path: &Path,
) -> Result<()> {
let backup = manifest.with_extension("json.phonto-backup");
if !backup.exists() {
fs::copy(manifest, &backup)?;
log::info!("backed up original manifest → {}", backup.display());
}
let text = fs::read_to_string(manifest)?;
let mut data: Value = serde_json::from_str(&text).context("parsing entries.json")?;
let video_url = file_url(video_path);
let preview_url = file_url(thumb_path);
upsert_phonto_category(&mut data, asset_id, &preview_url)?;
let entry = json!({
"id": asset_id,
"accessibilityLabel": display_name,
"categories": [PHONTO_CATEGORY_ID],
"subcategories": [PHONTO_SUBCATEGORY_ID],
"includeInShuffle": false,
"localizedNameKey": display_name,
"pointsOfInterest": {},
"preferredOrder": 0,
"previewImage": preview_url,
"shotID": format!("PHONTO_{}", &asset_id[..8]),
"showInTopLevel": true,
"url-4K-SDR-240FPS": video_url,
});
let assets = data
.get_mut("assets")
.and_then(Value::as_array_mut)
.context("entries.json has no `assets` array")?;
if let Some(existing) = assets
.iter_mut()
.find(|a| a.get("id").and_then(Value::as_str) == Some(asset_id))
{
*existing = entry;
log::debug!("updated existing entry in entries.json");
} else {
assets.insert(0, entry);
log::debug!("inserted new entry in entries.json");
}
fs::write(manifest, serde_json::to_string_pretty(&data)?)?;
Ok(())
}
fn upsert_phonto_category(data: &mut Value, rep_asset_id: &str, preview_url: &str) -> Result<()> {
let categories = data
.get_mut("categories")
.and_then(Value::as_array_mut)
.context("entries.json has no `categories` array")?;
if let Some(existing) = categories
.iter_mut()
.find(|c| c.get("id").and_then(Value::as_str) == Some(PHONTO_CATEGORY_ID))
{
existing["representativeAssetID"] = Value::String(rep_asset_id.into());
existing["previewImage"] = Value::String(preview_url.into());
existing["subcategories"] = json!([{
"id": PHONTO_SUBCATEGORY_ID,
"localizedDescriptionKey": "Phonto",
"localizedNameKey": "Phonto",
"preferredOrder": 0,
"previewImage": preview_url,
"representativeAssetID": rep_asset_id,
}]);
} else {
categories.push(json!({
"id": PHONTO_CATEGORY_ID,
"localizedDescriptionKey": "Phonto",
"localizedNameKey": "Phonto",
"preferredOrder": -1,
"previewImage": preview_url,
"representativeAssetID": rep_asset_id,
"subcategories": [{
"id": PHONTO_SUBCATEGORY_ID,
"localizedDescriptionKey": "Phonto",
"localizedNameKey": "Phonto",
"preferredOrder": 0,
"previewImage": preview_url,
"representativeAssetID": rep_asset_id,
}],
}));
log::debug!("registered Phonto category in entries.json");
}
Ok(())
}
fn file_url(path: &Path) -> String {
let mut out = String::from("file://");
for byte in path.to_string_lossy().bytes() {
if byte == b' ' {
out.push_str("%20");
} else {
out.push(byte as char);
}
}
out
}
fn remove_entry(manifest: &Path, asset_id: &str) -> Result<()> {
let text = fs::read_to_string(manifest)?;
let mut data: Value = serde_json::from_str(&text)?;
if let Some(assets) = data.get_mut("assets").and_then(Value::as_array_mut) {
assets.retain(|a| a.get("id").and_then(Value::as_str) != Some(asset_id));
}
repair_phonto_category(&mut data);
fs::write(manifest, serde_json::to_string_pretty(&data)?)?;
Ok(())
}
fn repair_phonto_category(data: &mut Value) {
let surviving = first_phonto_asset(data);
let Some(categories) = data.get_mut("categories").and_then(Value::as_array_mut) else {
return;
};
match surviving {
Some((rep_id, preview)) => {
if let Some(cat) = categories
.iter_mut()
.find(|c| c.get("id").and_then(Value::as_str) == Some(PHONTO_CATEGORY_ID))
{
cat["representativeAssetID"] = Value::String(rep_id.clone());
cat["previewImage"] = Value::String(preview.clone());
cat["subcategories"] = json!([{
"id": PHONTO_SUBCATEGORY_ID,
"localizedDescriptionKey": "Phonto",
"localizedNameKey": "Phonto",
"preferredOrder": 0,
"previewImage": preview,
"representativeAssetID": rep_id,
}]);
}
}
None => {
categories.retain(|c| c.get("id").and_then(Value::as_str) != Some(PHONTO_CATEGORY_ID));
}
}
}
fn first_phonto_asset(data: &Value) -> Option<(String, String)> {
let assets = data.get("assets")?.as_array()?;
assets.iter().find_map(|asset| {
let cats = asset.get("categories")?.as_array()?;
let references_phonto = cats.iter().any(|c| c.as_str() == Some(PHONTO_CATEGORY_ID));
if !references_phonto {
return None;
}
let id = asset.get("id")?.as_str()?.to_string();
let preview = asset.get("previewImage")?.as_str()?.to_string();
Some((id, preview))
})
}
fn kick_aerials() {
if let Ok(output) = Command::new("/usr/bin/killall")
.arg("WallpaperAerialsExtension")
.output()
{
log_subprocess_output("killall", &output.stdout, &output.stderr);
}
}
fn log_subprocess_output(tag: &str, stdout: &[u8], stderr: &[u8]) {
for line in String::from_utf8_lossy(stdout).lines() {
log::debug!("{tag}: {line}");
}
for line in String::from_utf8_lossy(stderr).lines() {
log::debug!("{tag}: {line}");
}
}