paneru 0.5.2

A sliding, tiling window manager for MacOS.
use std::{
    env, fs,
    io::{Error, ErrorKind, Result},
    os::unix::fs::PermissionsExt,
    path::{Path, PathBuf},
    process::Command,
};

use tracing::info;

use crate::util::exe_path;

const APP_BUNDLE_NAME: &str = "Paneru.app";
const APP_EXECUTABLE_NAME: &str = "PaneruLauncher";
const APP_BUNDLE_ID: &str = "com.github.karinushka.paneru.launcher";

pub struct AppLauncher {
    paneru_path: PathBuf,
    app_path: PathBuf,
}

impl AppLauncher {
    pub fn try_new() -> Result<Self> {
        let home_dir = env::home_dir().ok_or(Error::new(
            ErrorKind::NotFound,
            "Cannot find home directory.",
        ))?;
        let paneru_path = exe_path().ok_or(Error::new(
            ErrorKind::NotFound,
            "Cannot find current executable path.",
        ))?;
        Ok(Self::new(
            paneru_path,
            home_dir.join("Applications").join(APP_BUNDLE_NAME),
        ))
    }

    fn new(paneru_path: PathBuf, app_path: PathBuf) -> Self {
        Self {
            paneru_path,
            app_path,
        }
    }

    pub fn install(&self) -> Result<()> {
        let parent = self.app_path.parent().ok_or(Error::new(
            ErrorKind::InvalidInput,
            "Application bundle path has no parent",
        ))?;
        fs::create_dir_all(parent)?;

        let staging_path = parent.join(format!(".{APP_BUNDLE_NAME}.new"));
        if staging_path.exists() {
            fs::remove_dir_all(&staging_path)?;
        }

        self.write_bundle(&staging_path)?;
        sign_bundle(&staging_path)?;

        if self.app_path.exists() {
            ensure_owned_bundle(&self.app_path)?;
            fs::remove_dir_all(&self.app_path)?;
        }
        fs::rename(&staging_path, &self.app_path)?;
        info!("installed app launcher to `{}`", self.app_path.display());
        Ok(())
    }

    pub fn uninstall(&self) -> Result<()> {
        if !self.app_path.exists() {
            return Ok(());
        }
        ensure_owned_bundle(&self.app_path)?;
        fs::remove_dir_all(&self.app_path)?;
        info!("removed app launcher from `{}`", self.app_path.display());
        Ok(())
    }

    fn write_bundle(&self, app_path: &Path) -> Result<()> {
        let contents_path = app_path.join("Contents");
        let executable_dir = contents_path.join("MacOS");
        fs::create_dir_all(&executable_dir)?;
        fs::write(contents_path.join("Info.plist"), info_plist())?;

        let executable_path = executable_dir.join(APP_EXECUTABLE_NAME);
        fs::write(
            &executable_path,
            launcher_script(self.paneru_path.as_path()),
        )?;
        let mut permissions = fs::metadata(&executable_path)?.permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(executable_path, permissions)
    }
}

fn ensure_owned_bundle(app_path: &Path) -> Result<()> {
    let plist = fs::read_to_string(app_path.join("Contents/Info.plist"))?;
    if plist.contains(&format!("<string>{APP_BUNDLE_ID}</string>")) {
        return Ok(());
    }

    Err(Error::new(
        ErrorKind::AlreadyExists,
        format!(
            "refusing to replace application not owned by Paneru: {}",
            app_path.display()
        ),
    ))
}

fn info_plist() -> String {
    format!(
        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>CFBundleDevelopmentRegion</key>
    <string>en</string>
    <key>CFBundleDisplayName</key>
    <string>Paneru</string>
    <key>CFBundleExecutable</key>
    <string>{APP_EXECUTABLE_NAME}</string>
    <key>CFBundleIdentifier</key>
    <string>{APP_BUNDLE_ID}</string>
    <key>CFBundleInfoDictionaryVersion</key>
    <string>6.0</string>
    <key>CFBundleName</key>
    <string>Paneru</string>
    <key>CFBundlePackageType</key>
    <string>APPL</string>
    <key>CFBundleShortVersionString</key>
    <string>{}</string>
    <key>CFBundleVersion</key>
    <string>{}</string>
    <key>LSMinimumSystemVersion</key>
    <string>12.0</string>
    <key>LSUIElement</key>
    <true/>
  </dict>
</plist>
"#,
        env!("CARGO_PKG_VERSION"),
        env!("CARGO_PKG_VERSION"),
    )
}

fn launcher_script(paneru_path: &Path) -> String {
    format!(
        "#!/bin/sh\nexec {} start\n",
        shell_quote(&paneru_path.to_string_lossy())
    )
}

fn shell_quote(value: &str) -> String {
    format!("'{}'", value.replace('\'', "'\"'\"'"))
}

fn sign_bundle(app_path: &Path) -> Result<()> {
    let output = Command::new("/usr/bin/codesign")
        .args(["--force", "--deep", "--sign", "-"])
        .arg(app_path)
        .output()?;
    if output.status.success() {
        return Ok(());
    }

    Err(Error::other(format!(
        "codesign failed: {}",
        String::from_utf8_lossy(&output.stderr).trim()
    )))
}

#[cfg(test)]
mod tests {
    use std::{
        fs,
        os::unix::fs::PermissionsExt,
        path::PathBuf,
        sync::atomic::{AtomicU64, Ordering},
    };

    use super::{
        APP_BUNDLE_ID, APP_EXECUTABLE_NAME, AppLauncher, ensure_owned_bundle, launcher_script,
        shell_quote,
    };

    static NEXT_TEST_ID: AtomicU64 = AtomicU64::new(0);

    fn test_directory() -> PathBuf {
        let test_id = NEXT_TEST_ID.fetch_add(1, Ordering::Relaxed);
        std::env::temp_dir().join(format!(
            "paneru-launcher-test-{}-{test_id}",
            std::process::id()
        ))
    }

    #[test]
    fn launcher_script_quotes_the_paneru_path() {
        let script = launcher_script(PathBuf::from("/tmp/Paneru's bin").as_path());
        assert_eq!(script, "#!/bin/sh\nexec '/tmp/Paneru'\"'\"'s bin' start\n");
        assert_eq!(shell_quote("paneru"), "'paneru'");
    }

    #[test]
    fn write_bundle_creates_a_launchable_application() {
        let root = test_directory();
        let app_path = root.join("Paneru.app");
        let launcher =
            AppLauncher::new(PathBuf::from("/opt/homebrew/bin/paneru"), app_path.clone());

        launcher.write_bundle(&app_path).unwrap();

        let plist = fs::read_to_string(app_path.join("Contents/Info.plist")).unwrap();
        assert!(plist.contains(APP_BUNDLE_ID));
        assert!(plist.contains("<string>APPL</string>"));
        assert!(plist.contains("<key>LSUIElement</key>"));

        let executable = app_path.join("Contents/MacOS").join(APP_EXECUTABLE_NAME);
        assert_eq!(
            fs::read_to_string(&executable).unwrap(),
            "#!/bin/sh\nexec '/opt/homebrew/bin/paneru' start\n"
        );
        assert_ne!(
            fs::metadata(executable).unwrap().permissions().mode() & 0o111,
            0
        );

        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn owned_bundle_check_rejects_an_unrelated_application() {
        let root = test_directory();
        let app_path = root.join("Paneru.app");
        fs::create_dir_all(app_path.join("Contents")).unwrap();
        fs::write(
            app_path.join("Contents/Info.plist"),
            "<plist><string>com.example.unrelated</string></plist>",
        )
        .unwrap();

        assert_eq!(
            ensure_owned_bundle(&app_path).unwrap_err().kind(),
            std::io::ErrorKind::AlreadyExists
        );

        fs::remove_dir_all(root).unwrap();
    }

    #[test]
    fn install_replaces_owned_bundle_and_uninstall_removes_it() {
        let root = test_directory();
        let app_path = root.join("Applications/Paneru.app");
        let launcher = AppLauncher::new(PathBuf::from("/usr/bin/true"), app_path.clone());

        launcher.install().unwrap();
        launcher.install().unwrap();

        assert!(app_path.is_dir());
        assert!(app_path.join("Contents/_CodeSignature").is_dir());

        launcher.uninstall().unwrap();
        assert!(!app_path.exists());

        fs::remove_dir_all(root).unwrap();
    }
}