feat: add device ID override functionality to ShorebirdUpdater
ci / ✅ Semantic Pull Request (push) Has been cancelled
ci / 🔤 Check Spelling (push) Has been cancelled
ci / 👀 Detect Changes (push) Has been cancelled
Shorebird CI / changes (push) Has been cancelled
Shorebird CI / CSpell (push) Has been cancelled
ci / 🦀 Build ${{ matrix.crate }} (${{ matrix.os }}) (push) Has been cancelled
ci / 🎯 Build ${{ matrix.package }} (push) Has been cancelled
ci / ci (push) Has been cancelled
Shorebird CI / shorebird_code_push (push) Has been cancelled
Shorebird CI / shorebird_code_push_example (push) Has been cancelled
Shorebird CI / required (push) Has been cancelled

- Introduced `setDeviceIdOverride` method in `ShorebirdUpdater` to allow clients to set a custom device ID for patch checks.
- Implemented the method in `ShorebirdUpdaterImpl` for both IO and web platforms.
- Updated the `Updater` class to handle the device ID override in native bindings.
- Added tests for the new functionality in both IO and web test suites, ensuring proper behavior when the updater is available and unavailable.
This commit is contained in:
Tony
2026-06-24 03:02:58 +08:00
parent a591b7f6b9
commit 3ac748ff28
13 changed files with 1824 additions and 1589 deletions
+16
View File
@@ -120,6 +120,22 @@ updater.checkForUpdate(track: UpdateTrack('my-custom-track'));
tracks. See [#3484](https://github.com/shorebirdtech/shorebird/issues/3484)
for details.
### Device id override
The updater generates a random per-install `client_id` on first startup and
persists it locally. Self-hosted update servers that encrypt or target patches
per app/account device can override that id before checking for updates:
```dart
final updater = ShorebirdUpdater();
await updater.setDeviceIdOverride('stable-app-device-id');
await updater.checkForUpdate();
```
Do not put secrets in this value. It is sent to the update server as
`client_id`; encryption keys should be derived from server-side or
app-provided key material.
## Join us on Discord!
We have an active [Discord server](https://discord.gg/shorebird) where you can
File diff suppressed because it is too large Load Diff
@@ -127,6 +127,16 @@ abstract class ShorebirdUpdater {
/// Throws a [ReadPatchException] if the read is unsuccessful.
Future<Patch?> readNextPatch();
/// Overrides the generated random per-install device id used in patch
/// checks. Call this before [checkForUpdate] or [update] if your server
/// needs patch delivery bound to an app/account-specific identifier.
///
/// The updater persists the override in its local state after a successful
/// call. The value should be stable for this installation/account and must
/// not contain sensitive secrets; it is sent to the update server as
/// `client_id`.
Future<void> setDeviceIdOverride(String deviceId);
/// Checks for an available patch on [track] (or [UpdateTrack.stable] if no
/// track is specified) and returns the [UpdateStatus].
/// This method should be used to determine the update status before calling
@@ -9,12 +9,9 @@ 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,
});
typedef IsolateRun =
Future<R> Function<R>(FutureOr<R> Function(), {String? debugName});
/// {@template shorebird_updater_io}
/// The Shorebird IO Updater.
@@ -22,8 +19,8 @@ typedef IsolateRun = Future<R> Function<R>(
class ShorebirdUpdaterImpl implements ShorebirdUpdater {
/// {@macro shorebird_updater_io}
ShorebirdUpdaterImpl({Updater? updater, IsolateRun? run})
: _updater = updater ?? const Updater(),
_run = run ?? Isolate.run {
: _updater = updater ?? const Updater(),
_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
@@ -55,18 +52,25 @@ class ShorebirdUpdaterImpl implements ShorebirdUpdater {
@override
Future<Patch?> readNextPatch() => _readPatch(_updater.nextPatchNumber);
@override
Future<void> setDeviceIdOverride(String deviceId) async {
if (!_isAvailable) return;
final didSet = await _run(() => _updater.setDeviceIdOverride(deviceId));
if (!didSet) {
throw StateError('Unable to set Shorebird device id override.');
}
}
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');
}
},
);
return _run(() {
try {
final patchNumber = fn();
return patchNumber > 0 ? Patch(number: patchNumber) : null;
} catch (error) {
throw ReadPatchException(message: '$error');
}
});
}
@override
@@ -74,8 +78,9 @@ class ShorebirdUpdaterImpl implements ShorebirdUpdater {
if (!_isAvailable) return UpdateStatus.unavailable;
// First, check to see whether an update is available for download.
final isUpdateAvailable =
await _run(() => _updater.checkForDownloadableUpdate(track: track));
final isUpdateAvailable = await _run(
() => _updater.checkForDownloadableUpdate(track: track),
);
if (isUpdateAvailable) return UpdateStatus.outdated;
// If no new update is available for download, see if a new patch exists
@@ -18,6 +18,9 @@ class ShorebirdUpdaterImpl implements ShorebirdUpdater {
@override
Future<Patch?> readNextPatch() async => null;
@override
Future<void> setDeviceIdOverride(String deviceId) async {}
@override
Future<UpdateStatus> checkForUpdate({UpdateTrack? track}) async =>
UpdateStatus.unavailable;
+15 -2
View File
@@ -16,8 +16,9 @@ class Updater {
/// The ffi bindings to the Updater library.
@visibleForTesting
static UpdaterBindings bindings =
UpdaterBindings(ffi.DynamicLibrary.process());
static UpdaterBindings bindings = UpdaterBindings(
ffi.DynamicLibrary.process(),
);
/// The currently active patch number.
int currentPatchNumber() => bindings.shorebird_current_boot_patch_number();
@@ -32,6 +33,18 @@ class Updater {
track == null ? ffi.nullptr : track.name.toNativeUtf8().cast<Char>(),
);
/// Overrides the generated per-install device id sent in patch checks.
bool setDeviceIdOverride(String deviceId) {
final nativeDeviceId = deviceId.toNativeUtf8();
try {
return bindings.shorebird_set_device_id_override(
nativeDeviceId.cast<Char>(),
);
} finally {
malloc.free(nativeDeviceId);
}
}
/// Downloads the latest patch, if available and returns an [UpdateResult]
/// to indicate whether the update was successful.
Pointer<UpdateResult> update({UpdateTrack? track}) =>
@@ -118,11 +118,7 @@ void main() {
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
nextPatchNumber,
),
isA<Patch>().having((p) => p.number, 'number', nextPatchNumber),
),
);
});
@@ -151,11 +147,7 @@ void main() {
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
nextPatchNumber,
),
isA<Patch>().having((p) => p.number, 'number', nextPatchNumber),
),
);
});
@@ -186,11 +178,7 @@ void main() {
await expectLater(
shorebirdUpdater.readNextPatch(),
completion(
isA<Patch>().having(
(p) => p.number,
'number',
nextPatchNumber,
),
isA<Patch>().having((p) => p.number, 'number', nextPatchNumber),
),
);
});
@@ -221,6 +209,61 @@ void main() {
});
});
group('setDeviceIdOverride', () {
group('when updater is unavailable', () {
setUp(() {
when(updater.currentPatchNumber).thenThrow(Exception('oops'));
});
test(
'does nothing',
overridePrint((_) async {
shorebirdUpdater = ShorebirdUpdaterImpl(updater: updater, run: run);
await expectLater(
shorebirdUpdater.setDeviceIdOverride('developer-device-id'),
completes,
);
verifyNever(() => updater.setDeviceIdOverride(any()));
}),
);
});
group('when updater accepts the override', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(
() => updater.setDeviceIdOverride('developer-device-id'),
).thenReturn(true);
shorebirdUpdater = ShorebirdUpdaterImpl(updater: updater, run: run);
});
test('forwards the device id to the updater', () async {
await expectLater(
shorebirdUpdater.setDeviceIdOverride('developer-device-id'),
completes,
);
verify(
() => updater.setDeviceIdOverride('developer-device-id'),
).called(1);
});
});
group('when updater rejects the override', () {
setUp(() {
when(updater.currentPatchNumber).thenReturn(0);
when(() => updater.setDeviceIdOverride('')).thenReturn(false);
shorebirdUpdater = ShorebirdUpdaterImpl(updater: updater, run: run);
});
test('throws a StateError', () async {
await expectLater(
shorebirdUpdater.setDeviceIdOverride(''),
throwsA(isA<StateError>()),
);
});
});
});
group('checkForUpdate', () {
group('when updater is unavailable', () {
setUp(() {
@@ -304,7 +347,7 @@ void main() {
group('when current patch has been rolled back', () {
setUp(() {
// The app is currently running patch 1, but checkForDownloadableUpdate
// The app is running patch 1, but checkForDownloadableUpdate
// triggered a rollback which set next_boot_patch to None (0).
when(updater.currentPatchNumber).thenReturn(1);
when(updater.nextPatchNumber).thenReturn(0);
@@ -331,15 +374,18 @@ void main() {
shorebirdUpdater = ShorebirdUpdaterImpl(updater: updater, run: run);
});
test('forwards the provided track to the underlying updater call',
() async {
await expectLater(
shorebirdUpdater.checkForUpdate(track: track),
completion(equals(UpdateStatus.outdated)),
);
verify(() => updater.checkForDownloadableUpdate(track: track))
.called(1);
});
test(
'forwards the provided track to the underlying updater call',
() async {
await expectLater(
shorebirdUpdater.checkForUpdate(track: track),
completion(equals(UpdateStatus.outdated)),
);
verify(
() => updater.checkForDownloadableUpdate(track: track),
).called(1);
},
);
});
});
@@ -401,15 +447,17 @@ void main() {
shorebirdUpdater = ShorebirdUpdaterImpl(updater: updater, run: run);
});
test('propagates the exception and does not call freeUpdateResult',
() async {
await expectLater(
shorebirdUpdater.update(),
throwsA(isA<Exception>()),
);
verify(() => updater.update()).called(1);
verifyNever(() => updater.freeUpdateResult(any()));
});
test(
'propagates the exception and does not call freeUpdateResult',
() async {
await expectLater(
shorebirdUpdater.update(),
throwsA(isA<Exception>()),
);
verify(() => updater.update()).called(1);
verifyNever(() => updater.freeUpdateResult(any()));
},
);
});
group('when no update is available', () {
@@ -483,11 +531,7 @@ void main() {
shorebirdUpdater.update,
throwsA(
isA<UpdateException>()
.having(
(e) => e.message,
'message',
'oops',
)
.having((e) => e.message, 'message', 'oops')
.having(
(e) => e.reason,
'reason',
@@ -627,15 +671,14 @@ void main() {
shorebirdUpdater = ShorebirdUpdaterImpl(updater: updater, run: run);
});
test('forwards the provided track to the underlying updater call',
() async {
await expectLater(
shorebirdUpdater.update(track: track),
completes,
);
verify(() => updater.update(track: track)).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
});
test(
'forwards the provided track to the underlying updater call',
() async {
await expectLater(shorebirdUpdater.update(track: track), completes);
verify(() => updater.update(track: track)).called(1);
verify(() => updater.freeUpdateResult(any())).called(1);
},
);
});
});
});
@@ -65,6 +65,18 @@ void main() {
);
});
group('setDeviceIdOverride', () {
test(
'does nothing',
overridePrint((_) async {
await expectLater(
shorebirdUpdater.setDeviceIdOverride('developer-device-id'),
completes,
);
}),
);
});
group('update', () {
test(
'does nothing',
+65 -40
View File
@@ -40,56 +40,59 @@ void main() {
});
group('checkForDownloadableUpdate', () {
test('forwards the result of shorebird_check_for_downloadable_update',
() {
when(
() => updaterBindings.shorebird_check_for_downloadable_update(
nullptr,
),
).thenReturn(true);
expect(updater.checkForDownloadableUpdate(), isTrue);
test(
'forwards the result of shorebird_check_for_downloadable_update',
() {
when(
() => updaterBindings.shorebird_check_for_downloadable_update(
nullptr,
),
).thenReturn(true);
expect(updater.checkForDownloadableUpdate(), isTrue);
when(
() => updaterBindings.shorebird_check_for_downloadable_update(
nullptr,
),
).thenReturn(false);
expect(updater.checkForDownloadableUpdate(), isFalse);
});
when(
() => updaterBindings.shorebird_check_for_downloadable_update(
nullptr,
),
).thenReturn(false);
expect(updater.checkForDownloadableUpdate(), isFalse);
},
);
group('when a track is provided', () {
setUp(() {
when(
() => updaterBindings.shorebird_check_for_downloadable_update(
any(),
),
() =>
updaterBindings.shorebird_check_for_downloadable_update(any()),
).thenReturn(true);
});
test('forwards the result of shorebird_check_for_downloadable_update',
() {
expect(
updater.checkForDownloadableUpdate(track: UpdateTrack.beta),
isTrue,
);
test(
'forwards the result of shorebird_check_for_downloadable_update',
() {
expect(
updater.checkForDownloadableUpdate(track: UpdateTrack.beta),
isTrue,
);
expect(
updater.checkForDownloadableUpdate(track: UpdateTrack.stable),
isTrue,
);
expect(
updater.checkForDownloadableUpdate(track: UpdateTrack.stable),
isTrue,
);
final captured = verify(
() => updaterBindings.shorebird_check_for_downloadable_update(
captureAny(),
),
).captured;
expect(
captured.map(
(cstr) => (cstr as Pointer<Char>).cast<Utf8>().toDartString(),
),
equals(['beta', 'stable']),
);
});
final captured = verify(
() => updaterBindings.shorebird_check_for_downloadable_update(
captureAny(),
),
).captured;
expect(
captured.map(
(cstr) => (cstr as Pointer<Char>).cast<Utf8>().toDartString(),
),
equals(['beta', 'stable']),
);
},
);
});
});
@@ -103,6 +106,28 @@ void main() {
});
});
group('setDeviceIdOverride', () {
test('forwards the device id to shorebird_set_device_id_override', () {
late String capturedDeviceId;
when(
() => updaterBindings.shorebird_set_device_id_override(any()),
).thenAnswer((invocation) {
capturedDeviceId =
(invocation.positionalArguments.single as Pointer<Char>)
.cast<Utf8>()
.toDartString();
return true;
});
expect(updater.setDeviceIdOverride('developer-device-id'), isTrue);
verify(
() => updaterBindings.shorebird_set_device_id_override(captureAny()),
).called(1);
expect(capturedDeviceId, 'developer-device-id');
});
});
group('update', () {
test('calls bindings.shorebird_update_with_result', () {
when(