main
anastasia Premiere Pro Bridge 0.1.0: Rust bridge, UXP plugin, CEP helper, skills, macOS manifest 32fb583 10d ago
//! Installing the two in-app halves. Both are compiled INTO this binary (include_str!),
//! so the Release zip stays a single executable + bridge.json and a bridge update always
//! carries the matching plugin.
//!
//!   * UXP plugin  → packaged as a .ccx and installed with Adobe's own
//!                   UnifiedPluginInstallerAgent (UPIA) — no dialogs, no Developer Tool.
//!   * CEP helper  → copied into the per-user CEP extensions folder; unsigned CEP needs
//!                   PlayerDebugMode=1 for the CSXS versions Premiere uses.

use crate::Shared;
use serde_json::{json, Value};
use std::{
    io::Write,
    path::{Path, PathBuf},
};
use tokio::process::Command;

pub const BUNDLED_VERSION: &str = "0.1.0";
const UXP_ID: &str = "inc.adom.premiere.bridge";
const UXP_NAME: &str = "Adom Bridge";
const CEP_ID: &str = "inc.adom.premiere.cep";

const UXP_FILES: &[(&str, &str)] = &[
    ("manifest.json", include_str!("../plugin/uxp/manifest.json")),
    ("index.html", include_str!("../plugin/uxp/index.html")),
    ("index.js", include_str!("../plugin/uxp/index.js")),
];

const UXP_ICONS: &[(&str, &[u8])] = &[
    ("icons/[email protected]", include_bytes!("../plugin/uxp/icons/[email protected]")),
    ("icons/[email protected]", include_bytes!("../plugin/uxp/icons/[email protected]")),
    ("icons/[email protected]", include_bytes!("../plugin/uxp/icons/[email protected]")),
    ("icons/[email protected]", include_bytes!("../plugin/uxp/icons/[email protected]")),
];

const CEP_FILES: &[(&str, &str)] = &[
    ("CSXS/manifest.xml", include_str!("../plugin/cep/CSXS/manifest.xml")),
    ("index.html", include_str!("../plugin/cep/index.html")),
    ("main.js", include_str!("../plugin/cep/main.js")),
    ("host.jsx", include_str!("../plugin/cep/host.jsx")),
];

fn record_path(st: &Shared) -> PathBuf {
    st.data_dir.join("plugin-installed.json")
}

pub fn installed_version() -> Option<String> {
    let p = crate::host::data_dir().join("plugin-installed.json");
    let v: Value = serde_json::from_slice(&std::fs::read(p).ok()?).ok()?;
    v["version"].as_str().map(str::to_string)
}

fn upia_path() -> Option<PathBuf> {
    let p = if cfg!(target_os = "macos") {
        PathBuf::from("/Library/Application Support/Adobe/Adobe Desktop Common/RemoteComponents/UPI/UnifiedPluginInstallerAgent/UnifiedPluginInstallerAgent.app/Contents/MacOS/UnifiedPluginInstallerAgent")
    } else {
        let cpf = std::env::var("CommonProgramFiles").unwrap_or_else(|_| r"C:\Program Files\Common Files".into());
        PathBuf::from(cpf).join(r"Adobe\Adobe Desktop Common\RemoteComponents\UPI\UnifiedPluginInstallerAgent\UnifiedPluginInstallerAgent.exe")
    };
    p.exists().then_some(p)
}

fn cep_dir() -> PathBuf {
    if cfg!(windows) {
        let appdata = std::env::var("APPDATA").map(PathBuf::from).unwrap_or_else(|_| crate::host::home_dir().join("AppData/Roaming"));
        appdata.join(r"Adobe\CEP\extensions").join(CEP_ID)
    } else {
        crate::host::home_dir().join("Library/Application Support/Adobe/CEP/extensions").join(CEP_ID)
    }
}

/// Build the .ccx (a zip with manifest.json at the root). A copy of the connection file
/// rides inside so the plugin can find us even if it can't read ~/.adom.
fn build_ccx(st: &Shared) -> std::io::Result<PathBuf> {
    let out = st.data_dir.join(format!("{UXP_ID}.ccx"));
    let f = std::fs::File::create(&out)?;
    let mut z = zip::ZipWriter::new(f);
    let opts: zip::write::SimpleFileOptions = zip::write::SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
    for (name, body) in UXP_FILES {
        z.start_file(*name, opts).map_err(std::io::Error::other)?;
        z.write_all(body.as_bytes())?;
    }
    for (name, body) in UXP_ICONS {
        z.start_file(*name, opts).map_err(std::io::Error::other)?;
        z.write_all(body)?;
    }
    let conn = std::fs::read(st.data_dir.join("connection.json")).unwrap_or_default();
    z.start_file("connection.json", opts).map_err(std::io::Error::other)?;
    z.write_all(&conn)?;
    z.finish().map_err(std::io::Error::other)?;
    Ok(out)
}

async fn run(cmd: &Path, args: &[&str]) -> (bool, String) {
    match Command::new(cmd).args(args).output().await {
        Ok(o) => {
            let mut s = String::from_utf8_lossy(&o.stdout).into_owned();
            s.push_str(&String::from_utf8_lossy(&o.stderr));
            (o.status.success(), s.trim().to_string())
        }
        Err(e) => (false, e.to_string()),
    }
}

async fn install_uxp(st: &Shared, force: bool) -> Value {
    let Some(upia) = upia_path() else {
        return json!({"ok": false, "errorCode": "upia_missing",
            "error": "Adobe's UnifiedPluginInstallerAgent wasn't found (it ships with the Creative Cloud desktop app)."});
    };
    let ccx = match build_ccx(st) {
        Ok(p) => p,
        Err(e) => return json!({"ok": false, "errorCode": "ccx_build_failed", "error": e.to_string()}),
    };
    let flag = |mac: &'static str, win: &'static str| if cfg!(windows) { win } else { mac };
    let mut log = Vec::new();
    if force {
        let (_, out) = run(&upia, &[flag("--remove", "/remove"), UXP_NAME]).await;
        log.push(json!({"step": "remove", "output": out}));
    }
    let ccx_s = ccx.to_string_lossy().into_owned();
    let (ok, out) = run(&upia, &[flag("--install", "/install"), &ccx_s]).await;
    log.push(json!({"step": "install", "ok": ok, "output": out}));
    // UPIA's exit code is unreliable across versions; its text is the real signal.
    let lower = out.to_lowercase();
    let success = ok && !lower.contains("fail") && !lower.contains("error") || lower.contains("successfully installed") || lower.contains("installation successful");
    json!({"ok": success, "ccx": ccx, "upia": upia, "log": log})
}

async fn install_cep() -> Value {
    let dir = cep_dir();
    for (name, body) in CEP_FILES {
        let p = dir.join(name);
        if let Some(parent) = p.parent() {
            if let Err(e) = std::fs::create_dir_all(parent) {
                return json!({"ok": false, "errorCode": "cep_copy_failed", "error": e.to_string()});
            }
        }
        if let Err(e) = std::fs::write(&p, body) {
            return json!({"ok": false, "errorCode": "cep_copy_failed", "error": e.to_string()});
        }
    }
    // Unsigned CEP extensions only load with PlayerDebugMode=1. Set it for every CSXS
    // generation Premiere 2022–2027 might use; it's per-user and harmless for others.
    let mut debug = Vec::new();
    for n in 9..=13 {
        let ok = if cfg!(windows) {
            let key = format!(r"HKCU\Software\Adobe\CSXS.{n}");
            Command::new("reg").args(["add", &key, "/v", "PlayerDebugMode", "/t", "REG_SZ", "/d", "1", "/f"]).output().await.map(|o| o.status.success()).unwrap_or(false)
        } else {
            Command::new("defaults").args(["write", &format!("com.adobe.CSXS.{n}"), "PlayerDebugMode", "1"]).output().await.map(|o| o.status.success()).unwrap_or(false)
        };
        debug.push(json!({"csxs": n, "ok": ok}));
    }
    json!({"ok": true, "dir": dir, "playerDebugMode": debug})
}

pub async fn install(st: &Shared, args: &Value) -> Value {
    let force = args["force"].as_bool().unwrap_or(false);
    let uxp = install_uxp(st, force).await;
    let cep = if args["cep"].as_bool().unwrap_or(true) { install_cep().await } else { json!({"ok": false, "skipped": true}) };
    let uxp_ok = uxp["ok"].as_bool().unwrap_or(false);
    if uxp_ok {
        let _ = std::fs::write(
            record_path(st),
            serde_json::to_vec_pretty(&json!({"version": BUNDLED_VERSION, "uxp": uxp["ccx"], "cep": cep["dir"]})).unwrap_or_default(),
        );
    }
    let running = crate::host::premiere_running().await;
    let mut out = json!({
        "success": uxp_ok,
        "version": BUNDLED_VERSION,
        "uxp": uxp,
        "cep": cep,
        "premiereRunning": running,
    });
    if uxp_ok {
        out["_hint"] = json!(if running {
            "Installed. Premiere is running, so it hasn't loaded the plugin yet: premiere_quit {save:true} then premiere_launch. After launch, if pluginConnected stays false, the Adom Bridge panel needs opening once (Window > UXP Plugins > Adom Bridge) — the CEP helper connects on its own meanwhile."
        } else {
            "Installed. premiere_launch next; the plugin connects on startup."
        });
        out["_next"] = json!(if running { json!(["premiere_quit", "premiere_launch"]) } else { json!(["premiere_launch"]) });
    } else {
        out["errorCode"] = uxp["errorCode"].clone();
        out["error"] = json!("UXP plugin install failed — see uxp.log for the installer's output.");
        out["_hint"] = json!(format!(
            "If UPIA refused the unsigned .ccx, the package is at {} — opening it with the Creative Cloud app installs it (one confirmation click). Retry with force:true if an older copy is in the way.",
            uxp["ccx"].as_str().unwrap_or("?")
        ));
    }
    out
}

pub async fn info(st: &Shared) -> Value {
    json!({
        "success": true,
        "bundledVersion": BUNDLED_VERSION,
        "installedVersion": installed_version(),
        "uxpPluginId": UXP_ID,
        "upia": upia_path(),
        "cepDir": cep_dir(),
        "cepInstalled": cep_dir().join("CSXS/manifest.xml").exists(),
        "ccx": st.data_dir.join(format!("{UXP_ID}.ccx")),
    })
}