updater/ has moved to its own repository

This commit is contained in:
Eric Seidel
2023-04-06 21:12:27 -07:00
parent 9111869dd4
commit 36ccfd2c7a
34 changed files with 0 additions and 2448 deletions
-17
View File
@@ -1,17 +0,0 @@
# 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
# Cache directory from updater cli.
updater_cache
-122
View File
@@ -1,122 +0,0 @@
# Building the Shorebird Flutter Engine
Shorebird uses a modified version of the Flutter engine. Normally
when you use Shorebird, you would use the pre-built engine binaries
that we provide. However, if you want to build the engine yourself,
this document describes how to do that.
The primary modification Shorebird makes to the stock Flutter engine
is adding support for the updater library. The updater library is
written in Rust and is used to update the code running in the Flutter
app. The updater library is built as a static library and is linked
into the Flutter engine during build time.
## Building the Updater Library
### Installing Rust
The updater library is written in Rust. You can install Rust using
rustup. See https://rustup.rs/ for details.
## Building for Android
Rust Android tooling *mostly* works out of the box, but needs a bunch
of configuration to get it to work.
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
```
If others know of better instructions, please send us a PR!
Once you have cargo-ndk installed, you can build the updater library with the
beta toolchain you installed and the ndk command:
```
cargo +beta ndk --target aarch64-linux-android build --release
```
### Setting up to build the Flutter Engine:
https://github.com/flutter/flutter/wiki/Setting-up-the-Engine-development-environment
https://github.com/flutter/flutter/wiki/Compiling-the-engine
The .gclient file I recommend is:
```
solutions = [
{
"managed": False,
"name": "src/flutter",
"url": "git@github.com:shorebirdtech/engine.git",
"custom_deps": {},
"deps_file": "DEPS",
"safesync_url": "",
},
]
```
(We should probably just check that in somewhere.)
Once you have that set up and `gclient sync` has run, you will need
to switch your flutter checkout to the `codepush` branch:
```
cd src/flutter
git checkout codepush
```
And then `gclient sync` again.
### Symlink in the Rust binaries
Currently you need to symlink in the results of the rust build into the engine/src directory:
```
cd flutter
mkdir updater
cd updater
ln -s $SRC/shorebird/updater/library/include/updater.h
mkdir android_aarch64
cd android_aarch64
ln -s $SRC/shorebird/updater/target/aarch64-linux-android/release/libupdater.a
```
## Building Flutter Engine
```
./flutter/tools/gn --android --android-cpu arm64 --runtime-mode=release
ninja -C out/android_release_arm64
```
The linking step for android_release_arm64 is _much_ longer than other platforms
we may need to use unopt or debug builds for faster iteration.
I also add `&& say "done"` to the end of the ninja command so I know when it's
done (because it takes minutes).
## Running with your local engine
The `shorebird` tools don't yet support local engines, so you need to use
`flutter run` directly.
https://github.com/shorebirdtech/shorebird/issues/42
Here is a script:
```
#! /bin/sh -x
LOCAL_ENGINE_SRC_PATH=/path/to/local/flutter/engine
LOCAL_ENGINE=android_release_arm64
flutter build apk --release --no-tree-shake-icons --local-engine-src-path $LOCAL_ENGINE_SRC_PATH --local-engine=$LOCAL_ENGINE
```
Only need to build with your custom engine once. Once the app is installed on
the phone then you can `shorebird publish` to it as normal.
-2
View File
@@ -1,2 +0,0 @@
[workspace]
members = ["cli", "library", "patch"]
-16
View File
@@ -1,16 +0,0 @@
# Updater library
This is the C/Rust side of the Shorebird code push system. This is built
in Rust with a C API for easy calling from other languages, most notably
for linking into libflutter.so.
See cli/README.md for more documentation on the library.
## Parts
* cli: Test the updater library via the Rust API (for development).
* dart_cli: Test ffi wrapping of updater library.
* library: The rust library that does the actual update work.
* dart_bindings: The Dart bindings for the updater library.
All of the interesting code is in the `library` directory. There is also
a README.md in that directory explaining the design.
-30
View File
@@ -1,30 +0,0 @@
Shorebird currently maintains patches to the Flutter Engine to integrate the Shorebird updater library.
We have changes to both flutter/engine and flutter/buildroot. We need to make sure those changes are applied to the correct version of each, to be compatible with a given `flutter/flutter` version.
* flutter/flutter version is whatever is the latest `stable` in flutter/flutter.
* From that you can get the `flutter/engine` version in `bin/internal/engine.version` in flutter/flutter.
* From that you can get the `flutter/buildroot` version in `DEPS` in `flutter/engine`
Working from a `flutter/engine` checkout can be confusing because the layout is:
src/ <- `flutter/buildroot`
src/flutter <- `flutter/engine`
`gclient` uses the file in `src/flutter/DEPS` to control the whole checkout, as directed by your `.gclient` file in the parent directory of `src`.
Here are the steps to update those forks every time Flutter releases:
1. You need the release name / git id for the Flutter release. e.g. 3.7.7
1. Fetch flutter at that tag
1. Look at engine version https://github.com/flutter/flutter/blob/3.7.7/bin/internal/engine.version
See that is 1837b5be5f0f1376a1ccf383950e83a80177fb4e.
1. Rebase our engine patches onto that engine version. https://github.com/shorebirdtech/engine/tree/stable_codepush
If the engine is tagged correctly (it isn't always) it's as simple as:
`git rebase --onto 3.7.7 3.7.6
1. Should then also look at DEPS file in engine: https://github.com/flutter/engine/blob/3.7.7/DEPS
Where we are looking for the buildroot hash: https://github.com/flutter/engine/blob/3.7.7/DEPS#L239
1. We now need to rebase the buildroot (src/) changes onto the buildroot version in that DEPS. https://github.com/shorebirdtech/buildroot/tree/stable_codepush
e.g. `git rebase --onto 8747bce41d0dc6d9dc45c4d1b46d2100bb9ee688 93f7f85422a8604bdc44ef76c3f105ead65e8c1c`
Where as that should be `--onto new_base_revision previous_base_revision`. The base revision is the git id *before* we started making changes.
1. Once you've successfully rebased the buildroot, you then need to change the buildroot id in our fork of the engine (so that when others use `gclient sync` it pulls our modified buildroot rather than an unmodified one.
1. Finally, update the [Shorebird CLI flutterEngineRevision](https://github.com/shorebirdtech/shorebird/blob/main/packages/shorebird_cli/lib/src/flutter_engine_revision.dart#L3) to the new Flutter engine revision
-20
View File
@@ -1,20 +0,0 @@
# See https://github.com/eqrion/cbindgen/blob/master/docs.md#cbindgentoml
# for detailed documentation of every option here.
language = "C"
include_guard = "updater_h"
autogen_warning = "/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */"
cpp_compat = true
line_length = 80
# I don't know if these are required to export the shorebird_ symbols
# since I've hit multiple levels of export trouble in libflutter.so.
# But I'm leaving them here for now.
after_includes = """
#ifdef _WIN32
#define SHOREBIRD_EXPORT __declspec(dllexport)
#else
#define SHOREBIRD_EXPORT __attribute__((visibility("default")))
#endif
"""
[fn]
prefix = "SHOREBIRD_EXPORT"
-10
View File
@@ -1,10 +0,0 @@
[package]
name = "cli"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
clap = { version = "4.1.6", features = ["derive"] }
updater = { path = "../library" }
-66
View File
@@ -1,66 +0,0 @@
extern crate updater;
use clap::{Parser, Subcommand};
#[derive(Parser)]
#[command(author, version, about, long_about = None, arg_required_else_help=true)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
}
#[derive(Subcommand)]
enum Commands {
Check {},
Current {},
Update {},
}
fn main() {
let cli = Cli::parse();
let config = updater::AppConfig {
cache_dir: "updater_cache".to_owned(),
release_version: "0.1.0".to_owned(),
original_libapp_paths: vec!["libapp.so".to_owned()],
vm_path: "libflutter.so".to_owned(),
};
let yaml_str = "
app_id: demo
channel: stable
base_url: http://localhost:8000
";
updater::init(config, yaml_str).expect("init failed");
// You can check for the existence of subcommands, and if found use their
// matches just as you would the top level cmd
match &cli.command {
Some(Commands::Check {}) => {
let needs_update = updater::check_for_update();
println!("Checking for update...");
if needs_update {
println!("Update needed.");
} else {
println!("No update needed.");
}
}
Some(Commands::Current {}) => {
let version = updater::active_patch();
println!("Current version info:");
match version {
Some(v) => {
println!("path: {:?}", v.path);
println!("number: {:?}", v.number);
}
None => {
println!("None");
}
}
}
Some(Commands::Update {}) => {
let status = updater::update();
println!("Update: {}", status);
}
None => {}
}
}
-7
View File
@@ -1,7 +0,0 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
# Avoid committing pubspec.lock for library packages; see
# https://dart.dev/guides/libraries/private-files#pubspeclock.
pubspec.lock
-6
View File
@@ -1,6 +0,0 @@
# dart_bindings
Dart bindings for the updater library.
This presumably eventually either gets published to pub.dev, or more likely
ends up as dart:shorebird or something included with the Shorebird SDK.
@@ -1,30 +0,0 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
-116
View File
@@ -1,116 +0,0 @@
// This entire file could be easily autogenerated.
// Probably https://pub.dev/packages/ffigen would work.
import 'dart:ffi' as ffi;
import 'package:ffi/ffi.dart';
// This must be kept in sync with the C struct in updater.h.
// Including *in the same order* as the C struct.
class AppParameters extends ffi.Struct {
external ffi.Pointer<Utf8> channel;
// ignore: non_constant_identifier_names
external ffi.Pointer<Utf8> app_id;
// ignore: non_constant_identifier_names
external ffi.Pointer<Utf8> base_version;
// ignore: non_constant_identifier_names
external ffi.Pointer<Utf8> update_url;
// ignore: non_constant_identifier_names
external ffi.Pointer<ffi.Pointer<Utf8>> original_libapp_paths;
@ffi.Int8()
// ignore: non_constant_identifier_names
external int original_libapp_paths_size;
// ignore: non_constant_identifier_names
external ffi.Pointer<Utf8> vm_path;
// ignore: non_constant_identifier_names
external ffi.Pointer<Utf8> cache_dir;
static ffi.Pointer<AppParameters> allocate({
required String appId,
required String version,
required String channel,
required String? updateUrl,
required List<String> libappPaths,
required String libflutterPath,
required String cacheDir,
}) {
var config = calloc<AppParameters>();
config.ref.app_id = appId.toNativeUtf8();
config.ref.base_version = version.toNativeUtf8();
config.ref.channel = channel.toNativeUtf8();
if (updateUrl != null) {
config.ref.update_url = updateUrl.toNativeUtf8();
}
config.ref.original_libapp_paths = calloc<ffi.Pointer<Utf8>>(
libappPaths.length,
);
for (var i = 0; i < libappPaths.length; i++) {
config.ref.original_libapp_paths[i] = libappPaths[i].toNativeUtf8();
}
config.ref.vm_path = libflutterPath.toNativeUtf8();
config.ref.cache_dir = cacheDir.toNativeUtf8();
return config;
}
static void free(ffi.Pointer<AppParameters> config) {
calloc.free(config.ref.app_id);
calloc.free(config.ref.base_version);
calloc.free(config.ref.channel);
calloc.free(config.ref.update_url);
// Free all paths in original_libapp_path.
for (var i = 0; i < config.ref.original_libapp_paths_size; i++) {
calloc.free(config.ref.original_libapp_paths[i]);
}
calloc.free(config.ref.original_libapp_paths);
calloc.free(config.ref.vm_path);
calloc.free(config.ref.cache_dir);
calloc.free(config);
}
}
typedef _GetBoolFunc = ffi.Bool Function();
typedef GetBool = bool Function();
typedef _GetStringFunc = ffi.Pointer<Utf8> Function();
typedef GetString = ffi.Pointer<Utf8> Function();
typedef _GetVoidFunc = ffi.Void Function();
typedef GetVoid = void Function();
typedef _SBInitFunc = ffi.Void Function(ffi.Pointer<AppParameters> config);
typedef SBInit = void Function(ffi.Pointer<AppParameters> config);
typedef _FreeStringFunc = ffi.Void Function(ffi.Pointer<Utf8> str);
typedef FreeString = void Function(ffi.Pointer<Utf8> str);
class UpdaterBindings {
final ffi.DynamicLibrary library;
late SBInit init;
late GetBool checkForUpdate;
late GetString activeVersion;
late GetString activePath;
late FreeString freeString;
late GetVoid update;
UpdaterBindings(this.library) {
// None of these call back into Dart, so they're all safely "isLeaf: true".
init = library.lookupFunction<_SBInitFunc, SBInit>('shorebird_init',
isLeaf: true);
activeVersion = library.lookupFunction<_GetStringFunc, GetString>(
'shorebird_active_version',
isLeaf: true);
activePath = library.lookupFunction<_GetStringFunc, GetString>(
'shorebird_active_path',
isLeaf: true);
freeString = library.lookupFunction<_FreeStringFunc, FreeString>(
'shorebird_free_string',
isLeaf: true);
checkForUpdate = library.lookupFunction<_GetBoolFunc, GetBool>(
'shorebird_check_for_update',
isLeaf: true);
update = library.lookupFunction<_GetVoidFunc, GetVoid>('shorebird_update',
isLeaf: true);
}
}
-108
View File
@@ -1,108 +0,0 @@
import 'dart:ffi' as ffi;
import 'dart:io';
import 'package:ffi/ffi.dart';
import 'package:path/path.dart' as path;
import 'src/bindings.dart';
class Updater {
Updater();
static UpdaterBindings? _bindings;
static ffi.DynamicLibrary _loadLibraryInDirectory(
{required String directory, required String name}) {
if (Platform.isMacOS) {
return ffi.DynamicLibrary.open(path.join(directory, 'lib$name.dylib'));
}
if (Platform.isWindows) {
return ffi.DynamicLibrary.open(path.join(directory, '$name.dll'));
}
// Assume everything else follows the Linux pattern.
return ffi.DynamicLibrary.open(path.join(directory, 'lib$name.so'));
}
static loadLibrary({required String name, required String directory}) {
if (_bindings != null) {
throw Exception('Library already loaded.');
}
final updater = _loadLibraryInDirectory(directory: directory, name: name);
_bindings = UpdaterBindings(updater);
}
static loadFlutterLibrary() {
if (_bindings != null) {
throw Exception('Library already loaded.');
}
final updater = ffi.DynamicLibrary.process();
_bindings = UpdaterBindings(updater);
}
static UpdaterBindings get bindings {
if (_bindings == null) {
throw Exception('Must call loadLibrary() first.');
}
return _bindings!;
}
// This is only used when called from a Dart command line.
// Shorebird will have initialized the library already for you when
// inside a Flutter app.
static void initUpdaterLibrary({
required String appId,
required String version,
required String channel,
required String? updateUrl,
required List<String> baseLibraryPaths,
required String vmPath,
required String cacheDir,
}) {
var config = AppParameters.allocate(
appId: appId,
version: version,
channel: channel,
updateUrl: updateUrl,
libappPaths: baseLibraryPaths,
libflutterPath: vmPath,
cacheDir: cacheDir,
);
try {
bindings.init(config);
} finally {
AppParameters.free(config);
}
}
bool checkForUpdate() {
return bindings.checkForUpdate();
}
void update() {
return bindings.update();
}
String? _returnsMaybeString(ffi.Pointer<Utf8> Function() f) {
ffi.Pointer<Utf8> cString = ffi.Pointer<Utf8>.fromAddress(0);
cString = f();
if (cString.address == 0) {
return null;
}
try {
return cString.toDartString();
} finally {
// Using finally for two reasons:
// 1. it runs after the return (saving us a local)
// 2. it runs even if toDartString throws (which it shouldn't)
bindings.freeString(cString);
}
}
String? activeVersion() {
return _returnsMaybeString(bindings.activeVersion);
}
String? activePath() {
return _returnsMaybeString(bindings.activePath);
}
}
-16
View File
@@ -1,16 +0,0 @@
name: dart_bindings
description: dart bindings for Shorebird updater library
version: 1.0.0
environment:
sdk: '>=2.19.0 <4.0.0'
# Add regular dependencies here.
dependencies:
ffi: ^2.0.1
# path version must match that depended on by Flutter SDK.
path: ^1.8.2
dev_dependencies:
lints: ^2.0.0
test: ^1.21.0
-4
View File
@@ -1,4 +0,0 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
pubspec.lock
-1
View File
@@ -1 +0,0 @@
Command line application to test ffi wrapping of updater library.
-30
View File
@@ -1,30 +0,0 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.
include: package:lints/recommended.yaml
# Uncomment the following section to specify additional rules.
# linter:
# rules:
# - camel_case_types
# analyzer:
# exclude:
# - path/to/excluded/files/**
# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints
# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
-137
View File
@@ -1,137 +0,0 @@
import 'dart:convert';
import 'dart:io';
import 'package:args/command_runner.dart';
import 'package:dart_bindings/updater.dart';
import 'package:path/path.dart' as path;
void main(List<String> args) async {
var directory = path.join(Directory.current.path, 'target', 'debug');
Updater.loadLibrary(directory: directory, name: "updater");
Updater.initUpdaterLibrary(
appId: 'demo',
version: '1.0.0',
channel: 'stable',
updateUrl: null,
baseLibraryPaths: ['libapp.so'],
vmPath: Platform.executable,
cacheDir: 'updater_cache',
);
var updater = Updater();
final runner = CommandRunner<void>('updater', 'Updater CLI')
..addCommand(CheckForUpdate(updater))
..addCommand(PrintVersion(updater))
..addCommand(PrintPath(updater))
..addCommand(Update(updater))
..addCommand(Run(updater));
await runner.run(args);
}
class CheckForUpdate extends Command<void> {
final Updater updater;
CheckForUpdate(this.updater);
@override
final name = 'check';
@override
final description = 'Check for an update.';
@override
void run() {
var result = updater.checkForUpdate();
if (result) {
print('Update available');
} else {
print('No update available');
}
}
}
class PrintVersion extends Command<void> {
final Updater updater;
PrintVersion(this.updater);
@override
final name = 'version';
@override
final description = 'Print current installed version.';
@override
void run() {
print(updater.activeVersion());
}
}
class PrintPath extends Command<void> {
final Updater updater;
PrintPath(this.updater);
@override
final name = 'path';
@override
final description = 'Print current installed path.';
@override
void run() {
print(updater.activePath());
}
}
class Update extends Command<void> {
final Updater updater;
Update(this.updater);
@override
final name = 'update';
@override
final description = 'Update to the latest version.';
@override
void run() {
updater.update();
}
}
class Run extends Command<void> {
final Updater updater;
Run(this.updater) {
argParser.addFlag('update', abbr: 'u', help: 'Update before running.');
}
@override
final name = 'run';
@override
final description = 'Run the active version.';
@override
void run() async {
// This is a basic demo of what this might look like.
// Real callers wouldn't likely do this from Dart as there is no need
// to have two copies of the Dart VM running.
if (argResults!['update']) {
updater.update();
}
var path = updater.activePath();
if (path == null) {
print('No active version (should run the bundled version)');
return;
}
// Should this run update first?
print('Running $path');
// Is there a portable way to just "exec" and replace the current process?
var process = await Process.start(Platform.executable, ['run', path]);
process.stdout.transform(utf8.decoder).forEach(stdout.write);
process.stderr.transform(utf8.decoder).forEach(stderr.write);
// TODO: Handle stdin.
exit(await process.exitCode);
}
}
-18
View File
@@ -1,18 +0,0 @@
name: dart_cli
description: A sample command-line application.
version: 1.0.0
# repository: https://github.com/my_org/my_repo
publish_to: 'none'
environment:
sdk: ">=2.19.0 <4.0.0"
dependencies:
args: ^2.4.0
dart_bindings:
path: ../dart_bindings
path: ^1.8.3
dev_dependencies:
lints: ^2.0.0
test: ^1.21.0
-14
View File
@@ -1,14 +0,0 @@
# 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
-50
View File
@@ -1,50 +0,0 @@
[package]
name = "updater"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib]
# "lib" is used by the "cli" target for testing from Rust
# "cdylib" is used by the "dart_cli" target for testing from Dart
# "staticlib" is used by the engine build for linking into libflutter.so
crate-type = ["lib", "cdylib", "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 error handling for now.
anyhow = {version = "1.0.69", features = ["backtrace"]}
# For error!(), info!(), etc macros. `print` will not show up on Android.
log = "0.4.14"
# For implementing thread-local-storage of ResolvedConfig object.
once_cell = "1.17.1"
# For reading shorebird.yaml
serde_yaml = "0.9.19"
# For inflating compressed patch files.
bipatch = "1.0.0"
# comde is a wrapper around several compression libraries.
# We only use zstd and could depend on it directly instead.
comde = {version = "0.2.3", default-features = false, features = ["zstandard"]}
# Pipe is a simple in-memory pipe implementation, there might be a std way too?
pipe = "0.4.0"
# For computing hashes of patch files for validation.
sha2 = "0.10.6"
# For decoding the hex-encoded hashes in Patch network responses.
hex = "0.4.3"
[target.'cfg(target_os = "android")'.dependencies]
# For logging to Android logcat.
android_logger = "0.13.0"
# Send panics to log (instead of stderr), thus logcat on Android.
log-panics = { version = "2", features = ["with-backtrace"]}
[dev-dependencies]
tempdir = "0.3.7"
-175
View File
@@ -1,175 +0,0 @@
# Shorebird CodePush Updater
The rust library that does the actual update work.
## Design
The updater library is built in Rust for safety (and modernity). It's built
as a C-compatible library, so it can be used from any language.
The library is thread-safe, as it needs to be called both from the flutter_main
thread (during initialization) and then later from the Dart/UI thread
(from application Dart code) in Flutter.
The overarching principle with the Updater is "first, do no harm". The updater
should "fail open", terms of continuing to work with the currently installed
or active version of the application even when the network is unavailable.
The updater also needs to handle error cases conservatively, such as partial
downloads from a server, or malformed responses (e.g. a proxy interfering)
and not crash the application or leave the application in a broken state.
Every time the updater runs it needs to verify that the currently installed
patch is compatible with the currently installed base version. If it is not,
it should refuse to return paths to incompatible patches.
The updater also needs to regularly verify that the current state directory
is in a consistent state. If it is not, it should invalidate any installed
patches and return to a clean state.
Not all of the above is implemented yet, but such is the intent.
## Architecture
The updater is split into separate layers. The top layer is the C-compatible
API, which is used by all consumers of the updater. The C-compatible API
is a thin wrapper around the Rust API, which is the main implementation but
only used directly for testing (see the `cli` directory).
Thread safety is handled by a global configuration object that is locked
when accessed. It's possible I've missed cases where this is not sufficient,
and there could be thread safety issues in the library.
* src/c_api.rs - C-compatible API
* src/lib.rs - Rust API (and crate root)
* src/update.rs - Core updater logic
* src/config.rs - In memory configuration and thread locking
* src/cache.rs - On-disk state management
* src/logging.rs - Logging configuration (for platforms that need it)
* src/network.rs - Logic dealing with network requests and updater server
## Rust
We use normal rust idioms (e.g. Result) inside the library and then bridge those
to C via an explicit stable C API (explicit enums, null pointers for optional
arguments, etc). The reason for this is that it lets the Rust code feel natural
and also gives us maximum flexibility in the future for exposing more in the C
API without having to refactor the internals of the library.
https://docs.rust-embedded.org/book/interoperability/rust-with-c.html
are docs on how to use Rust from C (what we're doing).
https://github.com/RubberDuckEng/safe_wren has an example of building in Rust
and exposing it with a C api.
## Integration
The updater library is built as a static library, and is linked into the
libflutter.so as part of a custom build of Flutter. We also link libflutter.so
with the correct flags such that updater symbols are exposed to Dart.
The `dart_bindings` directory contains the Dart bindings for the updater
library.
## Building for Android
The best way I found was to install:
https://github.com/bbqsrc/cargo-ndk
```
cargo install cargo-ndk
rustup target add \
aarch64-linux-android \
armv7-linux-androideabi \
x86_64-linux-android \
i686-linux-android
cargo ndk -t armeabi-v7a -t arm64-v8a build --release
```
When building to include with libflutter.so, you need to build with the same
version of the ndk as Flutter is using:
You'll need to have a Flutter engine checkout already setup and synced.
As part of `gclient sync` the Flutter engine repo will pull down a copy of the
ndk into `src/third_party/android_tools/ndk`.
Then you can set the NDK_HOME environment variable to point to that directory.
e.g.:
```
NDK_HOME=$HOME/Documents/GitHub/engine/src/third_party/android_tools/ndk
```
Then you can build the updater library as above. If you don't want to change
your NDK_HOME, you can also set the environment variable for just the one call:
```
NDK_HOME=$HOME/Documents/GitHub/engine/src/third_party/android_tools/ndk cargo ndk -t armeabi-v7a -t arm64-v8a build --release
```
## Development
Uses cbindgen to generate the header file.
It isn't currently wired into the build process, so you'll need to run it
manually if you change the API.
https://github.com/shorebirdtech/shorebird/issues/121
```
cargo install cbindgen
cbindgen --config cbindgen.toml --crate updater --output library/include/updater.h
```
## Imagined Architecture (not all implemented)
### Assumptions (not all enforced yet)
* Updater library is never allowed to crash, except on bad parameters from C.
* Network and Disk are untrusted.
* Running code is trusted.
* Store-installed bundle is trusted (e.g. APK).
* Updates are signed by a trusted key.
* Updates must be applied in order.
* Updates are applied in a single transaction.
### Update State Machine
* Server is authoritative, regarding current update/patch state. Client can
cache state in memory. Not written to disk.
* Patches are downloaded to a temporary location on disk.
* Update State Machine:
* `ready`: Just woke up, ready to check for updates.
* `checking`: Checking for updates.
* `update_available`: Update or rollback is available.
* `no_update_available`: No update is available.
* `downloading`: Downloading an update.
* `downloaded`: Downloaded an update.
* Client keeps on disk:
* cache of patches in "slots"
* cache of in-progress download state.
* Last booted patch (may not have been successful).
* Last successful patch (never rolled back from unless becomes invalid).
* Boot State Machine:
* `ready`: Just woke up, ready to boot.
* `booting`: Booting a patch.
* `booted`: Patch is booted, we will not go back from here.
### Slot State Machine
* Patches are cached on disk in "slots".
* There is a currently active slot (the one that is booted).
* Patches are identified by base revision + patch number.
* A given slot is:
* `empty`: No update is installed.
* `pending`: An update is installed but has not been validated.
* `valid`: An update is installed and has been validated.
* Validation is a temporary state. Patches/slots are revalidated on boot.
### Trust model
* Network and Disk are untrusted.
* Running software (including apk service) is trusted.
* Patch contents are signed, public key is included in the APK.
## TODO:
* Add an async API.
* Write tests for state management.
* Make state management/filesystem management atomic (and tested).
* Support validating patches/slots (hashes, signatures, etc).
## Later-stage update system design docs
* https://theupdateframework.io/
* https://fuchsia.dev/fuchsia-src/concepts/packages/software_update_system
-101
View File
@@ -1,101 +0,0 @@
#ifndef updater_h
#define updater_h
/* Warning, this file is autogenerated by cbindgen. Don't modify this manually. */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>
#ifdef _WIN32
#define SHOREBIRD_EXPORT __declspec(dllexport)
#else
#define SHOREBIRD_EXPORT __attribute__((visibility("default")))
#endif
/**
* Struct containing configuration parameters for the updater.
* Passed to all updater functions.
* NOTE: If this struct is changed all language bindings must be updated.
*/
typedef struct AppParameters {
/**
* release_version, required. Named version of the app, off of which updates
* are based. Can be either a version number or a hash.
*/
const char *release_version;
/**
* Array of paths to the original aot library, required. For Flutter apps
* these are the paths to the bundled libapp.so. May be used for compression downloaded artifacts.
*/
const char *const *original_libapp_paths;
/**
* Length of the original_libapp_paths array.
*/
int original_libapp_paths_size;
/**
* Path to the app's libflutter.so, required. May be used for ensuring
* downloaded artifacts are compatible with the Flutter/Dart versions
* used by the app. For Flutter apps this should be the path to the
* bundled libflutter.so. For Dart apps this should be the path to the
* dart executable.
*/
const char *vm_path;
/**
* Path to cache_dir where the updater will store downloaded artifacts.
*/
const char *cache_dir;
} AppParameters;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
/**
* Configures updater. First parameter is a struct containing configuration
* from the running app. Second parameter is a YAML string containing
* configuration compiled into the app.
*/
SHOREBIRD_EXPORT
void shorebird_init(const struct AppParameters *c_params,
const char *c_yaml);
/**
* Return the active patch number, or NULL if there is no active patch.
*/
SHOREBIRD_EXPORT char *shorebird_active_patch_number(void);
/**
* Return the path to the active patch for the app, or NULL if there is no
* active patch.
*/
SHOREBIRD_EXPORT char *shorebird_active_path(void);
/**
* Free a string returned by the updater library.
*/
SHOREBIRD_EXPORT void shorebird_free_string(char *c_string);
/**
* Check for an update. Returns true if an update is available.
*/
SHOREBIRD_EXPORT bool shorebird_check_for_update(void);
/**
* Synchronously download an update if one is available.
*/
SHOREBIRD_EXPORT void shorebird_update(void);
/**
* Report that the app failed to launch. This will cause the updater to
* attempt to roll back to the previous version if this version has not
* been launched successfully before.
*/
SHOREBIRD_EXPORT void shorebird_report_failed_launch(void);
#ifdef __cplusplus
} // extern "C"
#endif // __cplusplus
#endif /* updater_h */
-146
View File
@@ -1,146 +0,0 @@
// This file handles translating the updater library's types into C types.
// Currently manually prefixing all functions with "shorebird_" to avoid
// name collisions with other libraries.
// cbindgen:prefix-with-name could do this for us.
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use crate::updater;
/// Struct containing configuration parameters for the updater.
/// Passed to all updater functions.
/// NOTE: If this struct is changed all language bindings must be updated.
#[repr(C)]
pub struct AppParameters {
/// release_version, required. Named version of the app, off of which updates
/// are based. Can be either a version number or a hash.
pub release_version: *const libc::c_char,
/// Array of paths to the original aot library, required. For Flutter apps
/// these are the paths to the bundled libapp.so. May be used for compression downloaded artifacts.
pub original_libapp_paths: *const *const libc::c_char,
/// Length of the original_libapp_paths array.
pub original_libapp_paths_size: libc::c_int,
/// Path to the app's libflutter.so, required. May be used for ensuring
/// downloaded artifacts are compatible with the Flutter/Dart versions
/// used by the app. For Flutter apps this should be the path to the
/// bundled libflutter.so. For Dart apps this should be the path to the
/// dart executable.
pub vm_path: *const libc::c_char,
/// Path to cache_dir where the updater will store downloaded artifacts.
pub cache_dir: *const libc::c_char,
}
fn to_rust(c_string: *const libc::c_char) -> String {
unsafe { CStr::from_ptr(c_string).to_str().unwrap() }.to_string()
}
fn to_rust_vector(c_array: *const *const libc::c_char, size: libc::c_int) -> Vec<String> {
let mut result = Vec::new();
for i in 0..size {
let c_string = unsafe { *c_array.offset(i as isize) };
result.push(to_rust(c_string));
}
result
}
fn app_config_from_c(c_params: *const AppParameters) -> updater::AppConfig {
let c_params_ref = unsafe { &*c_params };
updater::AppConfig {
cache_dir: to_rust(c_params_ref.cache_dir),
release_version: to_rust(c_params_ref.release_version),
original_libapp_paths: to_rust_vector(
c_params_ref.original_libapp_paths,
c_params_ref.original_libapp_paths_size,
),
vm_path: to_rust(c_params_ref.vm_path),
}
}
/// Configures updater. First parameter is a struct containing configuration
/// from the running app. Second parameter is a YAML string containing
/// configuration compiled into the app.
#[no_mangle]
pub extern "C" fn shorebird_init(c_params: *const AppParameters, c_yaml: *const libc::c_char) {
let config = app_config_from_c(c_params);
let yaml_string = to_rust(c_yaml);
let result = updater::init(config, &yaml_string);
match result {
Ok(_) => {}
Err(e) => {
error!("Error initializing updater: {:?}", e);
}
}
}
/// Return the active patch number, or NULL if there is no active patch.
#[no_mangle]
pub extern "C" fn shorebird_active_patch_number() -> *mut c_char {
let patch = updater::active_patch();
match patch {
Some(v) => {
let c_patch = CString::new(v.number.to_string()).unwrap();
c_patch.into_raw()
}
None => std::ptr::null_mut(),
}
}
/// Return the path to the active patch for the app, or NULL if there is no
/// active patch.
#[no_mangle]
// rename to shorebird_patch_path
pub extern "C" fn shorebird_active_path() -> *mut c_char {
let version = updater::active_patch();
match version {
Some(v) => {
let c_version = CString::new(v.path).unwrap();
c_version.into_raw()
}
None => std::ptr::null_mut(),
}
}
/// Free a string returned by the updater library.
#[no_mangle]
pub extern "C" fn shorebird_free_string(c_string: *mut c_char) {
unsafe {
if c_string.is_null() {
return;
}
drop(CString::from_raw(c_string));
}
}
/// Check for an update. Returns true if an update is available.
#[no_mangle]
pub extern "C" fn shorebird_check_for_update() -> bool {
return updater::check_for_update();
}
/// Synchronously download an update if one is available.
#[no_mangle]
pub extern "C" fn shorebird_update() {
updater::update();
}
/// Report that the app failed to launch. This will cause the updater to
/// attempt to roll back to the previous version if this version has not
/// been launched successfully before.
#[no_mangle]
pub extern "C" fn shorebird_report_failed_launch() {
let result = updater::report_failed_launch();
match result {
Ok(_) => {}
Err(e) => {
error!("Error recording launch failure: {:?}", e);
}
}
}
-362
View File
@@ -1,362 +0,0 @@
// This file deals with the cache / state management for the updater.
use std::fs::File;
use std::io::{BufReader, BufWriter};
use std::path::{Path, PathBuf};
use anyhow::Ok;
use serde::{Deserialize, Serialize};
use crate::updater::UpdateError;
#[derive(PartialEq, Debug)]
pub struct PatchInfo {
pub path: String,
pub number: usize,
}
#[derive(Deserialize, Serialize, Default, Clone, Debug)]
struct Slot {
/// Path to the slot directory.
path: String,
/// Patch number for the patch in this slot.
patch_number: usize,
}
impl Slot {
fn to_patch_info(&self) -> PatchInfo {
PatchInfo {
path: self.path.clone(),
number: self.patch_number,
}
}
}
// This struct is public, as callers can have a handle to it, but modifying
// anything inside should be done via the functions below.
#[derive(Deserialize, Serialize)]
pub struct UpdaterState {
/// Where this writes to disk.
cache_dir: String,
/// The release version this cache corresponds to.
/// If this does not match the release version we're booting from we will
/// clear the cache.
release_version: String,
/// The patch number of the patch that was last downloaded.
latest_downloaded_patch: Option<usize>,
/// List of patches that failed to boot. We will never attempt these again.
failed_patches: Vec<usize>,
/// List of patches that successfully booted. We will never rollback past
/// one of these for this device.
successful_patches: Vec<usize>,
/// Currently selected slot.
current_slot_index: Option<usize>,
/// List of slots.
slots: Vec<Slot>,
// Add file path or FD so modifying functions can save it to disk?
}
impl UpdaterState {
fn new(cache_dir: String, release_version: String) -> Self {
Self {
cache_dir,
release_version,
current_slot_index: None,
latest_downloaded_patch: None,
failed_patches: Vec::new(),
successful_patches: Vec::new(),
slots: Vec::new(),
}
}
}
impl UpdaterState {
pub fn is_known_good_patch(&self, patch: &PatchInfo) -> bool {
self.successful_patches.iter().any(|v| v == &patch.number)
}
pub fn is_known_bad_patch(&self, patch: &PatchInfo) -> bool {
self.failed_patches.iter().any(|v| v == &patch.number)
}
pub fn mark_patch_as_bad(&mut self, patch: &PatchInfo) {
if self.is_known_good_patch(patch) {
warn!("Tried to report failed launch for a known good patch. Ignoring.");
return;
}
if self.is_known_bad_patch(patch) {
return;
}
info!("Marking patch {} as bad", patch.number);
self.failed_patches.push(patch.number.clone());
}
pub fn mark_patch_as_good(&mut self, patch: &PatchInfo) {
if self.is_known_bad_patch(patch) {
warn!("Tried to report successful launch for a known bad patch. Ignoring.");
return;
}
if self.is_known_good_patch(patch) {
return;
}
self.successful_patches.push(patch.number.clone());
}
fn load(cache_dir: &str) -> anyhow::Result<Self> {
// Load UpdaterState from disk
let path = Path::new(cache_dir).join("state.json");
let file = File::open(path)?;
let reader = BufReader::new(file);
// TODO: Now that we depend on serde_yaml for shorebird.yaml
// we could use yaml here instead of json.
let state = serde_json::from_reader(reader)?;
Ok(state)
}
pub fn load_or_new_on_error(cache_dir: &str, release_version: &str) -> Self {
let loaded = Self::load(cache_dir).unwrap_or_else(|e| {
warn!("Failed to load updater state: {}", e);
Self::new(cache_dir.to_owned(), release_version.to_owned())
});
if loaded.release_version != release_version {
warn!("Release version changed, clearing updater state");
Self::new(cache_dir.to_owned(), release_version.to_owned())
} else {
loaded
}
}
pub fn save(&self) -> anyhow::Result<()> {
// Save UpdaterState to disk
std::fs::create_dir_all(&self.cache_dir)?;
let path = Path::new(&self.cache_dir).join("state.json");
let file = File::create(path)?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, self)?;
Ok(())
}
/// This is NOT the current booted path (we don't keep that in memory yet).
/// This is the patch that is selected in the state.json, which may or may
/// not be the one that is booted, but will be the one used next boot.
pub fn current_patch(&self) -> Option<PatchInfo> {
if self.slots.is_empty() {
return None;
}
if let Some(slot_index) = self.current_slot_index {
if slot_index >= self.slots.len() {
return None;
}
let slot = &self.slots[slot_index];
// Otherwise return the version info from the current slot.
return Some(slot.to_patch_info());
}
None
}
fn validate_slot(&self, slot: &Slot) -> bool {
// Check if the patch is known bad.
if self.is_known_bad_patch(&slot.to_patch_info()) {
return false;
}
if PathBuf::from(&slot.path).exists() {
return true;
}
// TODO: This should also check if the hash matches?
// let hash = compute_hash(&PathBuf::from(&slot.path));
// if let Ok(hash) = hash {
// if hash == slot.hash {
// return true;
// }
// error!("Hash mismatch for slot: {:?}", slot);
// }
false
}
fn latest_bootable_slot(&self) -> Option<usize> {
// Find the latest slot that has a patch that is not bad.
// Sort the slots by patch number, then return the highest
// patch number that is not bad.
let mut slots = self.slots.clone();
slots.sort_by(|a, b| a.patch_number.cmp(&b.patch_number));
slots.reverse();
for slot in slots {
if self.validate_slot(&slot) {
return Some(slot.patch_number);
}
}
None
}
pub fn activate_latest_bootable_patch(&mut self) -> Result<(), UpdateError> {
self.set_current_slot(self.latest_bootable_slot());
self.save().map_err(|_| UpdateError::FailedToSaveState)
}
fn available_slot(&self) -> usize {
// Assume we only use two slots and pick the one that's not current.
if self.slots.is_empty() {
return 0;
}
if let Some(slot_index) = self.current_slot_index {
if slot_index == 0 {
return 1;
}
}
return 0;
}
fn clear_slot(&mut self, index: usize) {
if self.slots.len() < index + 1 {
return;
}
self.slots[index] = Slot::default();
}
fn set_slot(&mut self, index: usize, slot: Slot) {
info!("Setting slot {} to {:?}", index, slot);
if self.slots.len() < index + 1 {
// Make sure we're not filling with empty slots.
assert!(self.slots.len() == index);
self.slots.resize(index + 1, Slot::default());
}
// Set the given slot to the given version.
self.slots[index] = slot
}
fn slot_dir(&self, index: usize) -> String {
Path::new(&self.cache_dir)
.join(format!("slot_{}", index))
.to_str()
.unwrap()
.to_owned()
}
pub fn install_patch(&mut self, patch: PatchInfo) -> anyhow::Result<()> {
let slot_index = self.available_slot();
let slot_dir_string = self.slot_dir(slot_index);
let slot_dir = PathBuf::from(&slot_dir_string);
// Clear the slot.
self.clear_slot(slot_index); // Invalidate the slot.
self.save()?;
if slot_dir.exists() {
std::fs::remove_dir_all(&slot_dir)?;
}
std::fs::create_dir_all(&slot_dir)?;
if self.is_known_bad_patch(&patch) {
return Err(UpdateError::InvalidArgument(
"patch".to_owned(),
format!("Refusing to install known bad patch: {:?}", patch),
)
.into());
}
// Move the artifact into the slot.
let artifact_path = slot_dir.join("dlc.vmcode");
std::fs::rename(&patch.path, &artifact_path)?;
// Update the state to include the new slot.
self.set_slot(
slot_index,
Slot {
path: artifact_path.to_str().unwrap().to_owned(),
patch_number: patch.number,
},
);
self.set_current_slot(Some(slot_index));
if (self.latest_downloaded_patch.is_none())
|| (self.latest_downloaded_patch.unwrap() < patch.number)
{
self.latest_downloaded_patch = Some(patch.number);
} else {
warn!(
"Installed patch {} but latest downloaded patch is {:?}",
patch.number, self.latest_downloaded_patch
);
}
self.save()?;
Ok(())
}
pub fn set_current_slot(&mut self, maybe_index: Option<usize>) {
self.current_slot_index = maybe_index;
}
pub fn latest_patch_number(&self) -> Option<usize> {
self.latest_downloaded_patch
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use tempdir::TempDir;
use crate::cache::{PatchInfo, UpdaterState};
fn test_state(tmp_dir: &TempDir) -> UpdaterState {
let cache_dir = tmp_dir.path().to_str().unwrap().to_string();
UpdaterState::new(cache_dir, "1.0.0".to_string())
}
fn fake_patch(tmp_dir: &TempDir, number: usize) -> super::PatchInfo {
let path = PathBuf::from(tmp_dir.path()).join(format!("patch_{}", number));
std::fs::write(&path, "fake patch").unwrap();
PatchInfo {
number,
path: path.to_str().unwrap().to_owned(),
}
}
#[test]
fn current_patch_does_not_crash() {
let tmp_dir = TempDir::new("example").unwrap();
let mut state = test_state(&tmp_dir);
assert_eq!(state.current_patch(), None);
state.current_slot_index = Some(3);
assert_eq!(state.current_patch(), None);
state.slots.push(super::Slot::default());
// This used to crash, where index was bad, but slots were not empty.
assert_eq!(state.current_patch(), None);
}
#[test]
fn release_version_changed() {
let tmp_dir = TempDir::new("example").unwrap();
let mut state = test_state(&tmp_dir);
state.latest_downloaded_patch = Some(1);
state.save().unwrap();
let loaded = UpdaterState::load_or_new_on_error(&state.cache_dir, &state.release_version);
assert_eq!(loaded.latest_downloaded_patch, Some(1));
let loaded_after_version_change =
UpdaterState::load_or_new_on_error(&state.cache_dir, "1.0.1");
assert_eq!(loaded_after_version_change.latest_downloaded_patch, None);
}
#[test]
fn latest_downloaded_patch() {
let tmp_dir = TempDir::new("example").unwrap();
let mut state = test_state(&tmp_dir);
assert_eq!(state.latest_downloaded_patch, None);
state.install_patch(fake_patch(&tmp_dir, 1)).unwrap();
assert_eq!(state.latest_downloaded_patch, Some(1));
state.install_patch(fake_patch(&tmp_dir, 2)).unwrap();
assert_eq!(state.latest_downloaded_patch, Some(2));
state.install_patch(fake_patch(&tmp_dir, 1)).unwrap();
assert_eq!(state.latest_downloaded_patch, Some(2));
}
#[test]
fn do_not_install_known_bad_patch() {
let tmp_dir = TempDir::new("example").unwrap();
let mut state = test_state(&tmp_dir);
let bad_patch = fake_patch(&tmp_dir, 1);
state.mark_patch_as_bad(&bad_patch);
assert!(state.install_patch(bad_patch).is_err());
}
}
-114
View File
@@ -1,114 +0,0 @@
// This file handles the global config for the updater library.
use std::sync::Mutex;
use crate::updater::AppConfig;
use crate::yaml::YamlConfig;
use once_cell::sync::OnceCell;
// cbindgen looks for const, ignore these so it doesn't warn about them.
/// cbindgen:ignore
const DEFAULT_BASE_URL: &'static str = "https://api.shorebird.dev";
/// cbindgen:ignore
const DEFAULT_CHANNEL: &'static str = "stable";
fn global_config() -> &'static Mutex<ResolvedConfig> {
static INSTANCE: OnceCell<Mutex<ResolvedConfig>> = OnceCell::new();
INSTANCE.get_or_init(|| Mutex::new(ResolvedConfig::empty()))
}
pub fn with_config<F, R>(f: F) -> R
where
F: FnOnce(&ResolvedConfig) -> R,
{
let lock = global_config()
.lock()
.expect("Failed to acquire updater lock.");
if !lock.is_initialized {
panic!("Must call shorebird_init() before using the updater.");
}
return f(&lock);
}
#[derive(Debug)]
pub struct ResolvedConfig {
is_initialized: bool,
pub cache_dir: String,
pub download_dir: String,
pub channel: String,
pub app_id: String,
pub release_version: String,
pub original_libapp_paths: Vec<String>,
pub vm_path: String,
pub base_url: String,
}
impl ResolvedConfig {
pub fn empty() -> Self {
Self {
is_initialized: false,
cache_dir: String::new(),
download_dir: String::new(),
channel: String::new(),
app_id: String::new(),
release_version: String::new(),
original_libapp_paths: Vec::new(),
vm_path: String::new(),
base_url: String::new(),
}
}
}
pub fn set_config(config: AppConfig, yaml: YamlConfig) {
// If there is no base_url, use the default.
// If there is no channel, use the default.
let mut lock = global_config()
.lock()
.expect("Failed to acquire updater lock.");
lock.base_url = yaml
.base_url
.as_deref()
.unwrap_or(DEFAULT_BASE_URL)
.to_owned();
lock.channel = yaml
.channel
.as_deref()
.unwrap_or(DEFAULT_CHANNEL)
.to_owned();
lock.cache_dir = config.cache_dir.to_string();
let mut cache_path = std::path::PathBuf::from(config.cache_dir);
cache_path.push("downloads");
lock.download_dir = cache_path.to_str().unwrap().to_string();
lock.app_id = yaml.app_id.to_string();
lock.release_version = config.release_version.to_string();
lock.original_libapp_paths = config.original_libapp_paths;
lock.vm_path = config.vm_path.to_string();
lock.is_initialized = true;
info!("Updater configured with: {:?}", lock);
}
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";
return 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";
return PLATFORM;
}
-24
View File
@@ -1,24 +0,0 @@
// This is a required file for rust libraries which declares what files are
// part of the library and what interfaces are public from the library.
// 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 other .rs file/module exists, but make them public.
mod cache;
mod config;
mod logging;
mod network;
mod updater;
mod yaml;
// Take all public items from the updater namespace and make them public.
pub use self::updater::*;
// Exposes error!(), info!(), etc macros.
#[macro_use]
extern crate log;
#[cfg(test)]
extern crate tempdir;
-18
View File
@@ -1,18 +0,0 @@
#[cfg(target_os = "android")]
pub fn init_logging() {
log_panics::init();
android_logger::init_once(
android_logger::Config::default()
// `flutter` tool ignores non-flutter tagged logs.
.with_tag("flutter")
.with_max_level(log::LevelFilter::Debug),
);
debug!("Logging initialized");
}
#[cfg(not(target_os = "android"))]
pub fn init_logging() {
// Nothing to do on non-Android platforms.
// Eventually iOS/MacOS may need something here.
}
-124
View File
@@ -1,124 +0,0 @@
// This file's job is to deal with the update_server and network side
// of the updater library.
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::string::ToString;
use crate::cache::UpdaterState;
use crate::config::{current_arch, current_platform, ResolvedConfig};
fn patches_check_url(base_url: &str) -> String {
return format!("{}/api/v1/patches/check", base_url);
}
#[derive(Debug, Deserialize)]
pub struct Patch {
/// The patch number. Starts at 1 for each new release and increases
/// monotonically.
pub number: usize,
/// The hex-encoded sha256 hash of the final uncompressed patch file.
/// Legacy: originally "#" before we implemented hash checks (remove).
pub hash: String,
/// The URL to download the patch file from.
pub download_url: String,
}
#[derive(Debug, Serialize)]
pub struct PatchCheckRequest {
/// The Shorebird app_id built into the shorebird.yaml in the app.
pub app_id: String,
/// The Shorebird channel built into the shorebird.yaml in the app.
pub channel: String,
/// The release version from AndroidManifest.xml, Info.plist in the app.
pub release_version: String,
/// The latest patch number that the client has downloaded.
/// Not necessarily the one it's running (if some have been marked bad).
/// We could rename this to be more clear.
#[serde(skip_serializing_if = "Option::is_none")]
pub patch_number: Option<usize>,
/// Platform (e.g. "android", "ios", "windows", "macos", "linux").
pub platform: String,
/// Architecture we're running (e.g. "aarch64", "x86", "x86_64").
pub arch: String,
}
#[derive(Debug, Deserialize)]
pub struct PatchCheckResponse {
pub patch_available: bool,
#[serde(default)]
pub patch: Option<Patch>,
}
pub fn send_patch_check_request(
config: &ResolvedConfig,
state: &UpdaterState,
) -> anyhow::Result<PatchCheckResponse> {
let latest_patch_number = state.latest_patch_number();
// Send the request to the server.
let client = reqwest::blocking::Client::new();
let req = PatchCheckRequest {
app_id: config.app_id.clone(),
channel: config.channel.clone(),
release_version: config.release_version.clone(),
patch_number: latest_patch_number,
platform: current_platform().to_string(),
arch: current_arch().to_string(),
};
info!("Sending patch check request: {:?}", req);
let response = client
.post(&patches_check_url(&config.base_url))
.json(&req)
.send()?
.json()?;
info!("Patch check response: {:?}", response);
return Ok(response);
}
pub fn download_to_path(url: &str, path: &Path) -> 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.
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let mut file = File::create(path)?;
file.write_all(&mut bytes)?;
Ok(())
}
#[cfg(test)]
mod tests {
use crate::network::PatchCheckResponse;
#[test]
fn check_patch_request_response_deserialization() {
let data = r###"
{
"patch_available": true,
"patch": {
"number": 1,
"download_url": "https://storage.googleapis.com/patch_artifacts/17a28ec1-00cf-452d-bdf9-dbb9acb78600/dlc.vmcode",
"hash": "#"
}
}"###;
let response: PatchCheckResponse = serde_json::from_str(data).unwrap();
assert!(response.patch_available == true);
assert!(response.patch.is_some());
let patch = response.patch.unwrap();
assert_eq!(patch.number, 1);
assert_eq!(patch.download_url, "https://storage.googleapis.com/patch_artifacts/17a28ec1-00cf-452d-bdf9-dbb9acb78600/dlc.vmcode");
assert_eq!(patch.hash, "#");
}
}
-464
View File
@@ -1,464 +0,0 @@
// This file's job is to be the Rust API for the updater.
use std::fmt::{Display, Formatter};
use crate::cache::{PatchInfo, UpdaterState};
use crate::config::{set_config, with_config, ResolvedConfig};
use crate::logging::init_logging;
use crate::network::{download_to_path, send_patch_check_request};
use crate::yaml::YamlConfig;
use std::path::{Path, PathBuf};
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"),
}
}
}
#[derive(Debug, PartialEq)]
pub enum UpdateError {
InvalidArgument(String, String),
InvalidState(String),
BadServerResponse,
FailedToSaveState,
}
impl std::error::Error for UpdateError {}
impl Display for UpdateError {
fn fmt(&self, f: &mut Formatter) -> std::fmt::Result {
match self {
UpdateError::InvalidArgument(name, value) => {
write!(f, "Invalid Argument: {} -> {}", name, value)
}
UpdateError::InvalidState(msg) => write!(f, "Invalid State: {}", msg),
UpdateError::FailedToSaveState => write!(f, "Failed to save state"),
UpdateError::BadServerResponse => write!(f, "Bad server response"),
}
}
}
// AppConfig is the rust API. ResolvedConfig is the internal storage.
// However rusty api would probably used &str instead of String,
// but making &str from CStr* is a bit of a pain.
pub struct AppConfig {
pub cache_dir: String,
pub release_version: String,
pub original_libapp_paths: Vec<String>,
pub vm_path: String,
}
/// Initialize the updater library.
/// Takes a AppConfig struct and a yaml string.
/// The yaml string is the contents of the shorebird.yaml file.
/// The AppConfig struct is information about the running app and where
/// the updater should keep its cache.
pub fn init(app_config: AppConfig, yaml: &str) -> Result<(), UpdateError> {
init_logging();
let config = YamlConfig::from_yaml(&yaml)
.map_err(|err| UpdateError::InvalidArgument("yaml".to_string(), err.to_string()))?;
set_config(app_config, config);
Ok(())
}
fn check_for_update_internal(config: &ResolvedConfig) -> bool {
// Load UpdaterState from disk
// If there is no state, make an empty state.
let state = UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
// Send info from app + current slot to server.
let response_result = send_patch_check_request(&config, &state);
match response_result {
Err(err) => {
error!("Failed update check: {err}");
return false;
}
Ok(response) => {
return response.patch_available;
}
}
}
/// Synchronously checks for an update and returns true if an update is available.
pub fn check_for_update() -> bool {
return with_config(check_for_update_internal);
}
fn check_hash(path: &Path, expected_string: &str) -> anyhow::Result<bool> {
let result = hex::decode(expected_string);
// Remove this legacy behavior.
if result.is_err() {
warn!("Failed to decode hash from server, allowing: {expected_string}");
return Ok(true);
}
let expected = result.unwrap();
use sha2::{Digest, Sha256};
use std::{fs, io};
// Based on guidance from:
// https://github.com/RustCrypto/hashes#hashing-readable-objects
let mut file = fs::File::open(&path)?;
let mut hasher = Sha256::new();
io::copy(&mut file, &mut hasher)?;
// Check that the length from copy is the same as the file size?
let hash = hasher.finalize();
let hash_matches = hash.as_slice() == expected;
if !hash_matches {
warn!(
"Hash mismatch: {:?}, expected: {}, got: {:?}",
path,
expected_string,
hex::encode(hash)
);
} else {
info!("Hash match: {:?}", path);
}
return Ok(hash_matches);
}
fn update_internal(config: &ResolvedConfig) -> anyhow::Result<UpdateStatus> {
// Load the state from disk.
let mut state = UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
// Check for update.
let response = send_patch_check_request(&config, &state)?;
if !response.patch_available {
return Ok(UpdateStatus::NoUpdate);
}
let patch = response.patch.ok_or(UpdateError::BadServerResponse)?;
let download_dir = PathBuf::from(&config.cache_dir);
let download_path = download_dir.join(patch.number.to_string());
download_to_path(&patch.download_url, &download_path)?;
let base_path = get_base_path(&config.original_libapp_paths)?;
let output_path = download_dir.join(format!("{}.full", patch.number.to_string()));
inflate(&download_path, &base_path, &output_path)?;
// Check the hash before moving into place.
let hash_ok = check_hash(&output_path, &patch.hash)?;
if !hash_ok {
return Err(UpdateError::InvalidState("Hash mismatch".to_string()).into());
}
// Move/state update should be "atomic".
// Consider supporting allowing the system to download for us (e.g. iOS).
let patch_info = PatchInfo {
path: output_path.to_str().unwrap().to_string(),
number: patch.number,
};
state.install_patch(patch_info)?;
info!("Patch {} successfully installed.", patch.number);
// Set the state to "restart required".
return Ok(UpdateStatus::UpdateInstalled);
}
fn get_base_path(original_lib_app_paths: &Vec<String>) -> anyhow::Result<PathBuf> {
// Iterate through the paths and find the first one that exists.
for path in original_lib_app_paths {
let path = PathBuf::from(path);
match path.try_exists() {
Ok(true) => {
return Ok(path);
}
Ok(false) => {
info!("File does not exist: {:?}", path);
continue;
}
Err(err) => {
info!("Failed to check for file: {:?}, err: {err}", path);
continue;
}
}
}
return Err(UpdateError::InvalidState("No base file found".to_string()).into());
}
fn inflate(patch_path: &Path, base_path: &Path, output_path: &Path) -> anyhow::Result<()> {
info!("Patch is compressed, inflating...");
use anyhow::Context;
use comde::de::Decompressor;
use comde::zstd::ZstdDecompressor;
use std::fs::File;
use std::io::{BufReader, BufWriter};
// Open all our files first for error clarity. Otherwise we might see
// PipeReader/Writer errors instead of file open errors.
info!("Reading base file: {:?}", base_path);
let base_r =
File::open(base_path).context(format!("Failed to open base file: {:?}", base_path))?;
info!("Reading patch file: {:?}", patch_path);
let compressed_patch_r = BufReader::new(
File::open(patch_path).context(format!("Failed to open patch file: {:?}", patch_path))?,
);
let output_file_w = File::create(&output_path)?;
// Set up a pipe to connect the writing from the decompression thread
// to the reading of the decompressed patch data on this thread.
let (patch_r, patch_w) = pipe::pipe();
let decompress = ZstdDecompressor::new();
// Spawn a thread to run the decompression in parallel to the patching.
// decompress.copy will block on the pipe being full (I think) and then
// when it returns the thread will exit.
std::thread::spawn(move || {
let result = decompress.copy(compressed_patch_r, patch_w);
// If this thread fails, undoubtedly the main thread will fail too.
// Most important is to not crash.
if let Err(err) = result {
error!("Decompression thread failed: {err}");
}
});
// Do the patch, using the uncompressed patch data from the pipe.
let mut fresh_r = bipatch::Reader::new(patch_r, base_r)?;
// Write out the resulting patched file to the new location.
let mut output_w = BufWriter::new(output_file_w);
std::io::copy(&mut fresh_r, &mut output_w)?;
Ok(())
}
/// Reads the current patch from the cache and returns it.
pub fn active_patch() -> Option<PatchInfo> {
return with_config(|config| {
let state = UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
return state.current_patch();
});
}
pub fn report_failed_launch() -> Result<(), UpdateError> {
info!("Reporting failed launch.");
with_config(|config| {
let mut state =
UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
let patch = state
.current_patch()
.ok_or(UpdateError::InvalidState("No current patch".to_string()))?;
state.mark_patch_as_bad(&patch);
state.activate_latest_bootable_patch()
})
}
pub fn report_successful_launch() -> Result<(), UpdateError> {
with_config(|config| {
let mut state =
UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
let patch = state
.current_patch()
.ok_or(UpdateError::InvalidState("No current patch".to_string()))?;
state.mark_patch_as_good(&patch);
state.save().map_err(|_| UpdateError::FailedToSaveState)
})
}
/// Synchronously checks for an update and downloads and installs it if available.
pub fn update() -> UpdateStatus {
return with_config(|config| {
let result = update_internal(&config);
match result {
Err(err) => {
error!("Problem updating: {err}");
error!("{}", err.backtrace());
return UpdateStatus::UpdateHadError;
}
Ok(status) => status,
}
});
}
#[cfg(test)]
mod tests {
use tempdir::TempDir;
fn init_for_testing(tmp_dir: &TempDir) {
let cache_dir = tmp_dir.path().to_str().unwrap().to_string();
crate::init(
crate::AppConfig {
cache_dir: cache_dir.clone(),
release_version: "1.0.0".to_string(),
original_libapp_paths: vec!["original_libapp_path".to_string()],
vm_path: "vm_path".to_string(),
},
"app_id: 1234",
)
.unwrap();
}
#[test]
fn init_missing_yaml() {
let tmp_dir = TempDir::new("example").unwrap();
let cache_dir = tmp_dir.path().to_str().unwrap().to_string();
assert_eq!(
crate::init(
crate::AppConfig {
cache_dir: cache_dir.clone(),
release_version: "1.0.0".to_string(),
original_libapp_paths: vec!["original_libapp_path".to_string()],
vm_path: "vm_path".to_string(),
},
"",
),
Err(crate::UpdateError::InvalidArgument(
"yaml".to_string(),
"missing field `app_id`".to_string()
))
);
}
#[test]
fn report_launch_result_with_no_current_patch() {
let tmp_dir = TempDir::new("example").unwrap();
init_for_testing(&tmp_dir);
assert_eq!(
crate::report_failed_launch(),
Err(crate::UpdateError::InvalidState(
"No current patch".to_string()
))
);
assert_eq!(
crate::report_successful_launch(),
Err(crate::UpdateError::InvalidState(
"No current patch".to_string()
))
);
}
#[test]
fn ignore_version_after_marked_bad() {
let tmp_dir = TempDir::new("example").unwrap();
init_for_testing(&tmp_dir);
use crate::cache::{PatchInfo, UpdaterState};
use crate::config::with_config;
// Install a fake patch.
with_config(|config| {
let download_dir = std::path::PathBuf::from(&config.download_dir);
let artifact_path = download_dir.join("1");
println!("artifact_path: {:?}", artifact_path);
std::fs::create_dir_all(&download_dir).unwrap();
std::fs::write(&artifact_path, "hello").unwrap();
let mut state =
UpdaterState::load_or_new_on_error(&config.cache_dir, &config.release_version);
state
.install_patch(PatchInfo {
path: artifact_path.to_str().unwrap().to_string(),
number: 1,
})
.expect("move failed");
state.save().expect("save failed");
});
assert!(crate::active_patch().is_some());
// pretend we booted from it
crate::report_successful_launch().unwrap();
assert!(crate::active_patch().is_some());
// mark it bad.
crate::report_failed_launch().unwrap();
// Technically might need to "reload"
// ask for current patch (should get none).
assert!(crate::active_patch().is_none());
}
#[test]
fn hash_matches() {
let tmp_dir = TempDir::new("example").unwrap();
let input_path = tmp_dir.path().join("input");
std::fs::write(&input_path, "hello world").unwrap();
let expected = "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
assert!(super::check_hash(&input_path, expected).unwrap());
// modify hash to not match
let expected = "a94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9";
assert_eq!(super::check_hash(&input_path, expected).unwrap(), false);
// invalid hashes should not match either
// Except for now they do (legacy behavior).
let expected = "foo";
assert_eq!(super::check_hash(&input_path, expected).unwrap(), true);
// Remove this case when legacy clients are gone.
let expected = "#";
assert!(super::check_hash(&input_path, expected).unwrap());
}
#[test]
fn inflate_missing_files() {
let tmp_dir = TempDir::new("example").unwrap();
let missing_file = tmp_dir.path().join("missing_file");
let existing_file = tmp_dir.path().join("existing_file");
std::fs::write(&existing_file, "hello world").unwrap();
let mut result = super::inflate(&missing_file, &missing_file, &missing_file);
let mut error = result.unwrap_err();
assert!(format!("{}", error).starts_with("Failed to open base file:"));
result = super::inflate(&missing_file, &existing_file, &missing_file);
error = result.unwrap_err();
assert!(format!("{}", error).starts_with("Failed to open patch file:"));
}
#[test]
fn get_base_path_uses_correct_path() {
let tmp_dir = TempDir::new("example").unwrap();
let missing_file = tmp_dir.path().join("missing_file");
let existing_file = tmp_dir.path().join("existing_file");
std::fs::write(&existing_file, "hello world").unwrap();
// Should use first file since it exists.
let mut base_paths = vec![
existing_file.as_path().to_str().unwrap().to_string(),
missing_file.as_path().to_str().unwrap().to_string(),
];
let mut result = super::get_base_path(&base_paths);
assert_eq!(result.unwrap(), existing_file.as_path());
// Should skip first file since it is missing.
base_paths = vec![
missing_file.as_path().to_str().unwrap().to_string(),
existing_file.as_path().to_str().unwrap().to_string(),
];
result = super::get_base_path(&base_paths);
assert_eq!(result.unwrap(), existing_file.as_path());
// Should error since all files are missing.
base_paths = vec![
missing_file.as_path().to_str().unwrap().to_string(),
missing_file.as_path().to_str().unwrap().to_string(),
];
result = super::get_base_path(&base_paths);
let error = result.unwrap_err();
assert_eq!(
format!("{}", error),
"Invalid State: No base file found".to_string()
);
}
}
-20
View File
@@ -1,20 +0,0 @@
use serde::Deserialize;
/// Struct for parsing shorebird.yaml.
#[derive(Deserialize)]
pub struct YamlConfig {
/// App ID. Required. Generated by Shorebird and included
/// in your app to identify which app/channel/version triple to update.
pub app_id: String,
/// Update channel name. Defaults to "stable" if not set.
pub channel: Option<String>,
/// Update URL. Defaults to the default update URL if not set.
pub base_url: Option<String>,
}
impl YamlConfig {
/// Read in shorebird.yaml from a string.
pub fn from_yaml(yaml: &str) -> Result<Self, serde_yaml::Error> {
serde_yaml::from_str(yaml)
}
}
-15
View File
@@ -1,15 +0,0 @@
[package]
name = "patch"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# Compression and decompression of patch files.
bidiff = "1.0.0"
# Pipe is a simple in-memory pipe implementation, there might be a std way too?
pipe = "0.4.0"
# comde is a wrapper around several compression libraries.
# We only use zstd and could depend on it directly instead.
comde = {version = "0.2.3", default-features = false, features = ["zstandard"]}
-13
View File
@@ -1,13 +0,0 @@
# patch command line tool
This is the tool used by the `shorebird` command line to compute the patch
file for uploading to the server.
This currently uses the rust `bidiff` crate to compute the patch file.
and could just use the `bic` command line tool included in that crate. However
we're explicitly writing our own command line to allow us to change the
underlying compression without affecting the `shorebird` command line callers.
## Usage
patch <old> <new> <patch>
-52
View File
@@ -1,52 +0,0 @@
use bidiff::DiffParams;
use std::{
fs::{self, File},
io::{BufWriter, Write},
time::Instant,
};
use comde::com::Compressor;
use comde::zstd::ZstdCompressor;
// Originally inspired from example in:
// https://github.com/divvun/bidiff/blob/main/crates/bic/src/main.rs
// and then hacked down to just service our needs.
// comde is just a wrapper around various compression/decompression libraries.
// and we could just depend on the zstd crate directly if we end up using
// zstd long term.
fn main() {
let mut args = std::env::args();
args.next(); // skip program name
let older = args.next().expect("path to base file");
let newer = args.next().expect("path to new file");
let patch = args.next().expect("path to output file");
let start = Instant::now();
let older_contents = fs::read(older).expect("read base file");
let newer_contents = fs::read(newer).expect("read new file");
let (mut patch_r, mut patch_w) = pipe::pipe();
let diff_params = DiffParams::new(1, None).unwrap();
std::thread::spawn(move || {
bidiff::simple_diff_with_params(
&older_contents[..],
&newer_contents[..],
&mut patch_w,
&diff_params,
)
.unwrap();
});
let compressor = ZstdCompressor::new();
let mut compatch_w = BufWriter::new(File::create(patch).expect("create patch file"));
compressor
.compress(&mut compatch_w, &mut patch_r)
.expect("compress patch");
compatch_w.flush().expect("flush patch");
println!("Completed in {:?}", start.elapsed());
}