fix(shorebird_cli): hint about unreachable iOS 17+ devices instead of falling back to ios-deploy (#3734)

This commit is contained in:
Brandon DeRosier
2026-05-06 10:15:02 -07:00
committed by GitHub
parent 178359aa10
commit 5503ad6d27
5 changed files with 395 additions and 3 deletions
@@ -824,6 +824,37 @@ This is only applicable when previewing Android releases.''',
}
final shouldUseDeviceCtl = deviceForLaunch != null;
// Before falling back to ios-deploy, check if devicectl knows about a
// matching iOS 17+ device that is paired but currently unreachable
// (locked, screen off, or off the local network when wireless debugging
// is in use). ios-deploy does not work with iOS 17 or later, so the
// fallback would just produce an opaque failure. Surface a hint
// instead.
if (!shouldUseDeviceCtl) {
final unreachable = await _findUnreachableIos17OrLaterDevice(
deviceId: deviceId,
);
if (unreachable != null) {
deviceLocateProgress.fail(
'${unreachable.name} (iOS '
'${unreachable.osVersionString ?? 'unknown'}) is paired but '
'currently unreachable.',
);
logger.info(
'''
This usually means the device is locked, or that wireless debugging dropped because the screen turned off.
To preview on this device:
• Connect it via USB, or
• Unlock the device and make sure it is on the same Wi-Fi network as this Mac.
Skipping the ios-deploy fallback because it does not support iOS 17 or later.''',
);
return ExitCode.software.code;
}
}
final progressCompleteMessage = deviceForLaunch != null
? 'Using device ${deviceForLaunch.name}'
: '''No iOS 17+ device found, looking for devices running iOS 16 or lower''';
@@ -856,6 +887,37 @@ This is only applicable when previewing Android releases.''',
}
}
/// Returns a paired iOS 17+ device that devicectl knows about but cannot
/// currently reach (e.g. locked, screen off, off Wi-Fi while wireless
/// debugging), or `null` if no such device exists. When [deviceId] is
/// provided, only a device with that UDID is considered.
///
/// If devicectl can't be queried for any reason, returns `null` so the
/// caller can fall through to its existing behavior rather than surface a
/// hint based on partial information.
Future<AppleDevice?> _findUnreachableIos17OrLaterDevice({
String? deviceId,
}) async {
final List<AppleDevice> all;
try {
all = await devicectl.listAllIosDevices();
} on Exception catch (error, stackTrace) {
logger.detail('listAllIosDevices failed: $error $stackTrace');
return null;
}
final unreachableModern = all.where((device) {
if (device.isAvailable) return false;
final version = device.osVersion;
return version != null && version.major >= 17;
});
if (deviceId != null) {
return unreachableModern.firstWhereOrNull((d) => d.udid == deviceId);
}
return unreachableModern.firstOrNull;
}
/// Resolves the artifact path for the given parameters.
String getArtifactPath({
required String appId,
@@ -206,8 +206,22 @@ class Devicectl {
return ExitCode.success.code;
}
/// Lists iOS devices that we can install and launch apps on.
Future<List<AppleDevice>> listAvailableIosDevices() async {
/// Lists iOS devices that we can install and launch apps on. Excludes
/// devices that devicectl reports as unavailable (e.g. paired but
/// currently disconnected).
Future<List<AppleDevice>> listAvailableIosDevices() =>
_listIosDevices(availableOnly: true);
/// Lists every iOS device devicectl knows about, including ones that are
/// paired but currently unreachable (e.g. unplugged and locked, or
/// momentarily off the local network). Useful for diagnosing why
/// [listAvailableIosDevices] returned nothing.
Future<List<AppleDevice>> listAllIosDevices() =>
_listIosDevices(availableOnly: false);
Future<List<AppleDevice>> _listIosDevices({
required bool availableOnly,
}) async {
const failureErrorMessage = 'Failed to list devices';
const timeout = Duration(seconds: 5);
@@ -240,7 +254,11 @@ class Devicectl {
.whereType<Json>()
.map(AppleDevice.tryParse)
.whereType<AppleDevice>()
.where((device) => device.platform == 'iOS' && device.isAvailable)
.where(
(device) =>
device.platform == 'iOS' &&
(!availableOnly || device.isAvailable),
)
.toList();
}
@@ -0,0 +1,64 @@
{
"info": {
"arguments": ["devicectl", "list", "devices", "--json-output=out.json"],
"commandType": "devicectl.list.devices",
"environment": {
"TERM": "xterm-256color"
},
"jsonVersion": 2,
"outcome": "success",
"version": "443.24"
},
"result": {
"devices": [
{
"connectionProperties": {
"authenticationType": "manualPairing",
"pairingState": "paired",
"transportType": "wired",
"tunnelState": "connected"
},
"deviceProperties": {
"bootState": "booted",
"name": "Reachable iPhone",
"osBuildUpdate": "22F76",
"osVersionNumber": "18.5"
},
"hardwareProperties": {
"deviceType": "iPhone",
"marketingName": "iPhone 16 Pro Max",
"platform": "iOS",
"productType": "iPhone17,2",
"udid": "11111111-1111111111111111"
},
"identifier": "11111111-1111111111111111",
"tags": [],
"visibilityClass": "default"
},
{
"connectionProperties": {
"authenticationType": "manualPairing",
"lastConnectionDate": "2026-04-15T18:22:11.000Z",
"pairingState": "paired",
"tunnelState": "unavailable"
},
"deviceProperties": {
"bootState": "booted",
"name": "Unreachable iPhone",
"osBuildUpdate": "21E236",
"osVersionNumber": "17.4.1"
},
"hardwareProperties": {
"deviceType": "iPhone",
"marketingName": "iPhone 15",
"platform": "iOS",
"productType": "iPhone15,4",
"udid": "22222222-2222222222222222"
},
"identifier": "22222222-2222222222222222",
"tags": [],
"visibilityClass": "default"
}
]
}
}
@@ -1503,6 +1503,9 @@ channel: ${track.channel}
when(
() => devicectl.deviceForLaunch(deviceId: any(named: 'deviceId')),
).thenAnswer((_) async => null);
when(
() => devicectl.listAllIosDevices(),
).thenAnswer((_) async => []);
when(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: any(named: 'runnerAppDirectory'),
@@ -1680,6 +1683,171 @@ channel: ${track.channel}
});
});
group('when devicectl knows of an unreachable iOS 17+ device', () {
late AppleDevice unreachableDevice;
setUp(() {
unreachableDevice = MockAppleDevice();
when(() => unreachableDevice.name).thenReturn('Locked iPhone');
when(
() => unreachableDevice.udid,
).thenReturn('UNREACHABLE-UDID');
when(() => unreachableDevice.isAvailable).thenReturn(false);
when(
() => unreachableDevice.osVersion,
).thenReturn(Version(17, 4, 1));
when(
() => unreachableDevice.osVersionString,
).thenReturn('17.4.1');
when(
() => devicectl.listAllIosDevices(),
).thenAnswer((_) async => [unreachableDevice]);
setupIOSShorebirdYaml();
});
test('does not attempt ios-deploy and exits with code 70', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verifyNever(
() => iosDeploy.installAndLaunchApp(
bundlePath: any(named: 'bundlePath'),
deviceId: any(named: 'deviceId'),
),
);
});
test('shows the device name and OS version in the failure', () async {
await runWithOverrides(command.run);
verify(
() => progress.fail(
any(
that: stringContainsInOrder([
'Locked iPhone',
'17.4.1',
'paired',
'unreachable',
]),
),
),
).called(1);
});
test(
'tells the user to plug in or unlock and that ios-deploy was skipped',
() async {
await runWithOverrides(command.run);
verify(
() => logger.info(
any(
that: stringContainsInOrder([
'Connect it via USB',
'Unlock the device',
'ios-deploy',
'iOS 17',
]),
),
),
).called(1);
},
);
group('when --device-id matches the unreachable device', () {
setUp(() {
// The existing --device-id branch in installAndLaunchIos calls
// listAvailableIosDevices(); the unreachable device is filtered
// out of that list, so it returns empty.
when(
() => devicectl.listAvailableIosDevices(),
).thenAnswer((_) async => []);
when(
() => argResults['device-id'],
).thenAnswer((_) => 'UNREACHABLE-UDID');
});
test('still emits the hint and skips ios-deploy', () async {
final result = await runWithOverrides(command.run);
expect(result, equals(ExitCode.software.code));
verifyNever(
() => iosDeploy.installAndLaunchApp(
bundlePath: any(named: 'bundlePath'),
deviceId: any(named: 'deviceId'),
),
);
});
});
group('when --device-id does not match any known device', () {
setUp(() {
when(
() => devicectl.listAvailableIosDevices(),
).thenAnswer((_) async => []);
when(
() => argResults['device-id'],
).thenAnswer((_) => 'SOME-OTHER-UDID');
});
test('falls back to ios-deploy (no hint)', () async {
await runWithOverrides(command.run);
verify(
() => iosDeploy.installAndLaunchApp(
bundlePath: any(named: 'bundlePath'),
deviceId: 'SOME-OTHER-UDID',
),
).called(1);
});
});
});
group('when devicectl knows of an unreachable iOS 16 device', () {
late AppleDevice oldUnreachableDevice;
setUp(() {
oldUnreachableDevice = MockAppleDevice();
when(() => oldUnreachableDevice.name).thenReturn('Old iPhone');
when(() => oldUnreachableDevice.udid).thenReturn('OLD-UDID');
when(() => oldUnreachableDevice.isAvailable).thenReturn(false);
when(
() => oldUnreachableDevice.osVersion,
).thenReturn(Version(16, 7, 0));
when(
() => oldUnreachableDevice.osVersionString,
).thenReturn('16.7.0');
when(
() => devicectl.listAllIosDevices(),
).thenAnswer((_) async => [oldUnreachableDevice]);
setupIOSShorebirdYaml();
});
test('falls back to ios-deploy (the hint is iOS 17+ only)', () async {
await runWithOverrides(command.run);
verify(
() => iosDeploy.installAndLaunchApp(
bundlePath: any(named: 'bundlePath'),
deviceId: any(named: 'deviceId'),
),
).called(1);
});
});
group('when listAllIosDevices throws', () {
setUp(() {
when(
() => devicectl.listAllIosDevices(),
).thenThrow(Exception('devicectl exploded'));
setupIOSShorebirdYaml();
});
test('silently falls through to ios-deploy', () async {
await runWithOverrides(command.run);
verify(
() => iosDeploy.installAndLaunchApp(
bundlePath: any(named: 'bundlePath'),
deviceId: any(named: 'deviceId'),
),
).called(1);
});
});
group('when install/launch throws', () {
setUp(() {
setupIOSShorebirdYaml();
@@ -2717,6 +2885,9 @@ channel: ${DeploymentTrack.staging.channel}
when(
() => devicectl.deviceForLaunch(deviceId: any(named: 'deviceId')),
).thenAnswer((_) async => null);
when(
() => devicectl.listAllIosDevices(),
).thenAnswer((_) async => []);
when(
() => devicectl.installAndLaunchApp(
runnerAppDirectory: any(named: 'runnerAppDirectory'),
@@ -602,6 +602,83 @@ void main() {
expect(secondDevice.platform, equals('iOS'));
});
});
group('when one of the devices is paired but unreachable', () {
setUp(() {
jsonOutput = File(
'$fixturesPath/device_list_with_unreachable.json',
).readAsStringSync();
});
test('omits the unreachable device', () async {
final devices = await runWithOverrides(
devicectl.listAvailableIosDevices,
);
expect(devices, hasLength(1));
expect(devices.first.name, equals('Reachable iPhone'));
expect(
devices.first.udid,
equals('11111111-1111111111111111'),
);
});
});
});
group('listAllIosDevices', () {
setUp(() {
exitCode = ExitCode.success;
});
group('when one of the devices is paired but unreachable', () {
setUp(() {
jsonOutput = File(
'$fixturesPath/device_list_with_unreachable.json',
).readAsStringSync();
});
test(
'returns the unreachable device alongside reachable ones',
() async {
final devices = await runWithOverrides(
devicectl.listAllIosDevices,
);
expect(devices, hasLength(2));
final reachable = devices.firstWhere((d) => d.isAvailable);
expect(reachable.name, equals('Reachable iPhone'));
expect(reachable.osVersionString, equals('18.5'));
final unreachable = devices.firstWhere((d) => !d.isAvailable);
expect(unreachable.name, equals('Unreachable iPhone'));
expect(unreachable.osVersionString, equals('17.4.1'));
expect(
unreachable.udid,
equals('22222222-2222222222222222'),
);
},
);
});
group('when command fails', () {
setUp(() {
jsonOutput = File(
'$fixturesPath/device_list_failure.json',
).readAsStringSync();
});
test('throws a DevicectlException', () {
expect(
runWithOverrides(devicectl.listAllIosDevices),
throwsA(
isA<DevicectlException>().having(
(e) => e.message,
'message',
'Failed to list devices',
),
),
);
});
});
});
});
}