diff --git a/packages/shorebird_code_push_updater/.gitignore b/packages/shorebird_code_push_updater/.gitignore new file mode 100644 index 00000000..ada8be92 --- /dev/null +++ b/packages/shorebird_code_push_updater/.gitignore @@ -0,0 +1,14 @@ +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb \ No newline at end of file diff --git a/packages/shorebird_code_push_updater/Cargo.toml b/packages/shorebird_code_push_updater/Cargo.toml new file mode 100644 index 00000000..e6782fe5 --- /dev/null +++ b/packages/shorebird_code_push_updater/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "shorebird_code_push_updater" +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[lib] +# Build a rust library (for testing with rust "cli" project) and a c library +crate-type = ["staticlib"] + +[dependencies] +# Used for exposing C API +libc = "0.2.98" +# Used for networking. +reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"] } +# Json serialization/de-serialization. +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0.93" +# Used for creating custom errors. +thiserror = "1.0" +# Used for error handling. +anyhow = {version = "1.0.69", features = ["backtrace"]} +# Used for logging. +android_logger = "0.13.0" +log = "0.4.14" diff --git a/packages/shorebird_code_push_updater/README.md b/packages/shorebird_code_push_updater/README.md new file mode 100644 index 00000000..c468976e --- /dev/null +++ b/packages/shorebird_code_push_updater/README.md @@ -0,0 +1,19 @@ +# Shorebird CodePush Updater + +The rust library that does the actual update work. + +## Building for Android + +The best way I found was to install: +https://github.com/bbqsrc/cargo-ndk + +``` +rustup install beta +cargo +beta install cargo-ndk +rustup +beta target add \ + aarch64-linux-android \ + armv7-linux-androideabi \ + x86_64-linux-android \ + i686-linux-android +cargo +beta ndk --target aarch64-linux-android build --release +``` diff --git a/packages/shorebird_code_push_updater/include/updater.h b/packages/shorebird_code_push_updater/include/updater.h new file mode 100644 index 00000000..e378512c --- /dev/null +++ b/packages/shorebird_code_push_updater/include/updater.h @@ -0,0 +1,20 @@ +#ifndef updater_h +#define updater_h + +#ifdef __cplusplus +extern "C" +{ +#endif + + char *active_version(const char *client_id, const char *cache_dir); + char *active_path(const char *client_id, const char *cache_dir); + bool check_for_update(const char *client_id, const char *cache_dir); + void update(const char *client_id, const char *cache_dir); + + void free_string(char *str); + +#ifdef __cplusplus +} // extern "C" +#endif + +#endif /* updater_h */ diff --git a/packages/shorebird_code_push_updater/src/c_api.rs b/packages/shorebird_code_push_updater/src/c_api.rs new file mode 100644 index 00000000..4ca2943d --- /dev/null +++ b/packages/shorebird_code_push_updater/src/c_api.rs @@ -0,0 +1,72 @@ +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; + +use crate::updater; + +fn app_config_from_c(c_client_id: *const c_char, c_cache_dir: *const c_char) -> updater::AppConfig { + let client_id = unsafe { CStr::from_ptr(c_client_id) }.to_str().unwrap(); + let cache_dir = if c_cache_dir == std::ptr::null() { + None + } else { + Some(unsafe { CStr::from_ptr(c_cache_dir).to_str().unwrap() }.to_string()) + }; + + updater::AppConfig { + client_id: client_id.to_string(), + cache_dir: cache_dir, + } +} + +#[no_mangle] +pub extern "C" fn active_version( + c_client_id: *const c_char, + c_cache_dir: *const c_char, +) -> *mut c_char { + let config = app_config_from_c(c_client_id, c_cache_dir); + let version = updater::active_version(&config); + match version { + Some(v) => { + let c_version = CString::new(v.version).unwrap(); + c_version.into_raw() + } + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn active_path( + c_client_id: *const c_char, + c_cache_dir: *const c_char, +) -> *mut c_char { + let config = app_config_from_c(c_client_id, c_cache_dir); + let version = updater::active_version(&config); + match version { + Some(v) => { + let c_version = CString::new(v.path).unwrap(); + c_version.into_raw() + } + None => std::ptr::null_mut(), + } +} + +#[no_mangle] +pub extern "C" fn free_string(c_string: *mut c_char) { + unsafe { + if c_string.is_null() { + return; + } + drop(CString::from_raw(c_string)); + } +} + +#[no_mangle] +pub extern "C" fn check_for_update(c_client_id: *const c_char, c_cache_dir: *const c_char) -> bool { + let config = app_config_from_c(c_client_id, c_cache_dir); + return updater::check_for_update(&config); +} + +#[no_mangle] +pub extern "C" fn update(c_client_id: *const c_char, c_cache_dir: *const c_char) { + let config = app_config_from_c(c_client_id, c_cache_dir); + updater::update(&config); +} diff --git a/packages/shorebird_code_push_updater/src/lib.rs b/packages/shorebird_code_push_updater/src/lib.rs new file mode 100644 index 00000000..70c1cad0 --- /dev/null +++ b/packages/shorebird_code_push_updater/src/lib.rs @@ -0,0 +1,12 @@ +// Declare that the c_api.rs file exists and is a public sub-namespace. +// C doesn't care about the namespaces, but Rust does. +pub mod c_api; + +// Declare that the updater.rs file/module exists, but don't make it public. +mod updater; + +// Take all public items from the updater namespace and make them public. +pub use self::updater::*; + +#[macro_use] +extern crate log; diff --git a/packages/shorebird_code_push_updater/src/updater.rs b/packages/shorebird_code_push_updater/src/updater.rs new file mode 100644 index 00000000..31c92ebd --- /dev/null +++ b/packages/shorebird_code_push_updater/src/updater.rs @@ -0,0 +1,336 @@ +use std::collections::HashMap; +use std::fmt::{Display, Formatter}; +use std::fs::File; +use std::io::{BufReader, BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::string::ToString; + +use android_logger::Config; +use log::LevelFilter; + +use serde::{Deserialize, Serialize}; +// use thiserror::Error; + +// #[derive(Error, Debug)] +// pub enum UpdateError { +// #[error("update server disconnected")] +// NetworkFailure(#[from] std::io::Error), +// #[error("unknown error")] +// Unknown, +// } + +pub enum UpdateStatus { + NoUpdate, + UpdateAvailable, + UpdateDownloaded, + UpdateInstalled, + UpdateHadError, +} + +impl Display for UpdateStatus { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + match self { + UpdateStatus::NoUpdate => write!(f, "No update"), + UpdateStatus::UpdateAvailable => write!(f, "Update available"), + UpdateStatus::UpdateDownloaded => write!(f, "Update downloaded"), + UpdateStatus::UpdateInstalled => write!(f, "Update installed"), + UpdateStatus::UpdateHadError => write!(f, "Update had error"), + } + } +} + +pub struct AppConfig { + // provided from the application + pub client_id: String, + pub cache_dir: Option, + // typically default=shorebird, but provided by the app as override? + // pub base_url: Option<&'a str>, + // typically default=stable, but provided by the app as override. + // pub channel: Option<&'a str>, + // Other needs: + // Architecture? Or engine can get that itself? + // fallback path? Or engine just returns null and caller figures that out? +} + +pub struct VersionInfo { + pub path: String, + pub version: String, + pub hash: String, +} + +#[derive(Deserialize, Serialize, Default, Clone)] +struct Slot { + path: String, + version: String, + hash: String, +} + +#[derive(Deserialize, Serialize)] +struct UpdaterState { + current_slot_index: usize, + slots: Vec, +} + +impl Default for UpdaterState { + fn default() -> Self { + Self { + current_slot_index: 0, + slots: Vec::new(), + } + } +} + +struct ResolvedConfig { + client_id: String, + base_url: String, + channel: String, + cache_dir: String, +} + +fn load_state(cache_dir: &str) -> anyhow::Result { + // Load UpdaterState from disk + let path = Path::new(cache_dir).join("state.json"); + let file = File::open(path)?; + let reader = BufReader::new(file); + let state = serde_json::from_reader(reader)?; + Ok(state) +} + +fn save_state(state: &UpdaterState, cache_dir: &str) -> anyhow::Result<()> { + // Save UpdaterState to disk + std::fs::create_dir_all(cache_dir)?; + let path = Path::new(cache_dir).join("state.json"); + let file = File::create(path)?; + let writer = BufWriter::new(file); + serde_json::to_writer_pretty(writer, &state)?; + Ok(()) +} + +fn resolve_config(config: &AppConfig) -> ResolvedConfig { + // Resolve the config + // If there is no base_url, use the default. + // If there is no channel, use the default. + return ResolvedConfig { + client_id: config.client_id.to_string(), + base_url: "https://shorebird-code-push-api-cypqazu4da-uc.a.run.app".to_string(), + cache_dir: config + .cache_dir + .as_deref() + .unwrap_or("updater_cache") + .to_owned(), + channel: "stable".to_string(), + }; +} + +fn updates_url(config: &ResolvedConfig) -> String { + return format!("{}/api/v1/updates", config.base_url); +} + +#[derive(Deserialize)] +struct Update { + version: String, + hash: String, + download_url: String, +} + +#[derive(Deserialize)] +struct UpdateResponse { + update_available: bool, + #[serde(default)] + update: Option, +} + +pub fn check_for_update(app_config: &AppConfig) -> bool { + let config = resolve_config(app_config); + // Load UpdaterState from disk + // If there is no state, make an empty state. + let state = load_state(&config.cache_dir).unwrap_or_default(); + // Check the current slot. + let version = current_version_internal(&state); + // Send info from app + current slot to server. + let response_result = send_update_request(&config, version); + match response_result { + Err(err) => { + error!("Failed update check: {err}"); + return false; + } + Ok(response) => { + return response.update_available; + } + } +} + +fn send_update_request( + config: &ResolvedConfig, + version: Option, +) -> anyhow::Result { + #[cfg(target_os = "macos")] + static PLATFORM: &str = "macos"; + #[cfg(target_os = "linux")] + static PLATFORM: &str = "linux"; + #[cfg(target_os = "windows")] + static PLATFORM: &str = "windows"; + #[cfg(target_os = "android")] + static PLATFORM: &str = "android"; + + #[cfg(target_arch = "x86")] + static ARCH: &str = "x86"; + #[cfg(target_arch = "x86_64")] + static ARCH: &str = "x86_64"; + #[cfg(target_arch = "aarch64")] + static ARCH: &str = "aarch64"; + + // Send the request to the server. + let client = reqwest::blocking::Client::new(); + let mut body = HashMap::new(); + body.insert("client_id", config.client_id.clone()); + body.insert("channel", config.channel.clone()); + if let Some(version) = version { + body.insert("version", version.version); + body.insert("hash", version.hash); + } + body.insert("platform", PLATFORM.to_string()); + body.insert("arch", ARCH.to_string()); + let response = client + .post(&updates_url(config)) + .json(&body) + .send()? + .json()?; + return Ok(response); +} + +fn current_version_internal(state: &UpdaterState) -> Option { + // If there is no state, return None. + if state.slots.is_empty() { + return None; + } + let slot = &state.slots[state.current_slot_index]; + // Otherwise return the version info from the current slot. + return Some(VersionInfo { + path: slot.path.clone(), + version: slot.version.clone(), + hash: slot.hash.clone(), + }); +} + +pub fn active_version(config: &AppConfig) -> Option { + let config = resolve_config(config); + let state = load_state(&config.cache_dir).unwrap_or_default(); + return current_version_internal(&state); +} + +fn unused_slot(state: &UpdaterState) -> usize { + // Assume we only use two slots and pick the one that's not current. + if state.slots.is_empty() { + return 0; + } + if state.current_slot_index == 0 { + return 1; + } + return 0; +} + +fn set_slot(state: &mut UpdaterState, index: usize, slot: Slot) { + if state.slots.len() < index + 1 { + // Make sure we're not filling with empty slots. + assert!(state.slots.len() == index); + state.slots.resize(index + 1, Slot::default()); + } + // Set the given slot to the given version. + state.slots[index] = slot +} + +fn download_file_to_path(url: &str, path: &PathBuf) -> anyhow::Result<()> { + // Download the file at the given url to the given path. + let client = reqwest::blocking::Client::new(); + let response = client.get(url).send()?; + let mut bytes = response.bytes()?; + + // Ensure the download directory exists. + std::fs::create_dir_all(path.parent().unwrap())?; + + let mut file = File::create(path)?; + file.write_all(&mut bytes)?; + Ok(()) +} + +fn download_into_slot( + config: &ResolvedConfig, + update_response: &UpdateResponse, + state: &mut UpdaterState, + slot_index: usize, +) -> anyhow::Result<()> { + // Download the new version into the given slot. + let path = Path::new(&config.cache_dir) + .join(format!("slot_{}", slot_index)) + .join("libapp.txt"); + + // TODO: Shouldn't crash on malformed response. + let update = update_response.update.as_ref().unwrap(); + + // We should download into a separate place and move into place. + // That would allow us to check the hash before moving into place. + // Would also allow the move/state update to be "atomic" or at least allow + // us to carefully guard against state corruption. + // Would also let us support when we need to allow the system to download for us (e.g. iOS). + download_file_to_path(&update.download_url, &path)?; + // Check the hash against the download? + + // Update the state to include the new version. + set_slot( + state, + slot_index, + Slot { + path: path.to_str().unwrap().to_string(), + version: update.version.clone(), + hash: update.hash.clone(), + }, + ); + save_state(&state, &config.cache_dir)?; + + return Ok(()); +} + +fn update_internal(config: &ResolvedConfig) -> anyhow::Result { + // Load the state from disk. + let mut state = load_state(&config.cache_dir).unwrap_or_default(); + let version = current_version_internal(&state); + // Check for update. + let response = send_update_request(&config, version)?; + if !response.update_available { + return Ok(UpdateStatus::NoUpdate); + } + // If needed, download the new version. + let slot = unused_slot(&mut state); + download_into_slot(&config, &response, &mut state, slot)?; + // Install the new version. + state.current_slot_index = slot; + save_state(&state, &config.cache_dir)?; + // Set the state to "restart required". + return Ok(UpdateStatus::UpdateInstalled); +} + +fn init_logging() { + android_logger::init_once( + Config::default() + // `flutter` tool ignores non-flutter tagged logs. + .with_tag("flutter") + .with_max_level(LevelFilter::Debug), + ); + debug!("Logging initialized"); +} + +pub fn update(app_config: &AppConfig) -> UpdateStatus { + init_logging(); + + let config = resolve_config(&app_config); + let result = update_internal(&config); + match result { + Err(err) => { + error!("Problem updating: {err}"); + error!("{}", err.backtrace()); + return UpdateStatus::UpdateHadError; + } + Ok(status) => status, + } +}