<?phpnamespace App\Gui;use App\Vnc\Keysym;use App\Vnc\RfbClient;use App\Vnc\Scancodes;use JumPHP\Gui\Pointer;use JumPHP\Gui\Theme;use JumPHP\Gui\Widget;use JumPHP\Renderer\Renderer;/** * The screen of a VM's console: the framebuffer of an RfbClient drawn to fit the widget (its proportions kept; `scale: false` * = pixel for pixel, centred), the keyboard and the mouse sent to the VM while it has the focus (a click gives it). * The changed parts of the framebuffer go into one texture as small BMP pictures (SDL_LoadBMP_RW: no pixel loop in PHP). * * @property bool $scale */final class VncView extends Widget{ public const DEFAULTS = ['width' => 'fill', 'height' => 'fill', 'scale' => true]; public ?RfbClient $client = null; /** a line shown when there is no picture: "Connecting…", the error */ public string $message = ''; /** @var resource|object|null */ private $texture = null; /** @var array{0: int, 1: int} */ private array $textureSize = [0, 0]; private float $lastMouseX = -1; private float $lastMouseY = -1; /** Tests: Alt held (the real keyboard is read otherwise) */ public bool $alt = false; /** Tests: the physical keys held, as SDL_GetKeyboardState() gives them (scancode => 1); null = the real keyboard */ public ?\Closure $keyState = null; /** Tests: the physical keys that went down this frame (Keyboard::physicalPressed()); null = the real keyboard */ public ?\Closure $keyTaps = null; /** @var array<int, true> physical keys sent down to the VM (QEMU Extended Key Event), by SDL scancode */ private array $physical = []; private float $clock = 0; private float $physicalAt = -1; private const KEY_F = 102; public function attach(?RfbClient $client): void { $this->client = $client; $this->textureSize = [0, 0]; } public function focusable(): bool { return true; } public function wantsText(): bool { return $this->client?->state === RfbClient::READY; } public function stretches(string $axis): bool { return true; } protected function content(Theme $t, float $maxWidth, string $breakpoint): array { return [$t->px(320), $t->px(200)]; } /** @return array{0: float, 1: float, 2: float, 3: float, 4: float} where the picture is: x, y, w, h, scale */ public function picture(): array { $fw = max(1, $this->client?->fb->width ?? 1); $fh = max(1, $this->client?->fb->height ?? 1); $k = $this->props['scale'] ? min($this->w / $fw, $this->h / $fh) : min(1.0, min($this->w / $fw, $this->h / $fh)); // a whole factor when it grows (x1, x2, x3): at x1.25 one pixel column in four came out double, the console's letters // looked smudged (the view follows the window pixel for pixel: view pixels are the screen's) if ($k > 1) { $k = floor($k); } $w = $fw * $k; $h = $fh * $k; return [$this->x + ($this->w - $w) / 2, $this->y + ($this->h - $h) / 2, $w, $h, $k]; } public function render(Renderer $r, Theme $t): void { $r->fillRect($this->x, $this->y, $this->w, $this->h, '#101214'); $client = $this->client; $ready = $client !== null && $client->fb->width > 0 && $client->fb->height > 0; if ($ready && $this->form?->sdlOrNull() !== null) { $this->upload($r, $client); [$x, $y, $w, $h] = $this->picture(); if ($this->texture !== null) { $r->copy($this->texture, null, $x, $y, $w, $h); } if ($this->focused) { $r->drawRect($x - 2, $y - 2, $w + 4, $h + 4, '#3a7bd5', max(1, (int)round($t->scale))); } } $text = $this->message !== '' ? $this->message : ($client === null ? '' : match ($client->state) { RfbClient::CONNECTING => 'Connecting to the console…', RfbClient::HANDSHAKE => 'Opening the console…', RfbClient::CLOSED => $client->error !== '' ? $client->error : 'The console is closed.', default => '', }); if ($text !== '') { $font = $t->font($t->fontSize * 1.05); $tw = min($this->w - $t->px(40), $font->width($text) + $t->px(40)); $bx = $this->x + ($this->w - $tw) / 2; $by = $this->y + $this->h / 2 - $t->px(22); $t->box($r, $bx, $by, $tw, $t->px(44), [0, 0, 0, 170], $t->px(8)); $font->draw($r, Canvas::fit($font, $text, $tw - $t->px(24)), $this->x + $this->w / 2, $by + ($t->px(44) - $font->height()) / 2, 1, '#e8eaed', 'center'); } elseif ($ready && !$this->focused && $this->hovered) { $small = $t->font($t->fontSize * 0.9); $hint = 'Click to type in the console'; $hw = $small->width($hint) + $t->px(20); $t->box($r, $this->x + ($this->w - $hw) / 2, $this->y + $t->px(10), $hw, $t->px(26), [0, 0, 0, 150], $t->px(6)); $small->draw($r, $hint, $this->x + $this->w / 2, $this->y + $t->px(10) + ($t->px(26) - $small->height()) / 2, 1, '#ffffff', 'center'); } } /** The changed part of the framebuffer into the texture. */ private function upload(Renderer $r, RfbClient $client): void { $fb = $client->fb; $size = [$fb->width, $fb->height]; if ($this->texture === null || $this->textureSize !== $size) { if ($this->texture !== null) { \SDL_DestroyTexture($this->texture); } $this->texture = $r->createTargetTexture($fb->width, $fb->height); $this->textureSize = $size; $fb->mark(0, 0, $fb->width, $fb->height); } $dirty = $fb->takeDirty(); if ($dirty === null) { return; } [$x, $y, $w, $h] = $dirty; $bmp = $fb->bmp($x, $y, $w, $h); $rw = \SDL_RWFromConstMem($bmp, strlen($bmp)); $surface = $rw ? \SDL_LoadBMP_RW($rw, 1) : null; if (!$surface) { return; } $patch = \SDL_CreateTextureFromSurface($r->getRenderer(), $surface); \SDL_FreeSurface($surface); if (!$patch) { return; } $previous = $r->getTarget(); [$ox, $oy] = $r->translation(); $r->translate(-$ox, -$oy); $r->setTarget($this->texture); $r->copy($patch, null, $x, $y, $w, $h); $r->setTarget($previous); $r->translate($ox, $oy); \SDL_DestroyTexture($patch); } /** A point of the view -> a pixel of the VM's screen. @return array{0: int, 1: int} */ public function toScreen(float $px, float $py): array { [$x, $y, , , $k] = $this->picture(); return [(int)floor(($px - $x) / max(0.0001, $k)), (int)floor(($py - $y) / max(0.0001, $k))]; } // input ------------------------------------------------------------------------------------------------------------- public function pointerDown(Pointer $p): void { [$x, $y] = $this->toScreen($p->x, $p->y); $this->client?->pointer($x, $y, 1); } public function pointerDrag(Pointer $p): void { [$x, $y] = $this->toScreen($p->x, $p->y); $this->client?->pointer($x, $y, 1); } public function pointerUp(Pointer $p, bool $inside): void { [$x, $y] = $this->toScreen($p->x, $p->y); $this->client?->pointer($x, $y, 0); $this->emit('click'); } public function wheel(float $notches): bool { if ($this->client?->state !== RfbClient::READY) { return false; } [$x, $y] = $this->toScreen($this->lastMouseX, $this->lastMouseY); for ($i = 0; $i < max(1, (int)round(abs($notches))); $i++) { $this->client->wheel($x, $y, $notches > 0); } return true; } public function update(float $dt): void { $engine = $this->form?->engineOrNull(); if ($engine === null) { return; } // the system's text input stays on while the console has the keyboard. JumPHP\Gui\Form::setFocus() turns it off for // any widget that is not a TextBox; once one text event came (a field typed into before), the Form takes characters // from text events only, so the letters typed into the console were lost (the browser: SDL's emscripten port then // also cancels keypress, the only source of its text events). Only Return, arrows, Backspace... reached the VM. if ($this->focused && $this->wantsText() && function_exists('SDL_IsTextInputActive') && !\SDL_IsTextInputActive()) { \SDL_StartTextInput(); } $this->clock += $dt; $this->syncPhysicalKeys(); // the pointer moving over the screen (no button): the VM's own pointer follows if (!$this->hovered || $this->client?->state !== RfbClient::READY) { return; } $mouse = $engine->input->getMouse(); if ($mouse->x !== $this->lastMouseX || $mouse->y !== $this->lastMouseY) { $this->lastMouseX = $mouse->x; $this->lastMouseY = $mouse->y; [$x, $y] = $this->toScreen($mouse->x, $mouse->y); $this->client->pointer($x, $y); } } public function keys(Pointer $p): bool { $client = $this->client; if ($client?->state !== RfbClient::READY) { return false; } if ($client->extendedKeys) { // the keys go as physical keys (syncPhysicalKeys(), each frame): the VM's own layout makes the characters foreach ($p->keys as $key) { if ($p->ctrl && ($key === self::KEY_F || $key === self::KEY_F - 32) && ($this->alt || $this->altHeld())) { $this->emit('fullscreen'); } } // text with no key behind it (a phone's on-screen keyboard, an input method, a paste): as characters if (!$p->ctrl && $this->physical === [] && $this->heldNow() === [] && $this->tappedNow() === [] && $this->clock - $this->physicalAt > 0.15) { foreach ($p->text as $char) { $client->tap(Keysym::char($char)); } } return true; } $mods = array_values(array_filter([$p->ctrl ? Keysym::CONTROL : null, $p->shift && $p->ctrl ? Keysym::SHIFT : null])); foreach ($p->keys as $key) { if ($p->ctrl && ($key === self::KEY_F || $key === self::KEY_F - 32) && ($this->alt || $this->altHeld())) { $this->emit('fullscreen'); // Ctrl+Alt+F: the console's own shortcut (Escape still goes to the VM) continue; } $special = Keysym::special($key); if ($special !== null) { $client->tap($special, $mods); } elseif ($p->ctrl && $key >= 32 && $key < 127) { $client->tap($key, $mods); // Ctrl+C, Ctrl+D...: no text comes with them } } if (!$p->ctrl) { foreach ($p->text as $char) { $client->tap(Keysym::char($char)); } } return true; // everything goes to the VM (Tab too) } /** @return list<int> the known physical keys held now (SDL scancodes) */ private function heldNow(): array { if ($this->keyState !== null) { $state = ($this->keyState)(); } elseif (($keyboard = $this->form?->engineOrNull()?->input->getKeyboard()) !== null) { // JumPHP 0.5.52+: the browser's KeyboardEvent.code (SDL's emscripten port makes its scancodes from the character) $state = array_fill_keys($keyboard->physicalHeld(), 1); } else { $state = function_exists('SDL_GetKeyboardState') ? \SDL_GetKeyboardState() : []; } return Scancodes::held(is_array($state) ? $state : []); } /** @return list<int> the physical keys that went down since the last frame, in order, even if let go already (a quick tap) */ private function tappedNow(): array { if ($this->keyTaps !== null) { return ($this->keyTaps)(); } $keyboard = $this->form?->engineOrNull()?->input->getKeyboard(); return $keyboard !== null ? $keyboard->physicalPressed() : []; } /** * The physical keys go down and up on the VM as they do here (QEMU Extended Key Event, when the server takes it): what the VM * types then follows its own keyboard layout (AZERTY...), as XenCenter does. Every key is let go when the console loses the * keyboard. Ctrl+Alt+F stays the console's own shortcut. */ private function syncPhysicalKeys(): void { $client = $this->client; $on = $this->focused && $client !== null && $client->state === RfbClient::READY && $client->extendedKeys; $held = $on ? $this->heldNow() : []; $modifiersFirst = fn (int $a, int $b) => ($b >= 224) <=> ($a >= 224) ?: $a <=> $b; usort($held, $modifiersFirst); // Shift / AltGr down before the key they change, in the same frame $now = array_fill_keys($held, true); $ctrlAlt = (isset($now[Scancodes::LCTRL]) || isset($now[Scancodes::RCTRL])) && isset($now[Scancodes::LALT]); foreach ($held as $scancode) { if (!isset($this->physical[$scancode]) && !($scancode === Scancodes::F && $ctrlAlt)) { $client?->physicalKey($scancode, true, self::keysymOf($scancode)); $this->physical[$scancode] = true; $this->physicalAt = $this->clock; } } // keys pressed and let go between two frames (a quick tap, a slow frame in the browser): down then up at once, in order $modifiersUp = []; foreach ($on ? $this->tappedNow() : [] as $scancode) { if (isset($now[$scancode]) || isset($this->physical[$scancode]) || !isset(Scancodes::XT[$scancode]) || ($scancode === Scancodes::F && $ctrlAlt)) { continue; } $client?->physicalKey($scancode, true, self::keysymOf($scancode)); $this->physicalAt = $this->clock; if ($scancode >= 224) { $modifiersUp[] = $scancode; // a modifier tapped with a key in the same frame: up after the key } else { $client?->physicalKey($scancode, false, self::keysymOf($scancode)); } } foreach (array_reverse($modifiersUp) as $scancode) { $client?->physicalKey($scancode, false, self::keysymOf($scancode)); } $released = array_keys($this->physical); usort($released, fn (int $a, int $b) => $modifiersFirst($b, $a)); // the keys up before their modifiers foreach ($released as $scancode) { if (!isset($now[$scancode])) { $client?->physicalKey($scancode, false, self::keysymOf($scancode)); unset($this->physical[$scancode]); $this->physicalAt = $this->clock; } } } /** What the key types here, given to the VM along with the key (QEMU reads the key; 0 for the keys without a character). */ private static function keysymOf(int $scancode): int { $key = function_exists('SDL_GetKeyFromScancode') ? (int)\SDL_GetKeyFromScancode($scancode) : 0; return $key >= 32 && $key < 127 ? $key : 0; } private function altHeld(): bool { $keyboard = $this->form?->engineOrNull()?->input->getKeyboard(); $mods = function_exists('SDL_GetModState') ? (int)\SDL_GetModState() : 0; return ($mods & 0x300) !== 0 || ($keyboard !== null && ($keyboard->isKeyHeld(1073742050) || $keyboard->isKeyHeld(1073742054))); } /** Tests: text typed into the console as keys. */ public function typeText(string $text): void { foreach (mb_str_split($text) ?: [] as $char) { $char === "\n" ? $this->client?->tap(Keysym::RETURN) : $this->client?->tap(Keysym::char($char)); } }}