Skip to content

Commit

Permalink
feat(shorebird_cli): tail logs in shorebird preview for iOS 17+ (#1478)
Browse files Browse the repository at this point in the history
  • Loading branch information
bryanoltman authored Nov 10, 2023
1 parent 43907bb commit 5e0b941
Show file tree
Hide file tree
Showing 12 changed files with 528 additions and 104 deletions.
2 changes: 2 additions & 0 deletions packages/shorebird_cli/bin/shorebird.dart
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command_runner.dart';
import 'package:shorebird_cli/src/doctor.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/executables/idevicesyslog.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/os/os.dart';
import 'package:shorebird_cli/src/patch_diff_checker.dart';
Expand Down Expand Up @@ -38,6 +39,7 @@ Future<void> main(List<String> args) async {
engineConfigRef,
gitRef,
gradlewRef,
idevicesyslogRef,
iosDeployRef,
javaRef,
loggerRef,
Expand Down
41 changes: 29 additions & 12 deletions packages/shorebird_cli/lib/src/commands/preview_command.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import 'package:shorebird_cli/src/cache.dart';
import 'package:shorebird_cli/src/code_push_client_wrapper.dart';
import 'package:shorebird_cli/src/command.dart';
import 'package:shorebird_cli/src/deployment_track.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/executables.dart';
import 'package:shorebird_cli/src/http_client/http_client.dart';
import 'package:shorebird_cli/src/logger.dart';
Expand Down Expand Up @@ -349,30 +350,46 @@ class PreviewCommand extends ShorebirdCommand {
return ExitCode.software.code;
}

final deviceId = results['device-id'] as String?;

try {
final shouldUseDeviceCtl = await devicectl.isSupported(
deviceId: deviceId,
);
final deviceLocateProgress = logger.progress('Locating device for run');
final AppleDevice? deviceForLaunch;
// Try to find a device using devicectl first. If that fails, fall back to
// ios-deploy.
if (deviceId != null) {
final deviceCtlDevices = await devicectl.listAvailableIosDevices();
deviceForLaunch = deviceCtlDevices.firstWhereOrNull(
(device) => device.udid == deviceId,
);
} else {
deviceForLaunch = await devicectl.deviceForLaunch();
}

final shouldUseDeviceCtl = deviceForLaunch != null;
final progressCompleteMessage = deviceForLaunch != null
? 'Using device ${deviceForLaunch.name}'
: null;
deviceLocateProgress.complete(progressCompleteMessage);

final int exitCode;
final int installExitCode;
if (shouldUseDeviceCtl) {
logger.detail('Using devicectl to install and launch.');
exitCode = await devicectl.installAndLaunchApp(
logger.detail(
'Using devicectl to install and launch on device $deviceId.',
);
installExitCode = await devicectl.installAndLaunchApp(
runnerAppDirectory: runnerDirectory,
deviceId: deviceId,
device: deviceForLaunch,
);
} else {
logger.detail('Using ios-deploy to install and launch.');
exitCode = await iosDeploy.installAndLaunchApp(
installExitCode = await iosDeploy.installAndLaunchApp(
bundlePath: runnerDirectory.path,
deviceId: deviceId,
);
}

return exitCode;
} catch (_) {
return installExitCode;
} catch (error, stackTrace) {
logger.detail('Error launching app. $error $stackTrace');
return ExitCode.software.code;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ part 'apple_device.g.dart';
/// {@macro apple_device}
class AppleDevice {
const AppleDevice({
required this.identifier,
required this.deviceProperties,
required this.hardwareProperties,
required this.connectionProperties,
Expand All @@ -23,10 +22,6 @@ class AppleDevice {
/// Creates an [AppleDevice] from JSON.
static AppleDevice fromJson(Json json) => _$AppleDeviceFromJson(json);

/// The device's unique identifier of the form
/// DEADBEEF-DEAD-BEEF-DEAD-BEEFDEADBEEF.
final String identifier;

/// Information about the device itself.
final DeviceProperties deviceProperties;

Expand Down Expand Up @@ -54,15 +49,27 @@ class AppleDevice {
/// [ConnectionProperties.tunnelState] for more information about known
/// tunnelState values and what they (seem to) represent.
bool get isAvailable => connectionProperties.tunnelState != 'unavailable';

/// The device's unique identifier of the form 12345678-1234567890ABCDEF
String get udid => hardwareProperties.udid;

/// Whether the device is connected via USB.
bool get isWired => connectionProperties.transportType == 'wired';

@override
String toString() => '$name ($osVersionString ${hardwareProperties.udid})';
}

@JsonSerializable(createToJson: false, fieldRename: FieldRename.none)
class HardwareProperties {
const HardwareProperties({required this.platform});
const HardwareProperties({required this.platform, required this.udid});

/// The device's platform (e.g., "iOS").
final String platform;

/// The unique identifier of this device
final String udid;

static HardwareProperties fromJson(Json json) =>
_$HardwarePropertiesFromJson(json);
}
Expand All @@ -83,7 +90,11 @@ class DeviceProperties {

@JsonSerializable(createToJson: false, fieldRename: FieldRename.none)
class ConnectionProperties {
const ConnectionProperties({required this.tunnelState});
const ConnectionProperties({required this.tunnelState, this.transportType});

/// How the device is connected. Values seen in development include
/// "localNetwork" and "wired". Will be absent if the device is not connected.
final String? transportType;

/// The device's connection state. Values seen in development (as devicectl
/// is seemingly undocumented) include:
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

55 changes: 24 additions & 31 deletions packages/shorebird_cli/lib/src/executables/devicectl/devicectl.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/executables/devicectl/nserror.dart';
import 'package:shorebird_cli/src/executables/idevicesyslog.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/process.dart';
import 'package:shorebird_cli/src/third_party/flutter_tools/lib/flutter_tools.dart';
Expand Down Expand Up @@ -66,28 +67,22 @@ class Devicectl {

/// Returns the first available iOS device, or the device with the given
/// [deviceId] if provided. Devices that are running iOS <17 are not
/// "CoreDevice"s and are not visible to devicectl.
Future<AppleDevice?> _deviceForLaunch({String? deviceId}) async {
/// "CoreDevice"s and are not visible to devicectl. Returns null if devicectl
/// is not available or no devices are found.
Future<AppleDevice?> deviceForLaunch({String? deviceId}) async {
if (!await _isAvailable()) {
return null;
}

final devices = await listAvailableIosDevices();

if (deviceId != null) {
return devices.firstWhereOrNull((d) => d.identifier == deviceId);
return devices.firstWhereOrNull((d) => d.udid == deviceId);
} else {
return devices.firstOrNull;
}
}

/// Whether we should use `devicectl` to install and launch the app on the
/// device with the given [deviceId], or the first available device we find if
/// [deviceId] is not provided.
Future<bool> isSupported({String? deviceId}) async {
if (!await _isAvailable()) {
return false;
}

return await _deviceForLaunch(deviceId: deviceId) != null;
}

/// Installs the given [runnerApp] on the device with the given [deviceId].
///
/// Returns the bundle ID of the installed app.
Expand Down Expand Up @@ -169,27 +164,25 @@ class Devicectl {
}
}

/// Installs and launches the given [runnerAppDirectory] on the device with
/// the given [deviceId]. If no [deviceId] is provided, the first available
/// device returned by [listAvailableIosDevices] will be used.
/// Installs and launches the given [runnerAppDirectory] on [device]. [device]
/// should be obtained using [listAvailableIosDevices]. After successfully
/// launching the app, this method will start a logger process to capture
/// logs from the device.
Future<int> installAndLaunchApp({
required Directory runnerAppDirectory,
String? deviceId,
required AppleDevice device,
}) async {
final deviceProgress = logger.progress('Finding device for run');
final device = await _deviceForLaunch(deviceId: deviceId);
if (device == null) {
deviceProgress.fail('No devices found');
return ExitCode.software.code;
}
deviceProgress.complete();

final installProgress = logger.progress('Installing app');

// Start the logger before launching the app to ensure we capture all
// logs. Starting the logger process after launching the app can result
// in missing some shorebird logs.
final loggerExitCodeFuture = idevicesyslog.startLogger(device: device);

final String bundleId;
try {
bundleId = await installApp(
deviceId: device.identifier,
deviceId: device.udid,
runnerApp: runnerAppDirectory,
);
} catch (error) {
Expand All @@ -200,16 +193,16 @@ class Devicectl {

final launchProgress = logger.progress('Launching app');
try {
await launchApp(
deviceId: device.identifier,
bundleId: bundleId,
);
await launchApp(deviceId: device.udid, bundleId: bundleId);
} catch (error) {
launchProgress.fail('Failed to launch app: $error');
return ExitCode.software.code;
}
launchProgress.complete();

final loggerExitCode = await loggerExitCodeFuture;
logger.detail('idevicesyslog exited with code $loggerExitCode');

return ExitCode.success.code;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export 'bundletool.dart';
export 'devicectl/devicectl.dart';
export 'git.dart';
export 'gradlew.dart';
export 'idevicesyslog.dart';
export 'ios_deploy.dart';
export 'java.dart';
export 'xcodebuild.dart';
110 changes: 110 additions & 0 deletions packages/shorebird_cli/lib/src/executables/idevicesyslog.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import 'dart:convert';
import 'dart:io';

import 'package:meta/meta.dart';
import 'package:path/path.dart' as p;
import 'package:scoped/scoped.dart';
import 'package:shorebird_cli/src/executables/devicectl/apple_device.dart';
import 'package:shorebird_cli/src/logger.dart';
import 'package:shorebird_cli/src/process.dart';
import 'package:shorebird_cli/src/shorebird_env.dart';

/// A reference to a [IDeviceSysLog] instance.
final idevicesyslogRef = create(IDeviceSysLog.new);

/// The [IDeviceSysLog] instance available in the current zone.
IDeviceSysLog get idevicesyslog => read(idevicesyslogRef);

/// {@template idevicesyslog}
/// A wrapper around the `idevicesyslog` executable.
/// {@endtemplate}
class IDeviceSysLog {
/// The location of the libimobiledevice library, which contains
/// idevicesyslog.
static Directory get libimobiledeviceDirectory => Directory(
p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'artifacts',
'libimobiledevice',
),
);

/// The location of the idevicesyslog executable.
static File get idevicesyslogExecutable => File(
p.join(libimobiledeviceDirectory.path, 'idevicesyslog'),
);

/// The libraries that idevicesyslog depends on.
@visibleForTesting
static const deps = [
'libimobiledevice',
'usbmuxd',
'libplist',
'openssl',
'ios-deploy',
];

/// idevicesyslog has Flutter-provided dependencies, so we need to tell the
/// dynamic linker where to find them.
String get _dyldPathEntry => deps
.map(
(dep) => p.join(
shorebirdEnv.flutterDirectory.path,
'bin',
'cache',
'artifacts',
dep,
),
)
.join(':');

/// idevicesyslog tails all logs produced by the device (similar to what is
/// shown in Console.app). This is very noisy and we only want to show logs
/// that are produced by the app. These log lines are of the form:
/// Nov 10 14:46:57 Runner(Flutter)[1044] <Notice>: flutter: hello
static RegExp appLogLineRegex = RegExp(r'\(Flutter\)\[\d+\] <Notice>: (.*)$');

/// Starts an instance of idevicesyslog for the given device ID. Returns the
/// exit code of the process.
///
/// stdout and stderr are parsed for lines matching [appLogLineRegex], and
/// those lines are logged at an info level.
Future<int> startLogger({required AppleDevice device}) async {
logger.detail(
'launching idevicesyslog with DYLD_LIBRARY_PATH=$_dyldPathEntry',
);

final loggerProcess = await process.start(
idevicesyslogExecutable.path,
[
'-u',
device.udid,
// If the device is not connected via USB, we need to specify the
// network flag.
if (!device.isWired) '--network',
],
environment: {
'DYLD_LIBRARY_PATH': _dyldPathEntry,
},
);

loggerProcess.stdout
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen(_parseLogLine);
loggerProcess.stderr
.transform<String>(utf8.decoder)
.transform<String>(const LineSplitter())
.listen(_parseLogLine);
return loggerProcess.exitCode;
}

void _parseLogLine(String line) {
final matches = appLogLineRegex.allMatches(line);
if (matches.isNotEmpty) {
logger.info(matches.first.group(1));
}
}
}
Loading

0 comments on commit 5e0b941

Please sign in to comment.