ios_deploy.dart 23 KB
Newer Older
1 2 3 4
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

5 6
import 'dart:async';

7
import 'package:meta/meta.dart';
8
import 'package:process/process.dart';
9 10

import '../artifacts.dart';
11
import '../base/file_system.dart';
12
import '../base/io.dart';
13
import '../base/logger.dart';
14
import '../base/platform.dart';
15 16
import '../base/process.dart';
import '../cache.dart';
17
import '../convert.dart';
18
import '../device.dart';
19 20 21 22 23 24
import 'code_signing.dart';

// Error message patterns from ios-deploy output
const String noProvisioningProfileErrorOne = 'Error 0xe8008015';
const String noProvisioningProfileErrorTwo = 'Error 0xe8000067';
const String deviceLockedError = 'e80000e2';
25
const String deviceLockedErrorMessage = 'the device was not, or could not be, unlocked';
26 27 28 29
const String unknownAppLaunchError = 'Error 0xe8000022';

class IOSDeploy {
  IOSDeploy({
30 31 32 33 34
    required Artifacts artifacts,
    required Cache cache,
    required Logger logger,
    required Platform platform,
    required ProcessManager processManager,
35 36 37 38
  }) : _platform = platform,
       _cache = cache,
       _processUtils = ProcessUtils(processManager: processManager, logger: logger),
       _logger = logger,
39
       _binaryPath = artifacts.getHostArtifact(HostArtifact.iosDeploy).path;
40 41 42 43 44 45 46 47 48 49 50 51 52 53

  final Cache _cache;
  final String _binaryPath;
  final Logger _logger;
  final Platform _platform;
  final ProcessUtils _processUtils;

  Map<String, String> get iosDeployEnv {
    // Push /usr/bin to the front of PATH to pick up default system python, package 'six'.
    //
    // ios-deploy transitively depends on LLDB.framework, which invokes a
    // Python script that uses package 'six'. LLDB.framework relies on the
    // python at the front of the path, which may not include package 'six'.
    // Ensure that we pick up the system install of python, which includes it.
54
    final Map<String, String> environment = Map<String, String>.of(_platform.environment);
55 56 57 58 59 60 61 62 63
    environment['PATH'] = '/usr/bin:${environment['PATH']}';
    environment.addEntries(<MapEntry<String, String>>[_cache.dyLdLibEntry]);
    return environment;
  }

  /// Uninstalls the specified app bundle.
  ///
  /// Uses ios-deploy and returns the exit code.
  Future<int> uninstallApp({
64 65
    required String deviceId,
    required String bundleId,
66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87
  }) async {
    final List<String> launchCommand = <String>[
      _binaryPath,
      '--id',
      deviceId,
      '--uninstall_only',
      '--bundle_id',
      bundleId,
    ];

    return _processUtils.stream(
      launchCommand,
      mapFunction: _monitorFailure,
      trace: true,
      environment: iosDeployEnv,
    );
  }

  /// Installs the specified app bundle.
  ///
  /// Uses ios-deploy and returns the exit code.
  Future<int> installApp({
88 89 90
    required String deviceId,
    required String bundlePath,
    required List<String>launchArguments,
91
    required DeviceConnectionInterface interfaceType,
92
    Directory? appDeltaDirectory,
93
  }) async {
94
    appDeltaDirectory?.createSync(recursive: true);
95 96 97 98 99 100
    final List<String> launchCommand = <String>[
      _binaryPath,
      '--id',
      deviceId,
      '--bundle',
      bundlePath,
101 102 103 104
      if (appDeltaDirectory != null) ...<String>[
        '--app_deltas',
        appDeltaDirectory.path,
      ],
105
      if (interfaceType != DeviceConnectionInterface.wireless)
106
        '--no-wifi',
107 108 109 110 111 112 113 114 115 116 117 118 119 120
      if (launchArguments.isNotEmpty) ...<String>[
        '--args',
        launchArguments.join(' '),
      ],
    ];

    return _processUtils.stream(
      launchCommand,
      mapFunction: _monitorFailure,
      trace: true,
      environment: iosDeployEnv,
    );
  }

121 122 123
  /// Returns [IOSDeployDebugger] wrapping attached debugger logic.
  ///
  /// This method does not install the app. Call [IOSDeployDebugger.launchAndAttach()]
124
  /// to install and attach the debugger to the specified app bundle.
125
  IOSDeployDebugger prepareDebuggerForLaunch({
126 127 128
    required String deviceId,
    required String bundlePath,
    required List<String> launchArguments,
129
    required DeviceConnectionInterface interfaceType,
130
    Directory? appDeltaDirectory,
131
    required bool uninstallFirst,
132
  }) {
133
    appDeltaDirectory?.createSync(recursive: true);
134
    // Interactive debug session to support sending the lldb detach command.
135 136 137 138 139 140 141 142 143 144
    final List<String> launchCommand = <String>[
      'script',
      '-t',
      '0',
      '/dev/null',
      _binaryPath,
      '--id',
      deviceId,
      '--bundle',
      bundlePath,
145 146 147 148
      if (appDeltaDirectory != null) ...<String>[
        '--app_deltas',
        appDeltaDirectory.path,
      ],
149 150
      if (uninstallFirst)
        '--uninstall',
151
      '--debug',
152
      if (interfaceType != DeviceConnectionInterface.wireless)
153 154 155 156 157 158 159 160 161 162 163 164 165 166
        '--no-wifi',
      if (launchArguments.isNotEmpty) ...<String>[
        '--args',
        launchArguments.join(' '),
      ],
    ];
    return IOSDeployDebugger(
      launchCommand: launchCommand,
      logger: _logger,
      processUtils: _processUtils,
      iosDeployEnv: iosDeployEnv,
    );
  }

167 168 169
  /// Installs and then runs the specified app bundle.
  ///
  /// Uses ios-deploy and returns the exit code.
170
  Future<int> launchApp({
171 172 173
    required String deviceId,
    required String bundlePath,
    required List<String> launchArguments,
174
    required DeviceConnectionInterface interfaceType,
175
    required bool uninstallFirst,
176
    Directory? appDeltaDirectory,
177
  }) async {
178
    appDeltaDirectory?.createSync(recursive: true);
179 180 181 182 183 184
    final List<String> launchCommand = <String>[
      _binaryPath,
      '--id',
      deviceId,
      '--bundle',
      bundlePath,
185 186 187 188
      if (appDeltaDirectory != null) ...<String>[
        '--app_deltas',
        appDeltaDirectory.path,
      ],
189
      if (interfaceType != DeviceConnectionInterface.wireless)
190
        '--no-wifi',
191 192
      if (uninstallFirst)
        '--uninstall',
193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208
      '--justlaunch',
      if (launchArguments.isNotEmpty) ...<String>[
        '--args',
        launchArguments.join(' '),
      ],
    ];

    return _processUtils.stream(
      launchCommand,
      mapFunction: _monitorFailure,
      trace: true,
      environment: iosDeployEnv,
    );
  }

  Future<bool> isAppInstalled({
209 210
    required String bundleId,
    required String deviceId,
211 212 213 214 215 216
  }) async {
    final List<String> launchCommand = <String>[
      _binaryPath,
      '--id',
      deviceId,
      '--exists',
217 218
      '--timeout', // If the device is not connected, ios-deploy will wait forever.
      '10',
219 220 221 222 223 224 225
      '--bundle_id',
      bundleId,
    ];
    final RunResult result = await _processUtils.run(
      launchCommand,
      environment: iosDeployEnv,
    );
226 227 228 229 230
    // Device successfully connected, but app not installed.
    if (result.exitCode == 255) {
      _logger.printTrace('$bundleId not installed on $deviceId');
      return false;
    }
231
    if (result.exitCode != 0) {
232
      _logger.printTrace('App install check failed: ${result.stderr}');
233 234
      return false;
    }
235
    return true;
236 237
  }

238 239 240 241 242 243 244 245 246 247
  String _monitorFailure(String stdout) => _monitorIOSDeployFailure(stdout, _logger);
}

/// lldb attach state flow.
enum _IOSDeployDebuggerState {
  detached,
  launching,
  attached,
}

248
/// Wrapper to launch app and attach the debugger with ios-deploy.
249 250
class IOSDeployDebugger {
  IOSDeployDebugger({
251 252 253 254
    required Logger logger,
    required ProcessUtils processUtils,
    required List<String> launchCommand,
    required Map<String, String> iosDeployEnv,
255 256 257 258 259 260 261 262 263 264 265
  }) : _processUtils = processUtils,
        _logger = logger,
        _launchCommand = launchCommand,
        _iosDeployEnv = iosDeployEnv,
        _debuggerState = _IOSDeployDebuggerState.detached;

  /// Create a [IOSDeployDebugger] for testing.
  ///
  /// Sets the command to "ios-deploy" and environment to an empty map.
  @visibleForTesting
  factory IOSDeployDebugger.test({
266 267
    required ProcessManager processManager,
    Logger? logger,
268 269 270 271 272 273 274 275 276 277 278 279 280 281 282
  }) {
    final Logger debugLogger = logger ?? BufferLogger.test();
    return IOSDeployDebugger(
      logger: debugLogger,
      processUtils: ProcessUtils(logger: debugLogger, processManager: processManager),
      launchCommand: <String>['ios-deploy'],
      iosDeployEnv: <String, String>{},
    );
  }

  final Logger _logger;
  final ProcessUtils _processUtils;
  final List<String> _launchCommand;
  final Map<String, String> _iosDeployEnv;

283
  Process? _iosDeployProcess;
284 285 286 287

  Stream<String> get logLines => _debuggerOutput.stream;
  final StreamController<String> _debuggerOutput = StreamController<String>.broadcast();

288
  bool get debuggerAttached => _debuggerState == _IOSDeployDebuggerState.attached;
289
  _IOSDeployDebuggerState _debuggerState;
290

291 292 293
  @visibleForTesting
  String? symbolsDirectoryPath;

294 295 296 297
  // (lldb)    platform select remote-'ios' --sysroot
  // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L33
  // This regex is to get the configurable lldb prompt. By default this prompt will be "lldb".
  static final RegExp _lldbPlatformSelect = RegExp(r"\s*platform select remote-'ios' --sysroot");
298

299 300
  // (lldb)     run
  // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L51
301 302
  static final RegExp _lldbProcessExit = RegExp(r'Process \d* exited with status =');

303 304 305
  // (lldb) Process 6152 stopped
  static final RegExp _lldbProcessStopped = RegExp(r'Process \d* stopped');

306 307 308
  // (lldb) Process 6152 detached
  static final RegExp _lldbProcessDetached = RegExp(r'Process \d* detached');

309 310 311
  // (lldb) Process 6152 resuming
  static final RegExp _lldbProcessResuming = RegExp(r'Process \d+ resuming');

312 313 314
  // Symbol Path: /Users/swarming/Library/Developer/Xcode/iOS DeviceSupport/16.2 (20C65) arm64e/Symbols
  static final RegExp _symbolsPathPattern = RegExp(r'.*Symbol Path: ');

315 316
  // Send signal to stop (pause) the app. Used before a backtrace dump.
  static const String _signalStop = 'process signal SIGSTOP';
317
  static const String _signalStopError = 'Failed to send signal 17';
318

319 320 321
  static const String _processResume = 'process continue';
  static const String _processInterrupt = 'process interrupt';

322 323 324
  // Print backtrace for all threads while app is stopped.
  static const String _backTraceAll = 'thread backtrace all';

325 326 327 328 329
  /// If this is non-null, then the app process is paused and awaiting backtrace logging.
  ///
  /// The future should be completed once the backtraces are logged.
  Completer<void>? _processResumeCompleter;

330 331 332 333 334
  /// Launch the app on the device, and attach the debugger.
  ///
  /// Returns whether or not the debugger successfully attached.
  Future<bool> launchAndAttach() async {
    // Return when the debugger attaches, or the ios-deploy process exits.
335 336 337 338 339

    // (lldb)     run
    // https://github.com/ios-control/ios-deploy/blob/1.11.2-beta.1/src/ios-deploy/ios-deploy.m#L51
    RegExp lldbRun = RegExp(r'\(lldb\)\s*run');

340 341 342 343 344 345
    final Completer<bool> debuggerCompleter = Completer<bool>();
    try {
      _iosDeployProcess = await _processUtils.start(
        _launchCommand,
        environment: _iosDeployEnv,
      );
346 347
      String? lastLineFromDebugger;
      final StreamSubscription<String> stdoutSubscription = _iosDeployProcess!.stdout
348 349 350 351
          .transform<String>(utf8.decoder)
          .transform<String>(const LineSplitter())
          .listen((String line) {
        _monitorIOSDeployFailure(line, _logger);
352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367

        // (lldb)    platform select remote-'ios' --sysroot
        // Use the configurable custom lldb prompt in the regex. The developer can set this prompt to anything.
        // For example `settings set prompt "(mylldb)"` in ~/.lldbinit results in:
        // "(mylldb)    platform select remote-'ios' --sysroot"
        if (_lldbPlatformSelect.hasMatch(line)) {
          final String platformSelect = _lldbPlatformSelect.stringMatch(line) ?? '';
          if (platformSelect.isEmpty) {
            return;
          }
          final int promptEndIndex = line.indexOf(platformSelect);
          if (promptEndIndex == -1) {
            return;
          }
          final String prompt = line.substring(0, promptEndIndex);
          lldbRun = RegExp(RegExp.escape(prompt) + r'\s*run');
368
          _logger.printTrace(line);
369 370 371
          return;
        }

372 373 374 375 376 377 378 379 380 381 382
        // Symbol Path: /Users/swarming/Library/Developer/Xcode/iOS DeviceSupport/16.2 (20C65) arm64e/Symbols
        if (_symbolsPathPattern.hasMatch(line)) {
          _logger.printTrace('Detected path to iOS debug symbols: "$line"');
          final String prefix = _symbolsPathPattern.stringMatch(line) ?? '';
          if (prefix.isEmpty) {
            return;
          }
          symbolsDirectoryPath = line.substring(prefix.length);
          return;
        }

383 384
        // (lldb)     run
        // success
385
        // 2020-09-15 13:42:25.185474-0700 Runner[477:181141] flutter: The Dart VM service is listening on http://127.0.0.1:57782/
386
        if (lldbRun.hasMatch(line)) {
387
          _logger.printTrace(line);
388
          _debuggerState = _IOSDeployDebuggerState.launching;
389 390
          // TODO(vashworth): Remove all debugger state comments when https://github.com/flutter/flutter/issues/126412 is resolved.
          _logger.printTrace('Debugger state set to launching.');
391 392 393 394 395
          return;
        }
        // Next line after "run" must be "success", or the attach failed.
        // Example: "error: process launch failed"
        if (_debuggerState == _IOSDeployDebuggerState.launching) {
396
          _logger.printTrace(line);
397 398
          final bool attachSuccess = line == 'success';
          _debuggerState = attachSuccess ? _IOSDeployDebuggerState.attached : _IOSDeployDebuggerState.detached;
399
          _logger.printTrace('Debugger state set to ${attachSuccess ? 'attached' : 'detached'}.');
400 401 402 403 404
          if (!debuggerCompleter.isCompleted) {
            debuggerCompleter.complete(attachSuccess);
          }
          return;
        }
405 406 407 408 409

        // (lldb) process signal SIGSTOP
        // or
        // process signal SIGSTOP
        if (line.contains(_signalStop)) {
410
          // The app is about to be stopped. Only show in verbose mode.
411
          _logger.printTrace(line);
412 413
          return;
        }
414 415 416 417 418 419 420 421

        // error: Failed to send signal 17: failed to send signal 17
        if (line.contains(_signalStopError)) {
          // The stop signal failed, force exit.
          exit();
          return;
        }

422 423
        if (line == _backTraceAll) {
          // The app is stopped and the backtrace for all threads will be printed.
424
          _logger.printTrace(line);
425 426 427
          // Even though we're not "detached", just stopped, mark as detached so the backtrace
          // is only show in verbose.
          _debuggerState = _IOSDeployDebuggerState.detached;
428
          _logger.printTrace('Debugger state set to detached.');
429 430 431 432 433 434 435

          // If we paused the app and are waiting to resume it, complete the completer
          final Completer<void>? processResumeCompleter = _processResumeCompleter;
          if (processResumeCompleter != null) {
            _processResumeCompleter = null;
            processResumeCompleter.complete();
          }
436 437 438 439 440
          return;
        }

        if (line.contains('PROCESS_STOPPED') || _lldbProcessStopped.hasMatch(line)) {
          // The app has been stopped. Dump the backtrace, and detach.
441
          _logger.printTrace(line);
442
          _iosDeployProcess?.stdin.writeln(_backTraceAll);
443 444 445
          if (_processResumeCompleter == null) {
            detach();
          }
446 447
          return;
        }
448

449
        if (line.contains('PROCESS_EXITED') || _lldbProcessExit.hasMatch(line)) {
450 451
          // The app exited or crashed, so exit. Continue passing debugging
          // messages to the log reader until it exits to capture crash dumps.
452
          _logger.printTrace(line);
453
          exit();
454 455
          return;
        }
456 457 458
        if (_lldbProcessDetached.hasMatch(line)) {
          // The debugger has detached from the app, and there will be no more debugging messages.
          // Kill the ios-deploy process.
459
          _logger.printTrace(line);
460 461 462 463
          exit();
          return;
        }

464
        if (_lldbProcessResuming.hasMatch(line)) {
465
          _logger.printTrace(line);
466 467
          // we marked this detached when we received [_backTraceAll]
          _debuggerState = _IOSDeployDebuggerState.attached;
468
          _logger.printTrace('Debugger state set to attached.');
469 470 471
          return;
        }

472
        if (_debuggerState != _IOSDeployDebuggerState.attached) {
473
          _logger.printTrace(line);
474 475
          return;
        }
476
        if (lastLineFromDebugger != null && lastLineFromDebugger!.isNotEmpty && line.isEmpty) {
477 478 479 480
          // The lldb console stream from ios-deploy is separated lines by an extra \r\n.
          // To avoid all lines being double spaced, if the last line from the
          // debugger was not an empty line, skip this empty line.
          // This will still cause "legit" logged newlines to be doubled...
481
        } else if (!_debuggerOutput.isClosed) {
482 483 484 485
          _debuggerOutput.add(line);
        }
        lastLineFromDebugger = line;
      });
486
      final StreamSubscription<String> stderrSubscription = _iosDeployProcess!.stderr
487 488 489 490
          .transform<String>(utf8.decoder)
          .transform<String>(const LineSplitter())
          .listen((String line) {
        _monitorIOSDeployFailure(line, _logger);
491
        _logger.printTrace(line);
492
      });
493
      unawaited(_iosDeployProcess!.exitCode.then((int status) async {
494 495
        _logger.printTrace('ios-deploy exited with code $exitCode');
        _debuggerState = _IOSDeployDebuggerState.detached;
496 497
        await stdoutSubscription.cancel();
        await stderrSubscription.cancel();
498 499 500 501 502 503 504 505 506 507 508 509 510
      }).whenComplete(() async {
        if (_debuggerOutput.hasListener) {
          // Tell listeners the process died.
          await _debuggerOutput.close();
        }
        if (!debuggerCompleter.isCompleted) {
          debuggerCompleter.complete(false);
        }
        _iosDeployProcess = null;
      }));
    } on ProcessException catch (exception, stackTrace) {
      _logger.printTrace('ios-deploy failed: $exception');
      _debuggerState = _IOSDeployDebuggerState.detached;
511 512 513
      if (!_debuggerOutput.isClosed) {
        _debuggerOutput.addError(exception, stackTrace);
      }
514 515 516
    } on ArgumentError catch (exception, stackTrace) {
      _logger.printTrace('ios-deploy failed: $exception');
      _debuggerState = _IOSDeployDebuggerState.detached;
517 518 519
      if (!_debuggerOutput.isClosed) {
        _debuggerOutput.addError(exception, stackTrace);
      }
520 521 522 523 524 525
    }
    // Wait until the debugger attaches, or the attempt fails.
    return debuggerCompleter.future;
  }

  bool exit() {
526
    final bool success = (_iosDeployProcess == null) || _iosDeployProcess!.kill();
527 528 529
    _iosDeployProcess = null;
    return success;
  }
530

531 532
  /// Pause app, dump backtrace for debugging, and resume.
  Future<void> pauseDumpBacktraceResume() async {
533 534 535
    if (!debuggerAttached) {
      return;
    }
536 537 538 539 540 541 542 543 544 545 546 547
    final Completer<void> completer = Completer<void>();
    _processResumeCompleter = completer;
    try {
      // Stop the app, which will prompt the backtrace to be printed for all threads in the stdoutSubscription handler.
      _iosDeployProcess?.stdin.writeln(_processInterrupt);
    } on SocketException catch (error) {
      _logger.printTrace('Could not stop app from debugger: $error');
    }
    // wait for backtrace to be dumped
    await completer.future;
    _iosDeployProcess?.stdin.writeln(_processResume);
  }
548

549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575
  /// Check what files are found in the device's iOS DeviceSupport directory.
  ///
  /// Expected files include Symbols (directory), Info.plist, and .finalized.
  ///
  /// If any of the expected files are missing or there are additional files
  /// (such as .copying_lock or .processing_lock), this may indicate the
  /// symbols may still be fetching or something went wrong when fetching them.
  ///
  /// Used for debugging test flakes: https://github.com/flutter/flutter/issues/121231
  Future<void> checkForSymbolsFiles(FileSystem fileSystem) async {
    if (symbolsDirectoryPath == null) {
      _logger.printTrace('No path provided for Symbols directory.');
      return;
    }
    final Directory symbolsDirectory = fileSystem.directory(symbolsDirectoryPath);
    if (!symbolsDirectory.existsSync()) {
      _logger.printTrace('Unable to find Symbols directory at $symbolsDirectoryPath');
      return;
    }
    final Directory currentDeviceSupportDir = symbolsDirectory.parent;
    final List<FileSystemEntity> symbolStatusFiles = currentDeviceSupportDir.listSync();
    _logger.printTrace('Symbol files:');
    for (final FileSystemEntity file in symbolStatusFiles) {
      _logger.printTrace('  ${file.basename}');
    }
  }

576 577 578 579
  Future<void> stopAndDumpBacktrace() async {
    if (!debuggerAttached) {
      return;
    }
580 581 582 583 584 585 586 587 588 589 590
    try {
      // Stop the app, which will prompt the backtrace to be printed for all threads in the stdoutSubscription handler.
      _iosDeployProcess?.stdin.writeln(_signalStop);
    } on SocketException catch (error) {
      // Best effort, try to detach, but maybe the app already exited or already detached.
      _logger.printTrace('Could not stop app from debugger: $error');
    }
    // Wait for logging to finish on process exit.
    return logLines.drain();
  }

591 592 593 594 595 596 597
  void detach() {
    if (!debuggerAttached) {
      return;
    }

    try {
      // Detach lldb from the app process.
598
      _iosDeployProcess?.stdin.writeln('process detach');
599 600 601 602 603
    } on SocketException catch (error) {
      // Best effort, try to detach, but maybe the app already exited or already detached.
      _logger.printTrace('Could not detach from debugger: $error');
    }
  }
604 605 606 607 608 609 610
}

// Maps stdout line stream. Must return original line.
String _monitorIOSDeployFailure(String stdout, Logger logger) {
  // Installation issues.
  if (stdout.contains(noProvisioningProfileErrorOne) || stdout.contains(noProvisioningProfileErrorTwo)) {
    logger.printError(noProvisioningProfileInstruction, emphasis: true);
611 612

    // Launch issues.
613
  } else if (stdout.contains(deviceLockedError) || stdout.contains(deviceLockedErrorMessage)) {
614
    logger.printError('''
615 616 617
═══════════════════════════════════════════════════════════════════════════════════
Your device is locked. Unlock your device first before running.
═══════════════════════════════════════════════════════════════════════════════════''',
618 619 620
        emphasis: true);
  } else if (stdout.contains(unknownAppLaunchError)) {
    logger.printError('''
621 622 623 624 625 626
═══════════════════════════════════════════════════════════════════════════════════
Error launching app. Try launching from within Xcode via:
    open ios/Runner.xcworkspace

Your Xcode version may be too old for your iOS version.
═══════════════════════════════════════════════════════════════════════════════════''',
627
        emphasis: true);
628
  }
629 630

  return stdout;
631
}