fix(shorebird_cli): hint about unreachable iOS 17+ devices instead of falling back to ios-deploy (#3734)
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
+64
@@ -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',
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user