Accedi
IT
games/xencenter-xenadmin-xcp-jumphp / src / Gui / VncView.php · commit fb1b7c9
<?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));        }    }}