Files
Tony 5d4e9c3396
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
ci / 🦀 Build ${{ matrix.crate }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / shorebird_code_push (push) Has been cancelled
Shorebird CI / shorebird_code_push_example (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled
refactor: improve comments for clarity and update privacy references
Signed-off-by: Tony <tonylu@tony-cloud.com>
2026-06-25 15:50:24 +08:00

330 lines
11 KiB
Rust

// This file handles the global config for the updater library.
use crate::network::NetworkHooks;
use crate::updater::AppConfig;
use crate::yaml::{PatchVerificationMode, YamlConfig};
use crate::{ExternalFileProvider, UpdateError};
use std::path::PathBuf;
use anyhow::{bail, Result};
use once_cell::sync::OnceCell;
use std::sync::Mutex;
// cbindgen looks for const, ignore these so it doesn't warn about them.
/// cbindgen:ignore
const DEFAULT_BASE_URL: &str = "http://localhost:8080";
/// cbindgen:ignore
const DEFAULT_CHANNEL: &str = "stable";
fn global_config() -> &'static Mutex<Option<UpdateConfig>> {
static INSTANCE: OnceCell<Mutex<Option<UpdateConfig>>> = OnceCell::new();
INSTANCE.get_or_init(|| Mutex::new(None))
}
/// Session-scoped patch number this process is using. Set by
/// `report_launch_start` from the next-boot patch at that moment, read
/// by `updater::running_patch()` (surfaced to Dart as
/// `shorebird_current_boot_patch_number`). Lives outside the on-disk
/// `PatchesState` because it tracks running state, not bootable-patch
/// metadata: it must survive a server-driven rollback of the running
/// patch (the process is still using it) and must reset to `None` on
/// every fresh process start. `report_launch_start` is called by
/// flutter_engine before `dart:ffi` is available, so the `None` window
/// before launch start is not observable from Dart.
fn global_running_patch() -> &'static Mutex<Option<usize>> {
static INSTANCE: OnceCell<Mutex<Option<usize>>> = OnceCell::new();
INSTANCE.get_or_init(|| Mutex::new(None))
}
pub fn running_patch_number() -> Option<usize> {
*global_running_patch()
.lock()
.expect("Failed to acquire running_patch lock.")
}
pub fn set_running_patch_number(patch_number: Option<usize>) {
*global_running_patch()
.lock()
.expect("Failed to acquire running_patch lock.") = patch_number;
}
/// Unit tests should call this to reset the config between tests.
/// Also exposed (via the `test-hooks` Cargo feature) to the
/// `library_test_hooks` cdylib so Dart-side integration tests can reset
/// state between scenarios without spawning a subprocess.
#[cfg(any(test, feature = "test-hooks"))]
pub fn testing_reset_config() {
with_config_mut(|config| {
*config = None;
});
set_running_patch_number(None);
}
pub fn check_initialized_and_call<F, R>(
f: F,
maybe_config: &Option<UpdateConfig>,
) -> anyhow::Result<R>
where
F: FnOnce(&UpdateConfig) -> anyhow::Result<R>,
{
match maybe_config {
Some(config) => f(config),
None => anyhow::bail!(UpdateError::ConfigNotInitialized),
}
}
pub fn with_config<F, R>(f: F) -> anyhow::Result<R>
where
F: FnOnce(&UpdateConfig) -> anyhow::Result<R>,
{
// expect() here should be OK, it's job is to propagate a panic across
// threads if the lock is poisoned.
let lock = global_config()
.lock()
.expect("Failed to acquire updater lock.");
check_initialized_and_call(f, &lock)
}
pub fn with_config_mut<F, R>(f: F) -> R
where
F: FnOnce(&mut Option<UpdateConfig>) -> R,
{
let mut lock = global_config()
.lock()
.expect("Failed to acquire updater lock.");
f(&mut lock)
}
// The config passed into init. This is immutable once set and copyable.
#[derive(Debug, Clone)]
pub struct UpdateConfig {
pub storage_dir: PathBuf,
pub download_dir: PathBuf,
pub auto_update: bool,
pub channel: String,
pub app_id: String,
pub release_version: String,
pub libapp_path: PathBuf,
pub base_url: String,
pub network_hooks: NetworkHooks,
pub file_provider: Box<dyn ExternalFileProvider>,
pub patch_public_key: Option<String>,
pub patch_verification: PatchVerificationMode,
}
/// Returns Ok if the config was set successfully, Err if it was already set.
pub fn set_config(
app_config: AppConfig,
file_provider: Box<dyn ExternalFileProvider>,
libapp_path: PathBuf,
yaml: &YamlConfig,
network_hooks: NetworkHooks,
) -> Result<()> {
with_config_mut(|config: &mut Option<UpdateConfig>| {
if config.is_some() {
// This previously returned an error, but this happens regularly
// with apps that use Firebase Messaging, and logging it as an error
// has caused confusion.
bail!("Updater already initialized, ignoring second shorebird_init call.");
}
let mut code_cache_path = std::path::PathBuf::from(&app_config.code_cache_dir);
code_cache_path.push("downloads");
let download_dir = code_cache_path;
let new_config = UpdateConfig {
storage_dir: std::path::PathBuf::from(app_config.app_storage_dir),
download_dir,
channel: yaml
.channel
.as_deref()
.unwrap_or(DEFAULT_CHANNEL)
.to_owned(),
auto_update: yaml.auto_update.unwrap_or(true),
app_id: yaml.app_id.to_string(),
release_version: app_config.release_version.to_string(),
libapp_path,
base_url: yaml
.base_url
.as_deref()
.unwrap_or(DEFAULT_BASE_URL)
.to_owned(),
network_hooks,
file_provider,
patch_public_key: yaml.patch_public_key.to_owned(),
patch_verification: yaml.patch_verification.unwrap_or_default(),
};
shorebird_debug!("Updater configured with: {:?}", new_config);
*config = Some(new_config);
Ok(())
})
}
// Arch/Platform names need to be kept in sync with the shorebird cli.
pub fn current_arch() -> &'static str {
#[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";
#[cfg(target_arch = "arm")]
static ARCH: &str = "arm";
ARCH
}
pub fn current_platform() -> &'static str {
#[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_os = "ios")]
static PLATFORM: &str = "ios";
PLATFORM
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::set_config;
use crate::{network::NetworkHooks, testing_reset_config, AppConfig, ExternalFileProvider};
use anyhow::Result;
use serial_test::serial;
#[derive(Debug, Clone)]
pub struct FakeExternalFileProvider {}
impl ExternalFileProvider for FakeExternalFileProvider {
fn open(&self) -> anyhow::Result<Box<dyn crate::ReadSeek>> {
Ok(Box::new(std::io::Cursor::new(vec![])))
}
}
fn fake_app_config() -> AppConfig {
AppConfig {
app_storage_dir: "/tmp".to_string(),
code_cache_dir: "/tmp".to_string(),
release_version: "1.0.0".to_string(),
original_libapp_paths: vec!["libapp.so".to_string()],
}
}
fn fake_yaml() -> crate::yaml::YamlConfig {
crate::yaml::YamlConfig {
app_id: "fake_app_id".to_string(),
channel: Some("fake_channel".to_string()),
auto_update: Some(true),
base_url: Some("fake_base_url".to_string()),
patch_public_key: None,
patch_verification: None,
}
}
// These tests are serial because they modify global state.
#[serial]
#[test]
fn set_config_correctly_sets_values() -> Result<()> {
testing_reset_config();
set_config(
AppConfig {
app_storage_dir: "/app_storage".to_string(),
code_cache_dir: "/code_cache".to_string(),
release_version: "1.0.0".to_string(),
original_libapp_paths: vec!["libapp.so".to_string()],
},
Box::new(FakeExternalFileProvider {}),
"first_path".into(),
&crate::yaml::YamlConfig {
app_id: "fake_app_id".to_string(),
channel: Some("fake_channel".to_string()),
auto_update: Some(true),
base_url: Some("fake_base_url".to_string()),
patch_public_key: Some("patch_public_key".to_string()),
patch_verification: None,
},
NetworkHooks::default(),
)?;
let config = super::with_config(|config| Ok(config.clone())).unwrap();
assert_eq!(config.storage_dir, PathBuf::from("/app_storage"));
assert_eq!(
config.download_dir,
PathBuf::from("/").join("code_cache").join("downloads")
);
assert!(config.auto_update);
assert_eq!(config.channel, "fake_channel");
assert_eq!(config.app_id, "fake_app_id");
assert_eq!(config.release_version, "1.0.0");
assert_eq!(config.libapp_path.to_str(), Some("first_path"));
assert_eq!(config.base_url, "fake_base_url");
// We should also validate network hooks here
assert_eq!(
config.patch_public_key,
Some("patch_public_key".to_string())
);
Ok(())
}
// These tests are serial because they modify global state.
#[serial]
#[test]
fn set_config_defaults_to_open_self_hosted_server() -> Result<()> {
testing_reset_config();
let mut yaml = fake_yaml();
yaml.base_url = None;
set_config(
fake_app_config(),
Box::new(FakeExternalFileProvider {}),
"first_path".into(),
&yaml,
NetworkHooks::default(),
)?;
let config = super::with_config(|config| Ok(config.clone())).unwrap();
assert_eq!(config.base_url, super::DEFAULT_BASE_URL);
Ok(())
}
// These tests are serial because they modify global state.
#[serial]
#[test]
fn set_config_returns_err_on_subsequent_calls() -> Result<()> {
testing_reset_config();
assert!(set_config(
fake_app_config(),
Box::new(FakeExternalFileProvider {}),
"first_path".into(),
&fake_yaml(),
NetworkHooks::default(),
)
.is_ok());
assert!(set_config(
fake_app_config(),
Box::new(FakeExternalFileProvider {}),
"second_path".into(),
&fake_yaml(),
NetworkHooks::default(),
)
.is_err());
let config = super::with_config(|config| Ok(config.clone())).unwrap();
assert_eq!(config.libapp_path.to_str(), Some("first_path"));
Ok(())
}
}