diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..15fe61f --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: [aarondfrancis] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: ['https://aaronfrancis.com/backstage'] diff --git a/config/solo.php b/config/solo.php index d943284..8005b71 100644 --- a/config/solo.php +++ b/config/solo.php @@ -3,8 +3,8 @@ use SoloTerm\Solo\Commands\Command; use SoloTerm\Solo\Commands\EnhancedTailCommand; use SoloTerm\Solo\Commands\MakeCommand; -use SoloTerm\Solo\Hotkeys as Hotkeys; -use SoloTerm\Solo\Themes as Themes; +use SoloTerm\Solo\Hotkeys; +use SoloTerm\Solo\Themes; // Solo may not (should not!) exist in prod, so we have to // check here first to see if it's installed. diff --git a/src/Commands/Command.php b/src/Commands/Command.php index 36ac8bf..e7f8149 100644 --- a/src/Commands/Command.php +++ b/src/Commands/Command.php @@ -159,12 +159,14 @@ public function isInteractive(): bool */ public function dd() { - $this->wrappedLines()->map(fn($line) => print_r(json_encode($line))); + $this->wrappedLines()->dd(); exit(); } public function addOutput($text) { + $text = str_replace('[screen is terminating]', '', $text); + $this->screen->write($text); } diff --git a/src/Commands/Concerns/ManagesProcess.php b/src/Commands/Concerns/ManagesProcess.php index b47eb55..b81258d 100644 --- a/src/Commands/Concerns/ManagesProcess.php +++ b/src/Commands/Concerns/ManagesProcess.php @@ -13,6 +13,7 @@ use Illuminate\Process\InvokedProcess; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Process; +use Illuminate\Support\Str; use ReflectionClass; use SoloTerm\Solo\Support\PendingProcess; use SoloTerm\Solo\Support\ProcessTracker; @@ -43,9 +44,19 @@ public function createPendingProcess(): PendingProcess $command = explode(' ', $this->command); + // ?? + // alias screen='TERM=xterm-256color screen' + // https://superuser.com/questions/800126/gnu-screen-changes-vim-syntax-highlighting-colors + // https://github.com/derailed/k9s/issues/2810 + // We have to make our own so that we can control pty. $process = app(PendingProcess::class) - ->command($command) + // ->command($command) + ->command([ + 'bash', + '-c', + "stty cols {$this->scrollPaneWidth()} rows {$this->scrollPaneHeight()} && screen -q " . $this->command, + ]) ->forever() ->timeout(0) ->idleTimeout(0) @@ -97,40 +108,10 @@ public function autostart(): static public function start(): void { $this->process = $this->createPendingProcess()->start(null, function ($type, $buffer) { - // After many, many hours of frustration I've figured out that for some reason the max - // number of bytes that come through at any time is 1024. I think it has to do with - // stdio buffering (https://www.pixelbeat.org/programming/stdio_buffering). - - // According to that article, it could be 1024 or 4096, depending on whether a terminal - // is connected or not. We'll check both along with 2048. If there are more than 1024 - // bytes in stdout, they might end up in stderr! No idea why. Not sure if that's - // a Symfony thing or just normal system stuff. Regardless, for that reason we - // don't differentiate between stdout and stderr here and listen for both. - - // So when we do get a chunk that seems like it might have a continuation, we need - // to buffer it, because there's more output coming right behind it. If we don't - // buffer, we could splice a multibyte character or an ANSI code. Much effort - // went into fixing byte splices, but ANSI splices are way tougher. Checking - // if it's a perfect multiple of 1024 seems to be foolproof. Hopefully. - if (strlen($buffer) % 1024 === 0) { - $this->partialBuffer .= $buffer; - - // @TODO add a timer to just force the partial buffer through, in case - // it's a legit block of 1024 bytes with nothing coming after. - return; - } - - $this->addOutput($this->partialBuffer . $buffer); - $this->partialBuffer = ''; - - // 5% chance of clearing the buffer. Hopefully this helps save memory. - // @link https://github.com/aarondfrancis/solo/issues/33 - if (rand(1, 100) < 5) { - $type === SymfonyProcess::OUT ? $this->clearStdOut() : $this->clearStdErr(); - } + $this->partialBuffer .= $buffer; }); - $this->sendSizeViaStty(); + // $this->sendSizeViaStty(); } public function whenStopping() @@ -208,6 +189,10 @@ public function sendSizeViaStty(): void $device = $matches[1]; + if ($device === '/dev/ttys000') { + continue; + } + exec(sprintf( 'stty rows %d cols %d < %s', $this->scrollPaneHeight(), @@ -221,18 +206,15 @@ public function sendSizeViaStty(): void protected function clearStdOut() { - $this->callPrivateMethodOnSymfonyProcess('clearOutput'); + $this->withSymfonyProcess(function (SymfonyProcess $process) { + $process->clearOutput(); + }); } protected function clearStdErr() { - $this->callPrivateMethodOnSymfonyProcess('clearErrorOutput'); - } - - protected function callPrivateMethodOnSymfonyProcess($method, array $args = []): mixed - { - return $this->withSymfonyProcess(function (SymfonyProcess $process) use ($method, $args) { - return (new ReflectionClass(SymfonyProcess::class))->getMethod($method)->invoke($process, ...$args); + $this->withSymfonyProcess(function (SymfonyProcess $process) { + $process->clearErrorOutput(); }); } @@ -298,12 +280,74 @@ protected function callAfterTerminateCallbacks() $this->afterTerminateCallbacks = []; } - protected function collectIncrementalOutput() + protected function collectIncrementalOutput(): void { + $before = strlen($this->partialBuffer); + // A bit of a hack, but there's no other way in. Process is a Laravel InvokedProcess. // Calling `running` on it defers to the Symfony process `isRunning` method. That // method calls a protected method `updateStatus` which calls a private method // `readPipes` which invokes the output callback, adding it to our buffer. $this->process?->running(); + + $after = strlen($this->partialBuffer); + + if (!$before && !$after) { + return; + } + + // No more data came out, so let's flush the whole thing. + if ($before === $after) { + $write = $this->partialBuffer; + + // @link https://github.com/aarondfrancis/solo/issues/33 + $this->clearStdOut(); + $this->clearStdErr(); + } elseif ($after > 10240) { + if (Str::contains($this->partialBuffer, "\n")) { + // We're over the limit, so look for a safe spot to cut, starting with newlines. + $write = Str::beforeLast($this->partialBuffer, "\n"); + } elseif (Str::contains($this->partialBuffer, "\e")) { + // If there aren't any, let's cut right before an ANSI code so we don't splice it. + $write = Str::beforeLast($this->partialBuffer, "\e"); + } else { + // Otherwise, we'll just slice anywhere that's safe. + $write = $this->sliceAtUTF8Boundary($this->partialBuffer); + } + } else { + return; + } + + $this->partialBuffer = substr($this->partialBuffer, strlen($write)); + $this->addOutput($write); + } + + public function sliceAtUTF8Boundary(string $input): string + { + $len = strlen($input); + + // Walk backward from the end, to find a safe UTF-8 start + $i = $len - 1; + while ($i >= 0) { + $byteVal = ord($input[$i]); + + // If this is a leading byte or ASCII, we're good + // Leading bytes match: + // 0xxxxxxx (ASCII) + // 110xxxxx (2-byte start) + // 1110xxxx (3-byte start) + // 11110xxx (4-byte start) + // etc. + if (($byteVal & 0b11000000) != 0b10000000) { + // This is not a continuation byte (i.e. 10xxxxxx), + // so it's a valid UTF-8 start boundary + break; + } + + $i--; + } + + // Now $i is either -1 (we fell off the start) or at the start of a codepoint + return substr($input, 0, $i + 1); } } diff --git a/tests/Fixtures/ngrok_1.txt b/tests/Fixtures/ngrok_1.txt new file mode 100644 index 0000000..e7493e3 --- /dev/null +++ b/tests/Fixtures/ngrok_1.txt @@ -0,0 +1,54 @@ +[?1049h[!p[?3;4l>[?1h=(B[?25lngrok (Ctrl+C to quit) +  +Session Status connecting  +Version 3.12.1  +Web Interface http://127.0.0.1:4040 +  +Connections ttl opn rt1 rt5 p50 p90   + 0 0 0.00 0.00 0.00 0.00  + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ��️ Protect endpoints w/ IP Intelligence: https://ngrok.com/r/ipintel +  +Session Status online  +Account Aaron Francis (Plan: Personal) +Update update available (version 3.19.1, Ctrl-U to update) +Version3.12.1  +Region United States (us) +Web Interface http://127.0.0.1:4040 + +Connections ttl opn rt1 rt5 p50 p90 + 0 0 0.00 0.00 0.00 0.00 Forwarding https://369943108eb2.ngrok.app -> http://localhost:8080 +  +Connectionsttlopnrt1 rt5 p50 p90 + 0 0 0.00 0.00 0.00 0.00 \ No newline at end of file diff --git a/tests/Unit/NgrokTest.php b/tests/Unit/NgrokTest.php new file mode 100644 index 0000000..d55e160 --- /dev/null +++ b/tests/Unit/NgrokTest.php @@ -0,0 +1,26 @@ + + * + * @link https://aaronfrancis.com + * @link https://x.com/aarondfrancis + */ + +namespace SoloTerm\Solo\Tests\Unit; + +use PHPUnit\Framework\Attributes\Test; +use SoloTerm\Solo\Tests\Support\ComparesVisually; + +use function Orchestra\Testbench\package_path; + +class NgrokTest extends Base +{ + use ComparesVisually; + + #[Test] + public function basic_ngrok() + { + $this->assertTerminalMatch(file_get_contents(package_path('tests/Fixtures/ngrok_1.txt'))); + } +}