1: <?php
2:
3: declare(strict_types=1);
4:
5: namespace Atk4\Ui;
6:
7: use Atk4\Core\DebugTrait;
8: use Atk4\Core\TraitUtil;
9: use Atk4\Ui\Js\JsBlock;
10: use Atk4\Ui\Js\JsExpressionable;
11: use Psr\Log\LoggerInterface;
12: use Psr\Log\LogLevel;
13:
14: /**
15: * Console is a black square component resembling terminal window. It can be programmed
16: * to run a job and output results to the user.
17: */
18: class Console extends View implements LoggerInterface
19: {
20: public $ui = 'inverted black segment';
21:
22: /**
23: * Specify which event will trigger this console. Set to false
24: * to disable automatic triggering if you need to trigger it
25: * manually.
26: *
27: * @var bool
28: */
29: public $event = true;
30:
31: /**
32: * Will be set to $true while executing callback. Some methods
33: * will use this to automatically schedule their own callback
34: * and allowing you a cleaner syntax, such as.
35: *
36: * $console->setModel($user, 'generateReport');
37: *
38: * @var bool
39: */
40: protected $sseInProgress = false;
41:
42: /** @var JsSse|null Stores object JsSse which is used for communication. */
43: public $sse;
44:
45: /**
46: * Bypass is used internally to capture and wrap direct output, but prevent JsSse from
47: * triggering output recursively.
48: *
49: * @var bool
50: */
51: protected $_outputBypass = false;
52:
53: /** @var int|null */
54: public $lastExitCode;
55:
56: /**
57: * Set a callback method which will be executed with the output sent back to the terminal.
58: *
59: * Argument passed to your callback will be $this Console. You may perform calls
60: * to methods such as
61: *
62: * $console->output()
63: * $console->outputHtml()
64: *
65: * If you are using setModel, and if your model implements \Atk4\Core\DebugTrait,
66: * then you you will see debug information generated by $this->debug() or $this->log().
67: *
68: * This intercepts default application logging for the duration of the process.
69: *
70: * If you are using runCommand, then server command will be executed with it's output
71: * (stdout and stderr) redirected to the console.
72: *
73: * While inside a callback you may execute runCommand or setModel multiple times.
74: *
75: * @param \Closure($this): void $fx callback which will be executed while displaying output inside console
76: * @param bool|string $event "true" would mean to execute on page load, string would indicate
77: * JS event. See first argument for View::js()
78: */
79: #[\Override]
80: public function set($fx = null, $event = null)
81: {
82: if (!$fx instanceof \Closure) {
83: throw new \TypeError('$fx must be of type Closure');
84: }
85:
86: if ($event !== null) {
87: $this->event = $event;
88: }
89:
90: if (!$this->sse) {
91: $this->sse = JsSse::addTo($this);
92: }
93:
94: $this->sse->set(function () use ($fx) {
95: $this->sseInProgress = true;
96: $oldLogger = $this->getApp()->logger;
97: $this->getApp()->logger = $this;
98: try {
99: ob_start(function (string $content) {
100: if ($this->_outputBypass || $content === '' /* needed as self::output() adds NL */) {
101: return $content;
102: }
103:
104: $output = '';
105: $this->sse->echoFunction = static function (string $str) use (&$output) {
106: $output .= $str;
107: };
108: $this->output($content);
109: $this->sse->echoFunction = false;
110:
111: return $output;
112: }, 1);
113:
114: try {
115: $fx($this);
116: } catch (\Throwable $e) {
117: $this->outputHtmlWithoutPre('<div class="ui segment">{0}</div>', [$this->getApp()->renderExceptionHtml($e)]);
118: }
119: } finally {
120: $this->sseInProgress = false;
121: $this->getApp()->logger = $oldLogger;
122: }
123: });
124:
125: if ($this->event) {
126: $this->js($this->event, $this->jsExecute());
127: }
128:
129: return $this;
130: }
131:
132: public function jsExecute(): JsBlock
133: {
134: return $this->sse->jsExecute();
135: }
136:
137: private function escapeOutputHtml(string $message): string
138: {
139: $res = $this->getApp()->encodeHtml($message);
140:
141: // TODO assert with Behat test
142: // fix new lines for display and copy paste, testcase:
143: // $genFx = function (array $values, int $maxLength, array $prev = null) use (&$genFx) {
144: // $res = [];
145: // foreach ($prev ?? [''] as $p) {
146: // foreach ($values as $v) {
147: // $res[] = $p . $v;
148: // }
149: // }
150: //
151: // if (--$maxLength > 0) {
152: // $res = array_merge($res, $genFx($values, $maxLength, $res));
153: // }
154: //
155: // if ($prev === null) {
156: // array_unshift($res, '');
157: // }
158: //
159: // return $res;
160: // };
161: // $testCases = $genFx([' ', "\t", "\n", 'x'], 5);
162: //
163: // foreach ($testCases as $testCase) {
164: // $this->output('--------' . str_replace([' ', "\t", "\n", 'x'], [' sp', ' tab', ' nl', ' x'], $testCase));
165: // $this->output($testCase);
166: // }
167: // $this->output('--------');
168: $res = preg_replace('~\r\n?|\n~s', "\n", $res);
169: $res = preg_replace('~^$|(?<!^)(\n+)$~s', "$1\n", $res);
170:
171: return $res;
172: }
173:
174: /**
175: * Output a single line to the console.
176: *
177: * @return $this
178: */
179: public function output(string $message, array $context = [])
180: {
181: $this->outputHtml($this->escapeOutputHtml($message), $context);
182:
183: return $this;
184: }
185:
186: /**
187: * Output unescaped HTML to the console.
188: *
189: * @return $this
190: */
191: public function outputHtml(string $messageHtml, array $context = [])
192: {
193: $this->outputHtmlWithoutPre($this->getApp()->getTag('div', ['style' => 'font-family: monospace; white-space: pre;'], [$messageHtml]), $context);
194:
195: return $this;
196: }
197:
198: /**
199: * Output unescaped HTML to the console without wrapping in <pre>.
200: *
201: * @return $this
202: */
203: protected function outputHtmlWithoutPre(string $messageHtml, array $context = [])
204: {
205: $messageHtml = preg_replace_callback('~{([\w]+)}~', static function ($matches) use ($context) {
206: if (isset($context[$matches[1]])) {
207: return $context[$matches[1]];
208: }
209:
210: return $matches[0];
211: }, $messageHtml);
212:
213: $this->_outputBypass = true;
214: try {
215: $this->sse->send($this->js()->append($messageHtml));
216: } finally {
217: $this->_outputBypass = false;
218: }
219:
220: return $this;
221: }
222:
223: #[\Override]
224: protected function renderView(): void
225: {
226: $this->setStyle('overflow-x', 'auto');
227:
228: parent::renderView();
229: }
230:
231: /**
232: * Executes a JavaScript action.
233: *
234: * @return $this
235: */
236: public function send(JsExpressionable $js)
237: {
238: $this->_outputBypass = true;
239: try {
240: $this->sse->send($js);
241: } finally {
242: $this->_outputBypass = false;
243: }
244:
245: return $this;
246: }
247:
248: /**
249: * Executes command passing along escaped arguments.
250: *
251: * Will also stream stdout / stderr as the command executes.
252: * once command terminates method will return the exit code.
253: *
254: * This method can be executed from inside callback or
255: * without it.
256: *
257: * Example: $console->exec('ping', ['-c', '5', '8.8.8.8']);
258: *
259: * All arguments are escaped.
260: */
261: public function exec(string $command, array $args = []): ?bool
262: {
263: if (!$this->sseInProgress) {
264: $this->set(function () use ($command, $args) {
265: $this->output(
266: '--[ Executing ' . $command
267: . ($args ? ' with ' . count($args) . ' arguments' : '')
268: . ' ]--------------'
269: );
270:
271: $this->exec($command, $args);
272:
273: $this->output('--[ Exit code: ' . $this->lastExitCode . ' ]------------');
274: });
275:
276: return null;
277: }
278:
279: [$proc, $pipes] = $this->execRaw($command, $args);
280:
281: stream_set_blocking($pipes[1], false);
282: stream_set_blocking($pipes[2], false);
283: // $pipes contain streams that are still open and not EOF
284: while ($pipes) {
285: $read = $pipes;
286: $j1 = null;
287: $j2 = null;
288: if (stream_select($read, $j1, $j2, 2) === false) {
289: throw new Exception('Unexpected stream_select() result');
290: }
291:
292: $status = proc_get_status($proc);
293: if (!$status['running']) {
294: proc_close($proc);
295:
296: break;
297: }
298:
299: foreach ($read as $f) {
300: $data = rtrim((string) fgets($f));
301: if ($data === '') {
302: // TODO fix coverage stability, add test with explicit empty string
303: // @codeCoverageIgnoreStart
304: continue;
305: // @codeCoverageIgnoreEnd
306: }
307:
308: if ($f === $pipes[2]) { // stderr
309: $this->warning($data);
310: } else { // stdout
311: $this->output($data);
312: }
313: }
314: }
315:
316: $this->lastExitCode = $status['exitcode'];
317:
318: return $this->lastExitCode ? false : true;
319: }
320:
321: /**
322: * @return array{resource, non-empty-array}
323: */
324: protected function execRaw(string $command, array $args = [])
325: {
326: $args = array_map(static fn ($v) => escapeshellarg($v), $args);
327:
328: $spec = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']]; // we want stdout and stderr
329: $pipes = null;
330: $proc = proc_open($command . ' ' . implode(' ', $args), $spec, $pipes);
331: if (!is_resource($proc)) {
332: throw (new Exception('Command failed to execute'))
333: ->addMoreInfo('command', $command)
334: ->addMoreInfo('args', $args);
335: }
336:
337: return [$proc, $pipes];
338: }
339:
340: /**
341: * Execute method of a certain object. If object uses Atk4\Core\DebugTrait,
342: * then debugging will also be used.
343: *
344: * During the invocation, Console will substitute $app->logger with itself,
345: * capturing all debug/info/log messages generated by your code and displaying
346: * it inside console.
347: *
348: * // runs $userController->generateReport('pdf')
349: * Console::addTo($app)->runMethod($userController, 'generateReports', ['pdf']);
350: *
351: * // runs PainFactory::lastStaticMethod()
352: * Console::addTo($app)->runMethod('PainFactory', 'lastStaticMethod');
353: *
354: * To produce output:
355: * - use $this->debug() or $this->info() (see documentation on DebugTrait)
356: *
357: * NOTE: debug() method will only output if you set debug=true. That is done
358: * for the $userController automatically, but for any nested objects you would have
359: * to pass on the property.
360: *
361: * @param object|class-string $object
362: *
363: * @return $this
364: */
365: public function runMethod($object, string $method, array $args = [])
366: {
367: if (!$this->sseInProgress) {
368: $this->set(function () use ($object, $method, $args) {
369: $this->runMethod($object, $method, $args);
370: });
371:
372: return $this;
373: }
374:
375: if (is_object($object)) {
376: // temporarily override app logging
377: if (TraitUtil::hasAppScopeTrait($object) && $object->issetApp()) {
378: $loggerBak = $object->getApp()->logger;
379: $object->getApp()->logger = $this;
380: }
381: if (TraitUtil::hasTrait($object, DebugTrait::class)) {
382: $debugBak = $object->debug;
383: $object->debug = true;
384: }
385:
386: $this->output('--[ Executing ' . get_class($object) . '->' . $method . ' ]--------------');
387:
388: try {
389: $result = $object->{$method}(...$args);
390: } finally {
391: if (TraitUtil::hasAppScopeTrait($object) && $object->issetApp()) {
392: $object->getApp()->logger = $loggerBak; // @phpstan-ignore-line
393: }
394: if (TraitUtil::hasTrait($object, DebugTrait::class)) {
395: $object->debug = $debugBak;
396: }
397: }
398: } else {
399: $this->output('--[ Executing ' . $object . '::' . $method . ' ]--------------');
400:
401: $result = $object::{$method}(...$args);
402: }
403: $this->output('--[ Result: ' . $this->getApp()->encodeJson($result) . ' ]------------');
404:
405: return $this;
406: }
407:
408: #[\Override]
409: public function emergency($message, array $context = []): void
410: {
411: $this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
412: }
413:
414: #[\Override]
415: public function alert($message, array $context = []): void
416: {
417: $this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
418: }
419:
420: #[\Override]
421: public function critical($message, array $context = []): void
422: {
423: $this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
424: }
425:
426: #[\Override]
427: public function error($message, array $context = []): void
428: {
429: $this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
430: }
431:
432: #[\Override]
433: public function warning($message, array $context = []): void
434: {
435: $this->outputHtml('<font color="pink">' . $this->escapeOutputHtml($message) . '</font>', $context);
436: }
437:
438: #[\Override]
439: public function notice($message, array $context = []): void
440: {
441: $this->outputHtml('<font color="yellow">' . $this->escapeOutputHtml($message) . '</font>', $context);
442: }
443:
444: #[\Override]
445: public function info($message, array $context = []): void
446: {
447: $this->outputHtml('<font color="gray">' . $this->escapeOutputHtml($message) . '</font>', $context);
448: }
449:
450: #[\Override]
451: public function debug($message, array $context = []): void
452: {
453: $this->outputHtml('<font color="cyan">' . $this->escapeOutputHtml($message) . '</font>', $context);
454: }
455:
456: /**
457: * @param LogLevel::* $level
458: */
459: #[\Override]
460: public function log($level, $message, array $context = []): void
461: {
462: $this->{$level}($message, $context);
463: }
464: }
465: