feat: add shorebird_code_push_updater (#22)

This commit is contained in:
Felix Angelov
2023-03-06 15:42:30 -06:00
committed by GitHub
parent a8fcda875c
commit f113d9f025
7 changed files with 499 additions and 0 deletions
@@ -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
@@ -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"
@@ -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
```
@@ -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 */
@@ -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);
}
@@ -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;
@@ -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<String>,
// 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<Slot>,
}
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<UpdaterState> {
// 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<Update>,
}
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<VersionInfo>,
) -> anyhow::Result<UpdateResponse> {
#[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<VersionInfo> {
// 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<VersionInfo> {
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<UpdateStatus> {
// 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,
}
}