self_update icon indicating copy to clipboard operation
self_update copied to clipboard

Support for Bundles

Open hacknus opened this issue 11 months ago • 3 comments

First of all, thank you very much for the work on this cool and very useful project!

I am running a GUI on macOS. It is bundled like this:

ApplicationName.app
└── Contents
    ├── Info.plist
    ├── MacOS
    │   └── application-binary
    └── Resources
        └── ApplicationName.icns

The release will be downloaded as ApplicationName.app.zip, however self-update fails to unpack it, because it expects a single file and not a directory structure. Is there a way to work with this?

Similarly: My application is also distributed as .deb for linux and .msi for windows - how would that work with self-update?

hacknus avatar Jan 14 '25 16:01 hacknus

Hi @hacknus, you could use the individual download, extract, move, self_replace utilities instead of the all-in-one update API. There's a brief example at the end of the readme. For os-specific installations, you will likely need to have three different implementations and use a compile-time #[cfg(...)] to choose which one is used

jaemk avatar Jan 15 '25 04:01 jaemk

Thanks, I managed to create an update method that takes care of this. I had to implement the unzipping myself without using the built-in of self-replace because the built-in can only extract a single file.

Would you be open to a PR introducing an unzipping method that extracts more than one file? I could then put this update function for bundles in the README.md or add an example.

update.rs

use self_update::self_replace;
use self_update::update::Release;
use semver::Version;
use std::fs::File;
use std::path::Path;
use std::process::Command;
use std::{env, fs, io};
use zip::ZipArchive;

const REPO_OWNER: &str = "hacknus";
const REPO_NAME: &str = "serial-monitor-rust";

const MACOS_APP_NAME: &str = "Serial Monitor.app";

pub fn restart_application() {
    // Get the current executable path
    let current_exe = std::env::current_exe().expect("Failed to get current executable path");

    // Launch a new instance of the application
    let _ = Command::new(current_exe).spawn();
}

fn extract_zip(tmp_archive_path: &Path, tmp_archive_dir: &Path) -> io::Result<()> {
    // Open the zip file
    let file = File::open(tmp_archive_path)?;
    let mut archive = ZipArchive::new(file)?;

    // Iterate through the entries in the zip file
    for i in 0..archive.len() {
        let mut file = archive.by_index(i)?;
        let outpath = Path::new(tmp_archive_dir).join(file.name());

        if file.is_dir() {
            // Create directories
            std::fs::create_dir_all(&outpath)?;
        } else {
            // If the parent directory doesn't exist, create it
            if let Some(parent) = outpath.parent() {
                if !parent.exists() {
                    std::fs::create_dir_all(parent)?;
                }
            }

            // Write the file to disk
            let mut outfile = File::create(&outpath)?;
            io::copy(&mut file, &mut outfile)?;
        }

        // Set file permissions if needed
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if let Some(mode) = file.unix_mode() {
                std::fs::set_permissions(&outpath, std::fs::Permissions::from_mode(mode))?;
            }
        }
    }

    Ok(())
}

fn copy_dir(src: &Path, dest: &Path, binary_name: &str) -> io::Result<()> {
    // Ensure the destination directory exists
    if !dest.exists() {
        fs::create_dir_all(dest)?;
    }

    // Iterate through entries in the source directory
    for entry in fs::read_dir(src)? {
        let entry = entry?;
        let path = entry.path();
        let dest_path = dest.join(entry.file_name());

        if path.is_dir() {
            // Recursively copy subdirectories
            copy_dir(&path, &dest_path, binary_name)?;
        } else if let Some(file_name) = path.file_name() {
            if file_name != binary_name {
                // Copy files except for the binary
                fs::copy(&path, &dest_path)?;
            }
        }
    }

    Ok(())
}

pub fn check_update() -> Option<Release> {
    if let Ok(builder) = self_update::backends::github::ReleaseList::configure()
        .repo_owner(REPO_OWNER)
        .repo_name(REPO_NAME)
        .build()
    {
        if let Ok(releases) = builder.fetch() {
            let current_version = Version::parse(env!("CARGO_PKG_VERSION")).unwrap();
            return releases
                .iter()
                .filter_map(|release| {
                    let release_version_str = release
                        .version
                        .strip_prefix("v")
                        .unwrap_or(&release.version);
                    Version::parse(release_version_str)
                        .ok()
                        .map(|parsed_version| (parsed_version, release))
                })
                .filter(|(parsed_version, _)| parsed_version > &current_version) // Compare versions
                .max_by(|(a, _), (b, _)| a.cmp(b)) // Find the max version
                .map(|(_, release)| release.clone()); // Return the release
        }
    }
    None
}

pub fn update(release: Release) -> Result<(), Box<dyn std::error::Error>> {
    // this if clause is only required if .msi and .exe are available for windows and .deb and a binary executable are available for linux
    let target_asset = if cfg!(target_os = "windows") {
        release
            .asset_for(self_update::get_target(), Some("exe"))
            .unwrap()
    } else if cfg!(target_os = "linux") {
        release
            .asset_for(self_update::get_target(), Some("bin"))
            .unwrap()
    } else {
        release.asset_for(self_update::get_target(), None).unwrap()
    };
    let tmp_archive_dir = tempfile::TempDir::new()?;
    let tmp_archive_path = tmp_archive_dir.path().join(&target_asset.name);
    let tmp_archive = fs::File::create(&tmp_archive_path)?;

    self_update::Download::from_url(&target_asset.download_url)
        .set_header(reqwest::header::ACCEPT, "application/octet-stream".parse()?)
        .download_to(&tmp_archive)?;

    extract_zip(&tmp_archive_path, tmp_archive_dir.path())?;
    let new_exe = if cfg!(target_os = "windows") {
        let binary = env::current_exe()
            .unwrap()
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
            .to_string();
        tmp_archive_dir.path().join(binary)
    } else if cfg!(target_os = "macos") {
        let binary = env::current_exe()
            .unwrap()
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
            .to_string();
        let app_dir = env::current_exe()
            .unwrap()
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .parent()
            .unwrap()
            .to_path_buf();

        let app_name = app_dir
            .clone()
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
            .to_string();

        let _ = copy_dir(&tmp_archive_dir.path().join(&app_name), &app_dir, &binary);

        // MACOS_APP_NAME either needs to be hardcoded or extracted from the downloaded and
        // extracted archive, but we cannot just assume that the parent directory of the 
        // currently running executable is equal to the app name - this is especially not 
        // the case if we run the code with cargo run.
        tmp_archive_dir
            .path()
            .join(format!("{}/Contents/MacOS/{}", MACOS_APP_NAME, binary))
    } else if cfg!(target_os = "linux") {
        let binary = env::current_exe()
            .unwrap()
            .file_name()
            .unwrap()
            .to_str()
            .unwrap()
            .to_string();
        tmp_archive_dir.path().join(binary)
    } else {
        panic!("Running on an unsupported OS");
    };

    self_replace::self_replace(new_exe)?;
    Ok(())
}

hacknus avatar Jan 17 '25 10:01 hacknus

Nice! A PR is welcome

jaemk avatar Jan 17 '25 12:01 jaemk