From 5503ad6d2707c76765f073da085cb9e84cbd6f07 Mon Sep 17 00:00:00 2001 From: Brandon DeRosier Date: Wed, 6 May 2026 10:15:02 -0700 Subject: [PATCH] fix(shorebird_cli): hint about unreachable iOS 17+ devices instead of falling back to ios-deploy (#3734) --- .../lib/src/commands/preview_command.dart | 62 +++++++ .../src/executables/devicectl/devicectl.dart | 24 ++- .../device_list_with_unreachable.json | 64 +++++++ .../src/commands/preview_command_test.dart | 171 ++++++++++++++++++ .../executables/devicectl/devicectl_test.dart | 77 ++++++++ 5 files changed, 395 insertions(+), 3 deletions(-) create mode 100644 packages/shorebird_cli/test/fixtures/devicectl/device_list_with_unreachable.json diff --git a/packages/shorebird_cli/lib/src/commands/preview_command.dart b/packages/shorebird_cli/lib/src/commands/preview_command.dart index 37ec63ca..5320734f 100644 --- a/packages/shorebird_cli/lib/src/commands/preview_command.dart +++ b/packages/shorebird_cli/lib/src/commands/preview_command.dart @@ -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 _findUnreachableIos17OrLaterDevice({ + String? deviceId, + }) async { + final List 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, diff --git a/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart b/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart index 985459a3..40432e8c 100644 --- a/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart +++ b/packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart @@ -206,8 +206,22 @@ class Devicectl { return ExitCode.success.code; } - /// Lists iOS devices that we can install and launch apps on. - Future> 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> 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> listAllIosDevices() => + _listIosDevices(availableOnly: false); + + Future> _listIosDevices({ + required bool availableOnly, + }) async { const failureErrorMessage = 'Failed to list devices'; const timeout = Duration(seconds: 5); @@ -240,7 +254,11 @@ class Devicectl { .whereType() .map(AppleDevice.tryParse) .whereType() - .where((device) => device.platform == 'iOS' && device.isAvailable) + .where( + (device) => + device.platform == 'iOS' && + (!availableOnly || device.isAvailable), + ) .toList(); } diff --git a/packages/shorebird_cli/test/fixtures/devicectl/device_list_with_unreachable.json b/packages/shorebird_cli/test/fixtures/devicectl/device_list_with_unreachable.json new file mode 100644 index 00000000..57875399 --- /dev/null +++ b/packages/shorebird_cli/test/fixtures/devicectl/device_list_with_unreachable.json @@ -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" + } + ] + } +} diff --git a/packages/shorebird_cli/test/src/commands/preview_command_test.dart b/packages/shorebird_cli/test/src/commands/preview_command_test.dart index 4df869ef..cc0b1a6c 100644 --- a/packages/shorebird_cli/test/src/commands/preview_command_test.dart +++ b/packages/shorebird_cli/test/src/commands/preview_command_test.dart @@ -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'), diff --git a/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart b/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart index 80b2e762..5badb03e 100644 --- a/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart +++ b/packages/shorebird_cli/test/src/executables/devicectl/devicectl_test.dart @@ -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().having( + (e) => e.message, + 'message', + 'Failed to list devices', + ), + ), + ); + }); + }); }); }); }