feat(shorebird_code_push): rewrite Dart API (#225)

This commit is contained in:
Felix Angelov
2024-11-04 12:22:37 -06:00
committed by GitHub
parent 8e7ec5a9b6
commit 6f1be35bd3
28 changed files with 2300 additions and 850 deletions
+2 -7
View File
@@ -58,7 +58,7 @@ jobs:
matrix:
crate: ${{ fromJSON(needs.changes.outputs.needs_rust_build) }}
runs-on: ubuntu-latest
runs-on: macos-latest
name: 🦀 Build ${{ matrix.crate }}
@@ -98,12 +98,7 @@ jobs:
working_directory: ${{ matrix.package }}
ci:
needs:
[
semantic_pull_request,
build_flutter_packages,
build_rust_crates,
]
needs: [semantic_pull_request, build_flutter_packages, build_rust_crates]
if: ${{ always() }}
runs-on: ubuntu-latest
+41 -1
View File
@@ -14,6 +14,33 @@
#endif
/**
* An unknown error occurred while updating. The update was not installed.
* This is a catch-all for errors that don't fit into the other categories.
*/
#define SHOREBIRD_UPDATE_ERROR -1
/**
* No update is available (e.g. the app is already up-to-date)
*/
#define SHOREBIRD_NO_UPDATE 0
/**
* An update was installed successfully. It will boot from the update on the
* next app launch.
*/
#define SHOREBIRD_UPDATE_INSTALLED 1
/**
* An error occurred while updating. The update was not installed.
*/
#define SHOREBIRD_UPDATE_HAD_ERROR 2
/**
* The downloaded patch was not installed because it was invalid.
*/
#define SHOREBIRD_UPDATE_IS_BAD_PATCH 3
/**
* Struct containing configuration parameters for the updater.
* Passed to all updater functions.
@@ -69,6 +96,11 @@ typedef struct FileCallbacks {
void (*close)(void *file_handle);
} FileCallbacks;
typedef struct UpdateResult {
int32_t status;
const char *message;
} UpdateResult;
#ifdef __cplusplus
extern "C" {
#endif // __cplusplus
@@ -114,7 +146,9 @@ SHOREBIRD_EXPORT char *shorebird_next_boot_patch_path(void);
* If this function is called with a non-null pointer, it must be a pointer
* returned by the updater library.
*/
SHOREBIRD_EXPORT void shorebird_free_string(char *c_string);
SHOREBIRD_EXPORT void shorebird_free_string(const char *c_string);
SHOREBIRD_EXPORT void shorebird_free_update_result(struct UpdateResult *result);
/**
* Check for an update. Returns true if an update is available.
@@ -126,6 +160,12 @@ SHOREBIRD_EXPORT bool shorebird_check_for_update(void);
*/
SHOREBIRD_EXPORT void shorebird_update(void);
/**
* Synchronously download an update if one is available.
* Returns an [UpdateResult] indicating whether the update was successful.
*/
SHOREBIRD_EXPORT const struct UpdateResult *shorebird_update_with_result(void);
/**
* Start a thread to download an update if one is available.
*/
+261 -3
View File
@@ -14,7 +14,7 @@ use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use std::path::PathBuf;
use crate::updater;
use crate::{updater, UpdateStatus};
use self::c_file::CFileProvder;
@@ -46,6 +46,28 @@ pub struct AppParameters {
pub code_cache_dir: *const libc::c_char,
}
/// An unknown error occurred while updating. The update was not installed.
/// This is a catch-all for errors that don't fit into the other categories.
pub const SHOREBIRD_UPDATE_ERROR: i32 = -1;
/// No update is available (e.g. the app is already up-to-date)
pub const SHOREBIRD_NO_UPDATE: i32 = 0;
/// An update was installed successfully. It will boot from the update on the
/// next app launch.
pub const SHOREBIRD_UPDATE_INSTALLED: i32 = 1;
/// An error occurred while updating. The update was not installed.
pub const SHOREBIRD_UPDATE_HAD_ERROR: i32 = 2;
/// The downloaded patch was not installed because it was invalid.
pub const SHOREBIRD_UPDATE_IS_BAD_PATCH: i32 = 3;
#[repr(C)]
pub struct UpdateResult {
pub status: i32,
pub message: *const libc::c_char,
}
#[derive(Clone, Copy, Debug)]
#[repr(C)]
pub struct FileCallbacks {
@@ -183,6 +205,24 @@ fn path_to_c_string(path: Option<PathBuf>) -> anyhow::Result<*mut c_char> {
})
}
fn to_update_result(status: anyhow::Result<UpdateStatus>) -> UpdateResult {
let result = match status {
Ok(status) => {
let message = status.to_string();
return UpdateResult {
status: status as i32,
message: allocate_c_string(message.as_str())
.unwrap_or_else(|_| std::ptr::null_mut()),
};
}
Err(err) => UpdateResult {
status: SHOREBIRD_UPDATE_ERROR,
message: allocate_c_string(&err.to_string()).unwrap_or_else(|_| std::ptr::null_mut()),
},
};
return result;
}
/// The path to the patch that will boot on the next run of the app, or NULL if
/// there is no next patch.
#[no_mangle]
@@ -203,12 +243,26 @@ pub extern "C" fn shorebird_next_boot_patch_path() -> *mut c_char {
/// If this function is called with a non-null pointer, it must be a pointer
/// returned by the updater library.
#[no_mangle]
pub unsafe extern "C" fn shorebird_free_string(c_string: *mut c_char) {
pub unsafe extern "C" fn shorebird_free_string(c_string: *const c_char) {
if c_string.is_null() {
return;
}
unsafe {
drop(CString::from_raw(c_string));
drop(CString::from_raw(c_string as *mut c_char));
}
}
#[no_mangle]
pub unsafe extern "C" fn shorebird_free_update_result(result: *mut UpdateResult) {
if result.is_null() {
return;
}
let message = (*result).message;
if !message.is_null() {
shorebird_free_string(message);
}
unsafe {
drop(Box::from_raw(result));
}
}
@@ -228,6 +282,14 @@ pub extern "C" fn shorebird_update() {
);
}
/// Synchronously download an update if one is available.
/// Returns an [UpdateResult] indicating whether the update was successful.
#[no_mangle]
pub extern "C" fn shorebird_update_with_result() -> *const UpdateResult {
let result = to_update_result(updater::update());
return Box::into_raw(Box::new(result));
}
/// Start a thread to download an update if one is available.
#[no_mangle]
pub extern "C" fn shorebird_start_update_thread() {
@@ -361,6 +423,8 @@ mod test {
// free_string also doesn't crash with null.
unsafe { shorebird_free_string(std::ptr::null_mut()) }
// free_update_result also doesn't crash with null.
unsafe { shorebird_free_update_result(std::ptr::null_mut()) }
}
#[serial]
@@ -499,6 +563,200 @@ mod test {
assert_eq!(new, expected_new);
}
#[serial]
#[test]
fn patch_success_with_result() {
testing_reset_config();
let tmp_dir = TempDir::new("example").unwrap();
// Generated by `string_patch "hello world" "hello tests"`
let base = "hello world";
let expected_new: &str = "hello tests";
let apk_path = tmp_dir.path().join("base.apk");
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
let c_yaml = c_string("app_id: foo");
assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
free_c_string(c_yaml);
free_parameters(c_params);
// set up the network hooks to return a patch.
testing_set_network_hooks(
|_url, _request| {
// Generated by `string_patch "hello world" "hello tests"`
let hash = "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45";
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(crate::Patch {
number: 1,
hash: hash.to_owned(),
download_url: "ignored".to_owned(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
})
},
|_url| {
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
];
Ok(patch_bytes)
},
|_url, _event| Ok(()),
);
// There is an update available.
assert!(shorebird_check_for_update());
// Go ahead and do the update.
let result = shorebird_update_with_result();
unsafe {
assert_eq!(result.read().status, SHOREBIRD_UPDATE_INSTALLED);
shorebird_free_update_result(result as *mut UpdateResult);
}
assert_eq!(shorebird_current_boot_patch_number(), 0);
assert_eq!(shorebird_next_boot_patch_number(), 1);
// Read path contents into memory and check against expected.
let c_path = shorebird_next_boot_patch_path();
let path = to_rust(c_path).unwrap();
unsafe { shorebird_free_string(c_path) };
let new = std::fs::read_to_string(path).unwrap();
assert_eq!(new, expected_new);
}
#[serial]
#[test]
fn patch_check_no_patch_with_result() {
testing_reset_config();
let tmp_dir = TempDir::new("example").unwrap();
// Generated by `string_patch "hello world" "hello tests"`
let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
let c_yaml = c_string("app_id: foo");
assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
free_c_string(c_yaml);
free_parameters(c_params);
// set up the network hooks to return a patch.
testing_set_network_hooks(
|_url, _request| {
Ok(PatchCheckResponse {
patch_available: false,
patch: None,
rolled_back_patch_numbers: None,
})
},
|_url| Err(anyhow::anyhow!("Error")),
|_url, _event| Ok(()),
);
// Go ahead and do the update.
let result = shorebird_update_with_result();
unsafe {
assert_eq!(result.read().status, SHOREBIRD_NO_UPDATE);
shorebird_free_update_result(result as *mut UpdateResult);
}
}
#[serial]
#[test]
fn patch_check_failure_with_result() {
testing_reset_config();
let tmp_dir = TempDir::new("example").unwrap();
// Generated by `string_patch "hello world" "hello tests"`
let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
let c_yaml = c_string("app_id: foo");
assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
free_c_string(c_yaml);
free_parameters(c_params);
// set up the network hooks to return a patch.
testing_set_network_hooks(
|_url, _request| Err(anyhow::anyhow!("Error")),
|_url| {
// Generated by `string_patch "hello world" "hello tests"`
let patch_bytes: Vec<u8> = vec![
40, 181, 47, 253, 0, 128, 177, 0, 0, 223, 177, 0, 0, 0, 16, 0, 0, 6, 0, 0, 0,
0, 0, 0, 5, 116, 101, 115, 116, 115, 0,
];
Ok(patch_bytes)
},
|_url, _event| Ok(()),
);
// Go ahead and do the update.
let result = shorebird_update_with_result();
unsafe {
assert_eq!(result.read().status, SHOREBIRD_UPDATE_ERROR);
shorebird_free_update_result(result as *mut UpdateResult);
}
}
#[serial]
#[test]
fn patch_download_failure_with_result() {
testing_reset_config();
let tmp_dir = TempDir::new("example").unwrap();
// Generated by `string_patch "hello world" "hello tests"`
let base = "hello world";
let apk_path = tmp_dir.path().join("base.apk");
write_fake_apk(apk_path.to_str().unwrap(), base.as_bytes());
let fake_libapp_path = tmp_dir.path().join("lib/arch/ignored.so");
let c_params = parameters(&tmp_dir, fake_libapp_path.to_str().unwrap());
// app_id is required or shorebird_init will fail.
let c_yaml = c_string("app_id: foo");
assert!(shorebird_init(&c_params, FileCallbacks::new(), c_yaml));
free_c_string(c_yaml);
free_parameters(c_params);
// set up the network hooks to return a patch.
testing_set_network_hooks(
|_url, _request| {
// Generated by `string_patch "hello world" "hello tests"`
let hash = "bb8f1d041a5cdc259055afe9617136799543e0a7a86f86db82f8c1fadbd8cc45";
Ok(PatchCheckResponse {
patch_available: true,
patch: Some(crate::Patch {
number: 1,
hash: hash.to_owned(),
download_url: "ignored".to_owned(),
hash_signature: None,
}),
rolled_back_patch_numbers: None,
})
},
|_url| Err(anyhow::anyhow!("Error")),
|_url, _event| Ok(()),
);
// Go ahead and do the update.
let result = shorebird_update_with_result();
unsafe {
assert_eq!(result.read().status, SHOREBIRD_UPDATE_ERROR);
shorebird_free_update_result(result as *mut UpdateResult);
}
}
#[serial]
#[test]
fn current_boot_patch_set_after_reporting_launch_start() {
+5
View File
@@ -1,3 +1,8 @@
# 2.0.0-dev.1
- **BREAKING**: revamp the updater API
- Remove `ShorebirdCodePush` in favor of `ShorebirdUpdater`
# 1.1.6
- Update log messages to explain what "using no-op implementation" means.
+17 -15
View File
@@ -34,13 +34,11 @@ this:
// Import the library
import 'package:shorebird_code_push/shorebird_code_push.dart';
// Create an instance of the ShorebirdCodePush class
final shorebirdCodePush = ShorebirdCodePush();
// Create an instance of the updater class
final updater = ShorebirdUpdater();
// Launch your app
void main() {
runApp(const MyApp());
}
void main() => runApp(const MyApp());
// [Other code here]
@@ -49,20 +47,24 @@ class _MyHomePageState extends State<MyHomePage> {
void initState() {
super.initState();
// Get the current patch number and print it to the console. It will be
// null if no patches are installed.
shorebirdCodePush
.currentPatchNumber()
.then((value) => print('current patch number is $value'));
// Get the current patch number and print it to the console.
// It will be `null` if no patches are installed.
updater.readCurrentPatch().then((currentPatch) {
print('The current patch number is: ${currentPatch.number}');
});
}
Future<void> _checkForUpdates() async {
// Check whether a patch is available to install.
final isUpdateAvailable = await shorebirdCodePush.isNewPatchAvailableForDownload();
// Check whether a new update is available.
final status = await updater.checkForUpdates();
if (isUpdateAvailable) {
// Download the new patch if it's available.
await shorebirdCodePush.downloadUpdateIfAvailable();
if (status == UpdateStatus.outdated) {
try {
// Perform the update
await updater.update();
} on UpdateException catch (error) {
// Handle any errors that occur while updating.
}
}
}
+156 -144
View File
@@ -1,13 +1,6 @@
import 'package:flutter/material.dart';
import 'package:restart_app/restart_app.dart';
import 'package:shorebird_code_push/shorebird_code_push.dart';
// Create an instance of ShorebirdCodePush. Because this example only contains
// a single widget, we create it here, but you will likely only need to create
// a single instance of ShorebirdCodePush in your app.
final _shorebirdCodePush = ShorebirdCodePush();
void main() => runApp(const MyApp());
class MyApp extends StatelessWidget {
@@ -21,198 +14,217 @@ class MyApp extends StatelessWidget {
colorScheme: ColorScheme.fromSeed(seedColor: Colors.red),
useMaterial3: true,
),
home: const MyHomePage(title: 'Shorebird Code Push'),
home: const MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
const MyHomePage({required this.title, super.key});
final String title;
const MyHomePage({super.key});
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _isShorebirdAvailable = _shorebirdCodePush.isShorebirdAvailable();
int? _currentPatchVersion;
bool _isCheckingForUpdate = false;
final _updater = ShorebirdUpdater();
late final bool _isUpdaterAvailable;
var _isCheckingForUpdates = false;
Patch? _currentPatch;
@override
void initState() {
super.initState();
// Request the current patch number.
_shorebirdCodePush.currentPatchNumber().then((currentPatchVersion) {
if (!mounted) return;
setState(() {
_currentPatchVersion = currentPatchVersion;
});
// Check whether Shorebird is available.
setState(() => _isUpdaterAvailable = _updater.isAvailable);
// Read the current patch (if there is one.)
// `currentPatch` will be `null` if no patch is installed.
_updater.readCurrentPatch().then((currentPatch) {
setState(() => _currentPatch = currentPatch);
}).catchError((Object error) {
// If an error occurs, we log it for now.
debugPrint('Error reading current patch: $error');
});
}
Future<void> _checkForUpdate() async {
setState(() {
_isCheckingForUpdate = true;
});
if (_isCheckingForUpdates) return;
// Ask the Shorebird servers if there is a new patch available.
final isUpdateAvailable =
await _shorebirdCodePush.isNewPatchAvailableForDownload();
if (!mounted) return;
setState(() {
_isCheckingForUpdate = false;
});
if (isUpdateAvailable) {
_showUpdateAvailableBanner();
} else {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('No update available'),
),
);
try {
setState(() => _isCheckingForUpdates = true);
// Check if there's an update available.
final status = await _updater.checkForUpdate();
if (!mounted) return;
// If there is an update available, show a banner.
if (status == UpdateStatus.outdated) _showUpdateAvailableBanner();
} catch (error) {
// If an error occurs, we log it for now.
debugPrint('Error checking for update: $error');
} finally {
setState(() => _isCheckingForUpdates = false);
}
}
void _showDownloadingBanner() {
ScaffoldMessenger.of(context).showMaterialBanner(
const MaterialBanner(
content: Text('Downloading...'),
actions: [
SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
const MaterialBanner(
content: Text('Downloading...'),
actions: [
SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(),
),
),
],
),
);
],
),
);
}
void _showUpdateAvailableBanner() {
ScaffoldMessenger.of(context).showMaterialBanner(
MaterialBanner(
content: const Text('Update available'),
actions: [
TextButton(
onPressed: () async {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
await _downloadUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Download'),
),
],
),
);
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('Update available'),
actions: [
TextButton(
onPressed: () async {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
await _downloadUpdate();
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Download'),
),
],
),
);
}
void _showRestartBanner() {
ScaffoldMessenger.of(context).showMaterialBanner(
const MaterialBanner(
content: Text('A new patch is ready!'),
actions: [
TextButton(
// Restart the app for the new patch to take effect.
onPressed: Restart.restartApp,
child: Text('Restart app'),
),
],
),
);
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: const Text('A new patch is ready! Please restart your app.'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
void _showErrorBanner() {
ScaffoldMessenger.of(context).showMaterialBanner(
MaterialBanner(
content: const Text('An error occurred while downloading the update.'),
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
void _showErrorBanner(Object error) {
ScaffoldMessenger.of(context)
..hideCurrentMaterialBanner()
..showMaterialBanner(
MaterialBanner(
content: Text(
'An error occurred while downloading the update: $error.',
),
],
),
);
actions: [
TextButton(
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
},
child: const Text('Dismiss'),
),
],
),
);
}
// Note: this is only run if an update is reported as available.
// [isNewPatchReadyToInstall] returning false does not always indicate an
// error with the download.
Future<void> _downloadUpdate() async {
_showDownloadingBanner();
await Future.wait([
_shorebirdCodePush.downloadUpdateIfAvailable(),
// Add an artificial delay so the banner has enough time to animate in.
Future<void>.delayed(const Duration(milliseconds: 250)),
]);
final isUpdateReadyToInstall =
await _shorebirdCodePush.isNewPatchReadyToInstall();
if (!mounted) return;
ScaffoldMessenger.of(context).hideCurrentMaterialBanner();
if (isUpdateReadyToInstall) {
try {
// Perform the update (e.g download the latest patch).
await _updater.update();
if (!mounted) return;
// Show a banner to inform the user that the update is ready and that they
// need to restart the app.
_showRestartBanner();
} else {
_showErrorBanner();
} on UpdateException catch (error) {
// If an error occurs, we show a banner with the error message.
_showErrorBanner(error.message);
}
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final heading = _currentPatchVersion != null
? '$_currentPatchVersion'
: 'No patch installed';
return Scaffold(
appBar: AppBar(
backgroundColor: theme.colorScheme.inversePrimary,
title: Text(widget.title),
title: const Text('Shorebird Code Push'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('Current patch version:'),
Text(
heading,
style: theme.textTheme.headlineMedium,
),
const SizedBox(height: 20),
if (!_isShorebirdAvailable)
Text(
'Shorebird Engine not available.',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.error,
),
),
if (_isShorebirdAvailable)
ElevatedButton(
onPressed: _isCheckingForUpdate ? null : _checkForUpdate,
child: _isCheckingForUpdate
? const _LoadingIndicator()
: const Text('Check for update'),
),
],
body: _isUpdaterAvailable
? _CurrentPatchVersion(patch: _currentPatch)
: const _ShorebirdUnavailable(),
floatingActionButton: FloatingActionButton(
onPressed: _isCheckingForUpdates ? null : _checkForUpdate,
tooltip: 'Check for update',
child: _isCheckingForUpdates
? const _LoadingIndicator()
: const Icon(Icons.refresh),
),
);
}
}
/// Widget that is mounted when Shorebird is not available.
class _ShorebirdUnavailable extends StatelessWidget {
const _ShorebirdUnavailable();
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Text(
'''
Shorebird is not available.
Please make sure the app was generated via `shorebird release` and that it is running in release mode.''',
style: theme.textTheme.bodyLarge?.copyWith(
color: theme.colorScheme.error,
),
),
);
}
}
/// Widget that displays the current patch version.
class _CurrentPatchVersion extends StatelessWidget {
const _CurrentPatchVersion({required this.patch});
final Patch? patch;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('Current patch version:'),
Text(
patch != null ? '${patch!.number}' : 'No patch installed',
style: theme.textTheme.headlineMedium,
),
],
),
);
}
}
/// A reusable loading indicator.
class _LoadingIndicator extends StatelessWidget {
const _LoadingIndicator();
-2
View File
@@ -10,12 +10,10 @@ environment:
dependencies:
flutter:
sdk: flutter
restart_app: ^1.3.2
shorebird_code_push:
path: ../
dev_dependencies:
flutter_lints: ^2.0.0
flutter_test:
sdk: flutter
very_good_analysis: ^5.0.0
+13 -6
View File
@@ -1,7 +1,14 @@
# This file is used to configure the Shorebird updater used by your application.
# Learn more at https://shorebird.dev
# This file should be checked into version control.
# This file is used to configure the Shorebird updater used by your app.
# Learn more at https://docs.shorebird.dev
# This file does not contain any sensitive information and should be checked into version control.
# This is the unique identifier assigned to your app.
# It is used by your app to request the correct patches from Shorebird servers.
app_id: 1692ba14-0c8d-490e-9593-13815d2ac1cf
# Your app_id is the unique identifier assigned to your app.
# It is used to identify your app when requesting patches from Shorebird's servers.
# It is not a secret and can be shared publicly.
app_id: 1692ba14-0c8d-490e-9593-13815d2ac1cf
# auto_update controls if Shorebird should automatically update in the background on launch.
# If auto_update: false, you will need to use package:shorebird_code_push to trigger updates.
# https://pub.dev/packages/shorebird_code_push
# Uncomment the following line to disable automatic updates.
auto_update: false
@@ -1,5 +1,4 @@
/// Get info about your Shorebird code push app
library shorebird_code_push;
export 'src/shorebird_code_push_io.dart'
if (dart.library.html) 'src/shorebird_code_push_web.dart';
export 'src/shorebird_updater.dart';
File diff suppressed because it is too large Load Diff
@@ -1,41 +0,0 @@
/// {@template shorebird_code_push_base}
/// Get info about your Shorebird code push app.
/// {@endtemplate}
abstract class ShorebirdCodePushBase {
/// Whether the Shorebird Engine is available.
bool isShorebirdAvailable();
/// Checks whether a new patch is available for download.
///
/// Returns true when there is a new patch for this app on Shorebird servers
/// but not yet downloaded to this device.
///
/// Returns false in all other cases, including when a new patch is installed
/// locally but not yet booted from.
/// Use [isNewPatchReadyToInstall] to check if a new patch has been downloaded
/// and is ready to boot from on next restart.
///
/// Runs in a separate isolate to avoid blocking the UI thread.
Future<bool> isNewPatchAvailableForDownload();
/// The version of the currently-installed patch. `null` if no patch is
/// installed (i.e., the app is running the release version).
///
/// This will also return `null` if Shorebird is not available.
Future<int?> currentPatchNumber();
/// The version of the patch that will be run on the next app launch. If no
/// new patch has been downloaded, this will be the same as
/// [currentPatchNumber].
Future<int?> nextPatchNumber();
/// Downloads the latest patch, if available.
/// Does nothing if there is no new patch available.
Future<void> downloadUpdateIfAvailable();
/// Whether a new patch has been downloaded and is ready to install.
///
/// If true, the patch number returned by [nextPatchNumber] will be run on the
/// next app launch.
Future<bool> isNewPatchReadyToInstall();
}
@@ -1,60 +0,0 @@
import 'dart:isolate';
import 'package:shorebird_code_push/src/shorebird_code_push_base.dart';
import 'package:shorebird_code_push/src/updater.dart';
/// {@template shorebird_code_push}
/// Get info about your Shorebird code push app.
/// {@endtemplate}
class ShorebirdCodePushFfi implements ShorebirdCodePushBase {
/// {@macro shorebird_code_push}
ShorebirdCodePushFfi({Updater? updater})
: _updater = updater ?? const Updater();
final Updater _updater;
@override
Future<bool> isNewPatchAvailableForDownload() {
return Isolate.run(_updater.checkForUpdate);
}
@override
Future<int?> currentPatchNumber() {
return Isolate.run(() {
final currentPatchNumber = _updater.currentPatchNumber();
// 0 means no patch is installed so we return null.
return currentPatchNumber == 0 ? null : currentPatchNumber;
});
}
@override
Future<int?> nextPatchNumber() {
return Isolate.run(
() {
final patchNumber = _updater.nextPatchNumber();
// 0 means no patch is next so we return null.
return patchNumber == 0 ? null : patchNumber;
},
);
}
@override
Future<void> downloadUpdateIfAvailable() async {
await Isolate.run(_updater.downloadUpdate);
}
@override
Future<bool> isNewPatchReadyToInstall() async {
final patchNumbers = await Future.wait([
currentPatchNumber(),
nextPatchNumber(),
]);
final currentPatch = patchNumbers[0];
final nextPatch = patchNumbers[1];
return nextPatch != null && currentPatch != nextPatch;
}
@override
bool isShorebirdAvailable() => true;
}
@@ -1,57 +0,0 @@
import 'package:meta/meta.dart';
import 'package:shorebird_code_push/src/shorebird_code_push_base.dart';
import 'package:shorebird_code_push/src/shorebird_code_push_ffi.dart';
import 'package:shorebird_code_push/src/shorebird_code_push_noop.dart';
import 'package:shorebird_code_push/src/updater.dart';
/// Applications should not import this file directly, but
/// import `package:shorebird_code_push/shorebird_code_push.dart` instead.
/// {@template shorebird_code_push}
/// Get info about your Shorebird code push app.
/// {@endtemplate}
class ShorebirdCodePush implements ShorebirdCodePushBase {
/// {@macro shorebird_code_push}
ShorebirdCodePush() : this._(updater: const Updater());
/// Constructor used for testing which allows injecting a mock [Updater].
@visibleForTesting
ShorebirdCodePush.test({Updater updater = const Updater()})
: this._(updater: updater);
ShorebirdCodePush._({required Updater updater}) {
try {
// If the Shorebird Engine is not available, this will throw an exception.
updater.currentPatchNumber();
_delegate = ShorebirdCodePushFfi(updater: updater);
} catch (error) {
_delegate = ShorebirdCodePushNoop();
}
}
late final ShorebirdCodePushBase _delegate;
@override
bool isShorebirdAvailable() => _delegate.isShorebirdAvailable();
@override
Future<bool> isNewPatchAvailableForDownload() {
return _delegate.isNewPatchAvailableForDownload();
}
@override
Future<int?> currentPatchNumber() => _delegate.currentPatchNumber();
@override
Future<int?> nextPatchNumber() => _delegate.nextPatchNumber();
@override
Future<void> downloadUpdateIfAvailable() {
return _delegate.downloadUpdateIfAvailable();
}
@override
Future<bool> isNewPatchReadyToInstall() {
return _delegate.isNewPatchReadyToInstall();
}
}
@@ -1,36 +0,0 @@
import 'package:shorebird_code_push/src/shorebird_code_push_base.dart';
/// {@template shorebird_code_push_noop}
/// A no-op implementation of [ShorebirdCodePushBase].
///
/// This is used when the build does not contain the Shorebird Engine.
/// {@endtemplate}
class ShorebirdCodePushNoop implements ShorebirdCodePushBase {
/// {@macro shorebird_code_push_noop}
ShorebirdCodePushNoop() {
// ignore: avoid_print
print('''
[ShorebirdCodePush]: Shorebird Engine not available, using no-op implementation.
This occurs when using package:shorebird_code_push in an app that does not
contain the Shorebird Engine. Most commonly this is due to building with
`flutter build` or `flutter run` instead of `shorebird release`.
''');
}
@override
Future<int?> currentPatchNumber() async => null;
@override
Future<void> downloadUpdateIfAvailable() async {}
@override
Future<bool> isNewPatchAvailableForDownload() async => false;
@override
Future<bool> isNewPatchReadyToInstall() async => false;
@override
bool isShorebirdAvailable() => false;
@override
Future<int?> nextPatchNumber() async => null;
}
@@ -1,11 +0,0 @@
import 'package:shorebird_code_push/src/shorebird_code_push_base.dart';
import 'package:shorebird_code_push/src/shorebird_code_push_noop.dart';
/// Applications should not import this file directly, but
/// import `package:shorebird_code_push/shorebird_code_push.dart` instead.
/// {@template shorebird_code_push}
/// Get info about your Shorebird code push app.
/// {@endtemplate}
class ShorebirdCodePush extends ShorebirdCodePushNoop
implements ShorebirdCodePushBase {}
@@ -0,0 +1,138 @@
import 'package:shorebird_code_push/src/shorebird_updater_io.dart'
if (dart.library.js_interop) './shorebird_updater_web.dart';
import 'package:shorebird_code_push/src/updater.dart';
/// The reason a call to [ShorebirdUpdater.update] failed.
enum UpdateFailureReason {
/// No update is available.
noUpdate,
/// The update failed because the patch could not be downloaded.
downloadFailed,
/// The update failed because the patch failed to install.
installFailed,
/// The update failed for an unknown reason.
unknown,
}
/// {@template read_patch_exception}
/// An exception thrown by [ShorebirdUpdater.readCurrentPatch] and
/// [ShorebirdUpdater.readNextPatch] when the read is unsuccessful.
/// {@endtemplate}
class ReadPatchException implements Exception {
/// {@macro update_exception}
const ReadPatchException({required this.message});
/// The human-readable error message.
final String message;
}
/// {@template update_exception}
/// An exception thrown by [ShorebirdUpdater.update] when the update is
/// unsuccessful.
/// {@endtemplate}
class UpdateException implements Exception {
/// {@macro update_exception}
const UpdateException({required this.message, required this.reason});
/// The human-readable error message.
final String message;
/// The reason the update failed.
final UpdateFailureReason reason;
}
/// Log message when the Shorebird updater is unavailable in the current
/// environment.
void logShorebirdEngineUnavailableMessage() {
// ignore: avoid_print
print('''
-------------------------------------------------------------------------------
The Shorebird Updater is unavailable in the current environment.
-------------------------------------------------------------------------------
This occurs when using pkg:shorebird_code_push in an app that does not
contain the Shorebird Engine. Most commonly this is due to building with
`flutter build` or `flutter run` instead of `shorebird release` or `shorebird preview`.
It can also occur when running on an unsupported platform (e.g. web or desktop).
''');
}
/// {@template patch}
/// An object representing a single patch (over-the-air update).
/// {@endtemplate}
class Patch {
/// {@macro patch}
const Patch({required this.number});
/// The patch number.
final int number;
}
/// The current status of the app in terms of whether its up-to-date.
enum UpdateStatus {
/// The app is up to date (e.g. running the latest patch.)
upToDate,
/// A new update is available for download.
outdated,
/// The app is up to date, but a restart is required for the update to take
/// effect.
restartRequired,
/// The update status is unavailable. This occurs when the updater is not
/// available in the current build.
/// See also:
/// * [ShorebirdUpdater.isAvailable] to determine if the updater is
/// available.
unavailable,
}
/// {@template shorebird_updater}
/// Manage updates for a Shorebird app.
/// {@endtemplate}
abstract class ShorebirdUpdater {
/// {@macro shorebird_updater}
factory ShorebirdUpdater() => ShorebirdUpdaterImpl(const Updater());
/// Whether the updater is available on the current platform.
/// The most common reasons for this returning false are:
/// 1. The app is running in debug mode (Shorebird only supports release
/// mode).
/// 2. The app was *NOT* built using `shorebird release` and does *NOT*
/// contain the Shorebird engine.
bool get isAvailable;
/// Returns information about the currently installed patch.
/// Returns `null` if no patch has been installed.
/// Returns `null` if the updater is not available.
/// Throws a [ReadPatchException] if the read is unsuccessful.
Future<Patch?> readCurrentPatch();
/// Returns information about the most recently downloaded patch.
/// Returns the same patch as [readCurrentPatch] if no new patch has been
/// downloaded.
/// Returns `null` if the updater is not available.
/// Throws a [ReadPatchException] if the read is unsuccessful.
Future<Patch?> readNextPatch();
/// Checks for available updates and returns the [UpdateStatus].
/// This method should be used to determine the update status before calling
/// [update].
/// Returns `null` if the updater is not available.
Future<UpdateStatus?> checkForUpdate();
/// Updates the app to the latest patch (if available).
/// Note: The app must be restarted for the update to take effect.
/// Note: This method does nothing if the updater is not available.
///
/// Throws an [UpdateException] if a the update call is unsuccessful.
///
/// See also:
/// * [isAvailable], which indicates whether the updater is available.
/// * [checkForUpdate], which should be called to check if an update is
/// available before calling this method.
Future<void> update();
}
@@ -0,0 +1,149 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
import 'package:meta/meta.dart';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
import 'package:shorebird_code_push/src/shorebird_updater.dart';
import 'package:shorebird_code_push/src/updater.dart';
@visibleForTesting
/// Type definition for [Isolate.run].
typedef IsolateRun = Future<R> Function<R>(
FutureOr<R> Function(), {
String? debugName,
});
/// {@template shorebird_updater_io}
/// The Shorebird IO Updater.
/// {@endtemplate}
class ShorebirdUpdaterImpl implements ShorebirdUpdater {
/// {@macro shorebird_updater_io}
ShorebirdUpdaterImpl(this._updater, {IsolateRun? run})
: _run = run ?? Isolate.run {
try {
// If the Shorebird Engine is not available, this will throw an exception.
// FIXME: Run this in an isolate or refactor the updater to avoid risking
// a hang. If another thread is also calling into Shorebird at the same
// time the underlying Rust code could block getting the config lock.
_updater.currentPatchNumber();
_isAvailable = true;
} catch (_) {
logShorebirdEngineUnavailableMessage();
_isAvailable = false;
}
}
late final bool _isAvailable;
final Updater _updater;
final IsolateRun _run;
@override
bool get isAvailable => _isAvailable;
@override
Future<Patch?> readCurrentPatch() => _readPatch(_updater.currentPatchNumber);
@override
Future<Patch?> readNextPatch() => _readPatch(_updater.nextPatchNumber);
Future<Patch?> _readPatch(int Function() fn) async {
if (!_isAvailable) return null;
return _run(
() {
try {
final patchNumber = fn();
return patchNumber > 0 ? Patch(number: patchNumber) : null;
} catch (error) {
throw ReadPatchException(message: '$error');
}
},
);
}
@override
Future<UpdateStatus> checkForUpdate() async {
if (!_isAvailable) return UpdateStatus.unavailable;
final isUpdateAvailable = await _run(_updater.checkForUpdate);
if (isUpdateAvailable) return UpdateStatus.outdated;
final (current, next) = await (readCurrentPatch(), readNextPatch()).wait;
return next != null && current?.number != next.number
? UpdateStatus.restartRequired
: UpdateStatus.upToDate;
}
@override
Future<void> update() async {
if (!_isAvailable) return;
Pointer<UpdateResult> result = nullptr;
try {
result = await _run(_updater.update);
} catch (_) {
return _legacyFallback();
}
const unknownErrorMessage = 'An unknown error occurred.';
try {
if (result == nullptr) {
throw const UpdateException(
reason: UpdateFailureReason.unknown,
message: unknownErrorMessage,
);
}
final status = result.ref.status;
if (status == SHOREBIRD_UPDATE_INSTALLED) return;
final reason = status.toFailureReason();
final message = result.ref.message != nullptr
? result.ref.message.cast<Utf8>().toDartString()
: unknownErrorMessage;
throw UpdateException(message: message, reason: reason);
} finally {
_updater.freeUpdateResult(result);
}
}
// Fallback to downloadUpdate if update is not available.
Future<void> _legacyFallback() async {
await _run(_updater.downloadUpdate);
final (current, next) = await (readCurrentPatch(), readNextPatch()).wait;
final status = next != null && current?.number != next.number
? UpdateStatus.restartRequired
: UpdateStatus.upToDate;
if (status == UpdateStatus.restartRequired) return;
throw const UpdateException(
message: '''
Downloading update failed but reason is unknown due to legacy updater.
Please upgrade the Shorebird Engine for improved error messages.''',
reason: UpdateFailureReason.unknown,
);
}
}
extension on int {
UpdateFailureReason toFailureReason() {
switch (this) {
case SHOREBIRD_NO_UPDATE:
return UpdateFailureReason.noUpdate;
case SHOREBIRD_UPDATE_HAD_ERROR:
return UpdateFailureReason.downloadFailed;
case SHOREBIRD_UPDATE_IS_BAD_PATCH:
return UpdateFailureReason.installFailed;
case SHOREBIRD_UPDATE_ERROR:
return UpdateFailureReason.unknown;
default:
return UpdateFailureReason.unknown;
}
}
}
@@ -0,0 +1,30 @@
import 'package:shorebird_code_push/src/shorebird_updater.dart';
import 'package:shorebird_code_push/src/updater.dart';
/// {@template shorebird_updater_web}
/// The Shorebird web updater.
/// {@endtemplate}
class ShorebirdUpdaterImpl implements ShorebirdUpdater {
/// {@macro shorebird_updater_web}
ShorebirdUpdaterImpl(this._updater) {
logShorebirdEngineUnavailableMessage();
}
// ignore: unused_field
final Updater _updater;
@override
bool get isAvailable => false;
@override
Future<Patch?> readCurrentPatch() async => null;
@override
Future<Patch?> readNextPatch() async => null;
@override
Future<UpdateStatus> checkForUpdate() async => UpdateStatus.unavailable;
@override
Future<void> update() async {}
}
+9
View File
@@ -1,4 +1,5 @@
import 'dart:ffi' as ffi;
import 'dart:ffi';
import 'package:meta/meta.dart';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
@@ -28,4 +29,12 @@ class Updater {
/// Downloads the latest patch, if available.
void downloadUpdate() => bindings.shorebird_update();
/// Downloads the latest patch, if available and returns an [UpdateResult]
/// to indicate whether the update was successful.
Pointer<UpdateResult> update() => bindings.shorebird_update_with_result();
/// Frees an update result allocated by the updater.
void freeUpdateResult(Pointer<UpdateResult> ptr) =>
bindings.shorebird_free_update_result(ptr);
}
+5 -4
View File
@@ -1,11 +1,12 @@
name: shorebird_code_push
description: Check for and download Shorebird code push updates from your app.
version: 1.1.6
version: 2.0.0-dev.1
homepage: https://shorebird.dev
repository: https://github.com/shorebirdtech/updater/tree/main/shorebird_code_push
environment:
sdk: ">=3.0.0 <4.0.0"
sdk: ">=3.5.4 <4.0.0"
flutter: ">=3.24.4 <4.0.0"
dependencies:
ffi: ^2.0.2
@@ -13,9 +14,9 @@ dependencies:
dev_dependencies:
ffigen: ">=8.0.2 <16.0.0"
mocktail: ">=0.3.0 <2.0.0"
mocktail: ^1.0.0
test: ^1.19.2
very_good_analysis: ">=5.0.0 <7.0.0"
very_good_analysis: ^6.0.0
ffigen:
output: "lib/src/generated/updater_bindings.g.dart"
@@ -0,0 +1,16 @@
import 'dart:async';
void Function() overridePrint(void Function(List<String> logs) fn) {
return () {
final printLogs = <String>[];
final spec = ZoneSpecification(
print: (_, __, ___, String msg) {
printLogs.add(msg);
},
);
return Zone.current
.fork(specification: spec)
.run<void>(() => fn(printLogs));
};
}
@@ -1,131 +0,0 @@
import 'dart:async';
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_code_push/src/shorebird_code_push_io.dart';
import 'package:shorebird_code_push/src/updater.dart';
import 'package:test/test.dart';
class _MockUpdater extends Mock implements Updater {}
void main() {
group(ShorebirdCodePush, () {
late List<String> printLogs;
late Updater updater;
late ShorebirdCodePush shorebirdCodePush;
setUp(() {
printLogs = [];
updater = _MockUpdater();
when(() => updater.currentPatchNumber()).thenReturn(0);
shorebirdCodePush = runZoned(
() => ShorebirdCodePush.test(updater: updater),
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => printLogs.add(line),
),
);
});
test('can be instantiated', () {
shorebirdCodePush = runZoned(
ShorebirdCodePush.new,
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => printLogs.add(line),
),
);
expect(shorebirdCodePush, isNotNull);
expect(
printLogs,
equals(
[
'''
[ShorebirdCodePush]: Shorebird Engine not available, using no-op implementation.
This occurs when using package:shorebird_code_push in an app that does not
contain the Shorebird Engine. Most commonly this is due to building with
`flutter build` or `flutter run` instead of `shorebird release`.\n''',
],
),
);
});
test('logs error when updater cannot be initialized', () {
final printLogs = <String>[];
final exception = Exception('Failed to lookup symbol');
when(() => updater.currentPatchNumber()).thenThrow(exception);
runZoned(
() => ShorebirdCodePush.test(updater: updater),
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => printLogs.add(line),
),
);
expect(
printLogs,
equals(
[
'''
[ShorebirdCodePush]: Shorebird Engine not available, using no-op implementation.
This occurs when using package:shorebird_code_push in an app that does not
contain the Shorebird Engine. Most commonly this is due to building with
`flutter build` or `flutter run` instead of `shorebird release`.\n''',
],
),
);
});
group('isShorebirdAvailable', () {
test('proxies to delegate', () {
expect(shorebirdCodePush.isShorebirdAvailable(), isTrue);
});
});
group('isNewPatchAvailableForDownload', () {
test('proxies to delegate', () {
when(() => updater.checkForUpdate()).thenReturn(true);
expectLater(
shorebirdCodePush.isNewPatchAvailableForDownload(),
completion(isTrue),
);
});
});
group('currentPatchNumber', () {
test('proxies to delegate', () {
when(() => updater.currentPatchNumber()).thenReturn(42);
expectLater(
shorebirdCodePush.currentPatchNumber(),
completion(equals(42)),
);
});
});
group('nextPatchNumber', () {
test('proxies to delegate', () {
when(() => updater.nextPatchNumber()).thenReturn(42);
expectLater(
shorebirdCodePush.nextPatchNumber(),
completion(equals(42)),
);
});
});
group('downloadUpdateIfAvailable', () {
test('proxies to delegate', () async {
when(() => updater.downloadUpdate()).thenAnswer((_) async {});
await expectLater(
shorebirdCodePush.downloadUpdateIfAvailable(),
completes,
);
});
});
group('isNewPatchReadyToInstall', () {
test('proxies to delegate', () async {
when(() => updater.currentPatchNumber()).thenReturn(0);
when(() => updater.nextPatchNumber()).thenReturn(1);
await expectLater(
shorebirdCodePush.isNewPatchReadyToInstall(),
completion(isTrue),
);
});
});
});
}
@@ -1,154 +0,0 @@
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_code_push/src/shorebird_code_push_ffi.dart';
import 'package:shorebird_code_push/src/updater.dart';
import 'package:test/test.dart';
class _MockUpdater extends Mock implements Updater {}
void main() {
group(ShorebirdCodePushFfi, () {
late Updater updater;
late ShorebirdCodePushFfi shorebirdCodePush;
setUp(() {
updater = _MockUpdater();
shorebirdCodePush = ShorebirdCodePushFfi(updater: updater);
});
group('isShorebirdAvailable', () {
test('returns true', () {
expect(shorebirdCodePush.isShorebirdAvailable(), isTrue);
});
});
group('isNewPatchAvailableForDownload', () {
test('returns false if no update is available', () async {
when(() => updater.checkForUpdate()).thenAnswer((_) => false);
await expectLater(
shorebirdCodePush.isNewPatchAvailableForDownload(),
completion(isFalse),
);
});
test('returns true if an update is available', () async {
when(() => updater.checkForUpdate()).thenAnswer((_) => true);
await expectLater(
shorebirdCodePush.isNewPatchAvailableForDownload(),
completion(isTrue),
);
});
test('surfaces exception if updater throws exception', () async {
when(() => updater.checkForUpdate()).thenThrow(Exception('oh no'));
await expectLater(
() => shorebirdCodePush.isNewPatchAvailableForDownload(),
throwsException,
);
});
});
group('currentPatchNumber', () {
test('returns null if current patch is reported as 0', () async {
when(() => updater.currentPatchNumber()).thenReturn(0);
await expectLater(
shorebirdCodePush.currentPatchNumber(),
completion(isNull),
);
});
test('forwards the return value of updater.currentPatchNumber', () async {
when(() => updater.currentPatchNumber()).thenReturn(1);
await expectLater(
shorebirdCodePush.currentPatchNumber(),
completion(equals(1)),
);
});
test('surfaces exception if updater throws exception', () async {
when(() => updater.currentPatchNumber()).thenThrow(Exception('oh no'));
await expectLater(
() => shorebirdCodePush.currentPatchNumber(),
throwsException,
);
});
});
group('nextPatchNumber', () {
test('returns null if current patch is reported as 0', () async {
when(() => updater.nextPatchNumber()).thenReturn(0);
await expectLater(
shorebirdCodePush.nextPatchNumber(),
completion(isNull),
);
});
test('forwards the return value of updater.nextPatchNumber', () async {
when(() => updater.nextPatchNumber()).thenReturn(1);
await expectLater(
shorebirdCodePush.nextPatchNumber(),
completion(equals(1)),
);
});
test('surfaces exception if updater throws exception', () async {
when(() => updater.nextPatchNumber()).thenThrow(Exception('oh no'));
await expectLater(
() => shorebirdCodePush.nextPatchNumber(),
throwsException,
);
});
});
group('downloadUpdate', () {
test('completes', () async {
when(() => updater.downloadUpdate()).thenReturn(null);
await expectLater(
shorebirdCodePush.downloadUpdateIfAvailable(),
completes,
);
});
test('surfaces exception if updater throws exception', () async {
when(() => updater.downloadUpdate()).thenThrow(Exception('oh no'));
await expectLater(
() => shorebirdCodePush.downloadUpdateIfAvailable(),
throwsException,
);
});
});
group('isNewPatchReadyToInstall', () {
test('returns false if no new patch is available', () async {
when(() => updater.currentPatchNumber()).thenReturn(1);
when(() => updater.nextPatchNumber()).thenReturn(0);
await expectLater(
shorebirdCodePush.isNewPatchReadyToInstall(),
completion(isFalse),
);
});
test(
'returns false if the next patch is the same as the current patch',
() async {
when(() => updater.currentPatchNumber()).thenReturn(1);
when(() => updater.nextPatchNumber()).thenReturn(1);
await expectLater(
shorebirdCodePush.isNewPatchReadyToInstall(),
completion(isFalse),
);
},
);
test(
'returns true if the next patch number is greater '
'than the current patch number', () async {
when(() => updater.currentPatchNumber()).thenReturn(1);
when(() => updater.nextPatchNumber()).thenReturn(2);
await expectLater(
shorebirdCodePush.isNewPatchReadyToInstall(),
completion(isTrue),
);
});
});
});
}
@@ -1,74 +0,0 @@
// ignore_for_file: prefer_const_constructors
import 'dart:async';
import 'package:shorebird_code_push/src/shorebird_code_push_noop.dart';
import 'package:test/test.dart';
void main() {
group(ShorebirdCodePushNoop, () {
late List<String> printLogs;
late ShorebirdCodePushNoop shorebirdCodePush;
setUp(() {
printLogs = [];
shorebirdCodePush = runZoned(
ShorebirdCodePushNoop.new,
zoneSpecification: ZoneSpecification(
print: (self, parent, zone, line) => printLogs.add(line),
),
);
});
test('logs warning when instantiated', () {
const expected = '''
[ShorebirdCodePush]: Shorebird Engine not available, using no-op implementation.
This occurs when using package:shorebird_code_push in an app that does not
contain the Shorebird Engine. Most commonly this is due to building with
`flutter build` or `flutter run` instead of `shorebird release`.\n''';
expect(printLogs, equals([expected]));
});
group('isShorebirdAvailable', () {
test('returns false', () {
expect(shorebirdCodePush.isShorebirdAvailable(), isFalse);
});
});
group('isNewPatchAvailableForDownload', () {
test('returns false', () {
expectLater(
shorebirdCodePush.isNewPatchAvailableForDownload(),
completion(isFalse),
);
});
});
group('currentPatchNumber', () {
test('returns null', () {
expectLater(shorebirdCodePush.currentPatchNumber(), completion(isNull));
});
});
group('nextPatchNumber', () {
test('returns null', () {
expectLater(shorebirdCodePush.nextPatchNumber(), completion(isNull));
});
});
group('downloadUpdate', () {
test('completes', () {
expectLater(shorebirdCodePush.downloadUpdateIfAvailable(), completes);
});
});
group('isNewPatchReadyToInstall', () {
test('returns false', () {
expectLater(
shorebirdCodePush.isNewPatchReadyToInstall(),
completion(isFalse),
);
});
});
});
}
@@ -0,0 +1,570 @@
import 'dart:async';
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
import 'package:shorebird_code_push/src/shorebird_updater.dart';
import 'package:shorebird_code_push/src/shorebird_updater_io.dart';
import 'package:shorebird_code_push/src/updater.dart';
import 'package:test/test.dart';
import '../override_print.dart';
class _MockUpdater extends Mock implements Updater {}
Future<R> run<R>(
FutureOr<R> Function() computation, {
String? debugName,
}) async {
return computation();
}
void main() {
group(ShorebirdUpdaterImpl, () {
late Updater updater;
late ShorebirdUpdaterImpl shorebirdUpdater;
setUpAll(() {
registerFallbackValue(Pointer.fromAddress(0));
});
setUp(() {
updater = _MockUpdater();
});
group('isAvailable', () {
group('when updater is available', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(1);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns true', () {
expect(shorebirdUpdater.isAvailable, isTrue);
});
});
group('when updater is unavailable', () {
setUp(() {
when(updater.currentPatchNumber).thenThrow(Exception('oops'));
});
test(
'returns false',
overridePrint((_) {
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
expect(shorebirdUpdater.isAvailable, isFalse);
}),
);
});
});
group('readPatch', () {
group('when updater is unavailable', () {
setUp(() {
when(updater.currentPatchNumber).thenThrow(Exception('oops'));
});
test(
'returns null',
overridePrint((_) async {
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
await expectLater(
shorebirdUpdater.readCurrentPatch(),
completion(isNull),
);
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(isNull),
);
}),
);
});
group('when updater has no installed patches', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(updater.nextPatchNumber).thenReturn(0);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns null', () async {
await expectLater(
shorebirdUpdater.readCurrentPatch(),
completion(isNull),
);
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(isNull),
);
});
});
group('when updater has a downloaded patch', () {
const currentPatchNumber = 0;
const nextPatchNumber = 1;
setUp(() {
when(updater.currentPatchNumber).thenReturn(currentPatchNumber);
when(updater.nextPatchNumber).thenReturn(nextPatchNumber);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns correct patch numbers', () async {
await expectLater(
shorebirdUpdater.readCurrentPatch(),
completion(isNull),
);
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
nextPatchNumber,
),
),
);
});
});
group('when updater has an installed patch', () {
const currentPatchNumber = 1;
const nextPatchNumber = 1;
setUp(() {
when(updater.currentPatchNumber).thenReturn(currentPatchNumber);
when(updater.nextPatchNumber).thenReturn(nextPatchNumber);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns correct patch numbers', () async {
await expectLater(
shorebirdUpdater.readCurrentPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
currentPatchNumber,
),
),
);
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
nextPatchNumber,
),
),
);
});
});
group(
'when updater has an installed patch '
'and a new downloaded patch', () {
const currentPatchNumber = 1;
const nextPatchNumber = 2;
setUp(() {
when(updater.currentPatchNumber).thenReturn(currentPatchNumber);
when(updater.nextPatchNumber).thenReturn(nextPatchNumber);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns correct patch numbers', () async {
await expectLater(
shorebirdUpdater.readCurrentPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
currentPatchNumber,
),
),
);
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
nextPatchNumber,
),
),
);
});
});
group('when an exception occurs trying to read patches', () {
final currentPatchNumberReturnValues = [0, -1];
setUp(() {
when(updater.currentPatchNumber).thenAnswer((_) {
final value = currentPatchNumberReturnValues.removeAt(0);
if (value < 0) throw Exception('oops');
return value;
});
when(updater.nextPatchNumber).thenThrow(Exception('oops'));
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $ReadPatchException', () async {
await expectLater(
() => shorebirdUpdater.readCurrentPatch(),
throwsA(isA<ReadPatchException>()),
);
await expectLater(
() => shorebirdUpdater.readNextPatch(),
throwsA(isA<ReadPatchException>()),
);
});
});
});
group('checkForUpdate', () {
group('when updater is unavailable', () {
setUp(() {
when(updater.currentPatchNumber).thenThrow(Exception('oops'));
});
test(
'returns UpdateStatus.unavailable',
overridePrint((_) async {
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
await expectLater(
shorebirdUpdater.checkForUpdate(),
completion(equals(UpdateStatus.unavailable)),
);
}),
);
});
group('when updater has an update available', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(updater.checkForUpdate).thenReturn(true);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns UpdateStatus.outdated', () async {
await expectLater(
shorebirdUpdater.checkForUpdate(),
completion(equals(UpdateStatus.outdated)),
);
});
});
group('when updater has downloaded an update', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(updater.nextPatchNumber).thenReturn(1);
when(updater.checkForUpdate).thenReturn(false);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns UpdateStatus.restartRequired', () async {
await expectLater(
shorebirdUpdater.checkForUpdate(),
completion(equals(UpdateStatus.restartRequired)),
);
});
});
group('when updater installed an update and is up to date', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(1);
when(updater.nextPatchNumber).thenReturn(1);
when(updater.checkForUpdate).thenReturn(false);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('returns UpdateStatus.upToDate', () async {
await expectLater(
shorebirdUpdater.checkForUpdate(),
completion(equals(UpdateStatus.upToDate)),
);
});
});
});
group('update', () {
group('when updater is unavailable', () {
setUp(() {
when(updater.currentPatchNumber).thenThrow(Exception('oops'));
});
test(
'does nothing',
overridePrint((_) async {
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
await expectLater(shorebirdUpdater.update(), completes);
verifyNever(updater.downloadUpdate);
}),
);
});
group('when a nullptr result is returned', () {
setUp(() {
when(() => updater.currentPatchNumber()).thenReturn(0);
when(() => updater.update()).thenReturn(nullptr);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $UpdateException', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having(
(e) => e.message,
'message',
'An unknown error occurred.',
)
.having(
(e) => e.reason,
'reason',
UpdateFailureReason.unknown,
),
),
);
verify(updater.update).called(1);
});
});
group('when no update is available', () {
setUp(() {
when(() => updater.currentPatchNumber()).thenReturn(0);
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
result.ref.status = SHOREBIRD_NO_UPDATE;
result.ref.message = 'oops'.toNativeUtf8().cast<Char>();
addTearDown(() {
calloc
..free(result.ref.message)
..free(result);
});
when(() => updater.update()).thenReturn(result);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $UpdateException', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having(
(e) => e.message,
'message',
'oops',
)
.having(
(e) => e.reason,
'reason',
UpdateFailureReason.noUpdate,
),
),
);
verify(updater.update).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
});
group('when an error occurs during download', () {
setUp(() {
when(() => updater.currentPatchNumber()).thenReturn(0);
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
result.ref.status = SHOREBIRD_UPDATE_HAD_ERROR;
result.ref.message = 'oops'.toNativeUtf8().cast<Char>();
addTearDown(() {
calloc
..free(result.ref.message)
..free(result);
});
when(() => updater.update()).thenReturn(result);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $UpdateException', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having(
(e) => e.message,
'message',
'oops',
)
.having(
(e) => e.reason,
'reason',
UpdateFailureReason.downloadFailed,
),
),
);
verify(updater.update).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
});
group('when the downloaded patch is bad', () {
setUp(() {
when(() => updater.currentPatchNumber()).thenReturn(0);
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
result.ref.status = SHOREBIRD_UPDATE_IS_BAD_PATCH;
result.ref.message = 'oops'.toNativeUtf8().cast<Char>();
addTearDown(() {
calloc
..free(result.ref.message)
..free(result);
});
when(() => updater.update()).thenReturn(result);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $UpdateException', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having((e) => e.message, 'message', 'oops')
.having(
(e) => e.reason,
'reason',
UpdateFailureReason.installFailed,
),
),
);
verify(updater.update).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
});
group('when an unknown error occurs', () {
setUp(() {
when(() => updater.currentPatchNumber()).thenReturn(0);
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
result.ref.status = SHOREBIRD_UPDATE_ERROR;
addTearDown(() => calloc.free(result));
when(() => updater.update()).thenReturn(result);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $UpdateException', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having(
(e) => e.message,
'message',
'An unknown error occurred.',
)
.having(
(e) => e.reason,
'reason',
UpdateFailureReason.unknown,
),
),
);
verify(updater.update).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
});
group('when an outdated version of the engine is used', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(updater.nextPatchNumber).thenReturn(1);
when(() => updater.update()).thenThrow(Exception('oops'));
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('falls back to downloadUpdate', () async {
await expectLater(shorebirdUpdater.update(), completes);
verify(updater.update).called(1);
verify(updater.downloadUpdate).called(1);
verifyNever(() => updater.freeUpdateResult(any()));
});
group('when update fails', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(updater.nextPatchNumber).thenReturn(0);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws if legacy update fails', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>().having(
(e) => e.message,
'message',
'''
Downloading update failed but reason is unknown due to legacy updater.
Please upgrade the Shorebird Engine for improved error messages.''',
).having(
(e) => e.reason,
'reason',
UpdateFailureReason.unknown,
),
),
);
verify(updater.update).called(1);
verify(updater.downloadUpdate).called(1);
verifyNever(() => updater.freeUpdateResult(any()));
});
});
});
group('when an unsupported status code is returned', () {
setUp(() {
when(() => updater.currentPatchNumber()).thenReturn(0);
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
result.ref.status = -42; // invalid status code
result.ref.message = nullptr;
addTearDown(() => calloc.free(result));
when(() => updater.update()).thenReturn(result);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('throws $UpdateException', () async {
await expectLater(
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having(
(e) => e.message,
'message',
'An unknown error occurred.',
)
.having(
(e) => e.reason,
'reason',
UpdateFailureReason.unknown,
),
),
);
verify(updater.update).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
});
group('when download succeeds', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
result.ref.status = SHOREBIRD_UPDATE_INSTALLED;
addTearDown(() => calloc.free(result));
when(() => updater.update()).thenReturn(result);
shorebirdUpdater = ShorebirdUpdaterImpl(updater, run: run);
});
test('completes', () async {
await expectLater(shorebirdUpdater.update(), completes);
verify(updater.update).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
});
});
});
}
@@ -0,0 +1,15 @@
import 'package:shorebird_code_push/shorebird_code_push.dart';
import 'package:test/test.dart';
import '../override_print.dart';
void main() {
group(ShorebirdUpdater, () {
test(
'can be instantiated',
overridePrint((_) {
expect(ShorebirdUpdater.new, returnsNormally);
}),
);
});
}
@@ -0,0 +1,87 @@
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_code_push/src/shorebird_updater.dart';
import 'package:shorebird_code_push/src/shorebird_updater_web.dart';
import 'package:shorebird_code_push/src/updater.dart';
import 'package:test/test.dart';
import '../override_print.dart';
class _MockUpdater extends Mock implements Updater {}
void main() {
group(ShorebirdUpdaterImpl, () {
late Updater updater;
late ShorebirdUpdaterImpl shorebirdUpdater;
setUp(() {
updater = _MockUpdater();
});
test(
'logs unavailable error',
overridePrint((logs) {
shorebirdUpdater = ShorebirdUpdaterImpl(updater);
expect(
logs,
contains(
isA<String>().having(
(s) => s,
'message',
contains(
'''The Shorebird Updater is unavailable in the current environment.''',
),
),
),
);
}),
);
group('isAvailable', () {
test(
'returns false',
overridePrint((_) {
shorebirdUpdater = ShorebirdUpdaterImpl(updater);
expect(shorebirdUpdater.isAvailable, isFalse);
}),
);
});
group('readPatch', () {
test(
'returns null',
overridePrint((_) async {
await expectLater(
shorebirdUpdater.readCurrentPatch(),
completion(isNull),
);
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(isNull),
);
}),
);
});
group('checkForUpdate', () {
test(
'returns UpdateStatus.unavailable',
overridePrint((_) async {
await expectLater(
shorebirdUpdater.checkForUpdate(),
completion(equals(UpdateStatus.unavailable)),
);
}),
);
});
group('update', () {
test(
'does nothing',
overridePrint((_) async {
await expectLater(shorebirdUpdater.update(), completes);
verifyNever(updater.downloadUpdate);
}),
);
});
});
}
@@ -1,3 +1,6 @@
import 'dart:ffi';
import 'package:ffi/ffi.dart';
import 'package:mocktail/mocktail.dart';
import 'package:shorebird_code_push/src/generated/updater_bindings.g.dart';
import 'package:shorebird_code_push/src/updater.dart';
@@ -10,6 +13,10 @@ void main() {
late UpdaterBindings updaterBindings;
late Updater updater;
setUpAll(() {
registerFallbackValue(Pointer.fromAddress(0));
});
setUp(() {
updaterBindings = _MockUpdaterBindings();
@@ -62,5 +69,25 @@ void main() {
verify(() => updaterBindings.shorebird_update()).called(1);
});
});
group('update', () {
test('calls bindings.shorebird_update_with_result', () {
when(
() => updaterBindings.shorebird_update_with_result(),
).thenReturn(nullptr);
updater.update();
verify(() => updaterBindings.shorebird_update_with_result()).called(1);
});
});
group('freeUpdateResult', () {
test('calls bindings.shorebird_free_update_result', () {
final result = calloc.allocate<UpdateResult>(sizeOf<UpdateResult>());
updater.freeUpdateResult(result);
verify(
() => updaterBindings.shorebird_free_update_result(any()),
).called(1);
});
});
});
}