commit b860b031bb6602369761c6e1024b2dbe28fd9ccc Author: Owen Rummage Date: Sun Jun 28 09:49:44 2026 -0500 initial commit diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..19adbc8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,6 @@ +* text=auto eol=lf +*.cpp text eol=lf +*.hpp text eol=lf +*.txt text eol=lf +*.md text eol=lf +*.json text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b6e89dc --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +build/ +build-*/ +cmake-build-*/ +CMakeFiles/ +CMakeCache.txt +cmake_install.cmake +compile_commands.json +CTestTestfile.cmake +Testing/ + +wayviewer +*.o +*.obj +*.a +*.so +*.dylib +*.dll +*.exe +*.out + +.cache/ +.idea/ +.vscode/ +*.swp +*.swo +*~ + +.DS_Store +Thumbs.db diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..0cbbb7b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,35 @@ +cmake_minimum_required(VERSION 3.16) + +project(wayviewer VERSION 0.1.0 LANGUAGES CXX) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE) +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(SDL2 REQUIRED IMPORTED_TARGET sdl2) +pkg_check_modules(LIBVNCCLIENT REQUIRED IMPORTED_TARGET libvncclient) + +add_executable(wayviewer + src/input.cpp + src/main.cpp + src/options.cpp + src/sdl_app.cpp + src/viewer_state.cpp + src/vnc_client.cpp +) + +target_compile_options(wayviewer PRIVATE + -Wall + -Wextra + -Wpedantic +) + +target_link_libraries(wayviewer PRIVATE + PkgConfig::SDL2 + PkgConfig::LIBVNCCLIENT +) diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..1f8c3cc --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,35 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "default", + "displayName": "Default", + "generator": "Unix Makefiles", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "debug", + "displayName": "Debug", + "generator": "Unix Makefiles", + "binaryDir": "${sourceDir}/build-debug", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + } + ], + "buildPresets": [ + { + "name": "default", + "configurePreset": "default" + }, + { + "name": "debug", + "configurePreset": "debug" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..46da59c --- /dev/null +++ b/README.md @@ -0,0 +1,37 @@ +# wayviewer + +A small Wayland-friendly VNC viewer written in C++. + +## Build + +Dependencies: + +- CMake 3.16+ +- C++17 compiler +- SDL2 development files +- libvncclient development files +- pkg-config + +```sh +cmake --preset default +cmake --build --preset default +``` + +For a debug build: + +```sh +cmake --preset debug +cmake --build --preset debug +``` + +## Run + +```sh +./build/wayviewer HOST:5900 +``` + +Use `./build/wayviewer --help` for all options. + +## Why? + +Because I needed a VNC viewer that didnt suck and so here it is. Provided with no warranty to anyone who wants to build off of this hunk of complete trash diff --git a/src/input.cpp b/src/input.cpp new file mode 100644 index 0000000..9248251 --- /dev/null +++ b/src/input.cpp @@ -0,0 +1,83 @@ +#include "input.hpp" + +#include +#include + +#include + +namespace wayviewer { + +std::uint32_t mapSdlKey(SDL_Keycode key) +{ + if (key >= 0x20 && key <= 0x7e) { + return static_cast(key); + } + + switch (key) { + case SDLK_BACKSPACE: return XK_BackSpace; + case SDLK_TAB: return XK_Tab; + case SDLK_RETURN: return XK_Return; + case SDLK_ESCAPE: return XK_Escape; + case SDLK_DELETE: return XK_Delete; + case SDLK_HOME: return XK_Home; + case SDLK_END: return XK_End; + case SDLK_PAGEUP: return XK_Page_Up; + case SDLK_PAGEDOWN: return XK_Page_Down; + case SDLK_LEFT: return XK_Left; + case SDLK_UP: return XK_Up; + case SDLK_RIGHT: return XK_Right; + case SDLK_DOWN: return XK_Down; + case SDLK_INSERT: return XK_Insert; + case SDLK_F1: return XK_F1; + case SDLK_F2: return XK_F2; + case SDLK_F3: return XK_F3; + case SDLK_F4: return XK_F4; + case SDLK_F5: return XK_F5; + case SDLK_F6: return XK_F6; + case SDLK_F7: return XK_F7; + case SDLK_F8: return XK_F8; + case SDLK_F9: return XK_F9; + case SDLK_F10: return XK_F10; + case SDLK_F11: return XK_F11; + case SDLK_F12: return XK_F12; + case SDLK_LSHIFT: return XK_Shift_L; + case SDLK_RSHIFT: return XK_Shift_R; + case SDLK_LCTRL: return XK_Control_L; + case SDLK_RCTRL: return XK_Control_R; + case SDLK_LALT: return XK_Alt_L; + case SDLK_RALT: return XK_Alt_R; + case SDLK_LGUI: return XK_Super_L; + case SDLK_RGUI: return XK_Super_R; + default: return 0; + } +} + +int buttonMaskFromMouseState(std::uint32_t buttons) +{ + int mask = 0; + if (buttons & SDL_BUTTON_LMASK) { + mask |= rfbButton1Mask; + } + if (buttons & SDL_BUTTON_MMASK) { + mask |= rfbButton2Mask; + } + if (buttons & SDL_BUTTON_RMASK) { + mask |= rfbButton3Mask; + } + return mask; +} + +std::pair localToRemote(int localX, int localY, int windowW, int windowH, int remoteW, int remoteH) +{ + if (windowW <= 0 || windowH <= 0 || remoteW <= 0 || remoteH <= 0) { + return {0, 0}; + } + + int x = localX * remoteW / windowW; + int y = localY * remoteH / windowH; + x = std::max(0, std::min(remoteW - 1, x)); + y = std::max(0, std::min(remoteH - 1, y)); + return {x, y}; +} + +} diff --git a/src/input.hpp b/src/input.hpp new file mode 100644 index 0000000..c2d3890 --- /dev/null +++ b/src/input.hpp @@ -0,0 +1,14 @@ +#pragma once + +#include + +#include +#include + +namespace wayviewer { + +std::uint32_t mapSdlKey(SDL_Keycode key); +int buttonMaskFromMouseState(std::uint32_t buttons); +std::pair localToRemote(int localX, int localY, int windowW, int windowH, int remoteW, int remoteH); + +} diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..d2efe4b --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,14 @@ +#include "sdl_app.hpp" + +#include +#include + +int main(int argc, char** argv) +{ + try { + return wayviewer::runViewer(argc, argv); + } catch (const std::exception& e) { + std::cerr << "wayviewer: " << e.what() << '\n'; + return 1; + } +} diff --git a/src/options.cpp b/src/options.cpp new file mode 100644 index 0000000..9fae3a8 --- /dev/null +++ b/src/options.cpp @@ -0,0 +1,235 @@ +#include "options.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace wayviewer { +namespace { + +[[noreturn]] void usage(const char* program, int code) +{ + std::ostream& out = code == 0 ? std::cout : std::cerr; + out << "Usage: " << program << " [options]\n" + << " " << program << " --host HOST [--port PORT] [options]\n" + << " " << program << " HOST[:PORT] [options]\n\n" + << "Options:\n" + << " --host HOST VNC server host\n" + << " --port PORT VNC server port, default 5900\n" + << " --username USER VNC username for servers that request one\n" + << " --password PASS VNC password\n" + << " --password-file PATH Read the first line of PATH as the VNC password\n" + << " --quality LEVEL low, medium, or high; default medium\n" + << " --server-scale N Ask server for 1/N framebuffer scaling if supported\n" + << " --no-prompt Fail instead of prompting for missing values\n" + << " --view-only Do not send keyboard or pointer input\n" + << " --shared Ask the server for a shared session\n" + << " --title TITLE Window title\n" + << " --help Show this help\n"; + std::exit(code); +} + +int parsePort(const std::string& value) +{ + try { + const int port = std::stoi(value); + if (port <= 0 || port > 65535) { + throw std::out_of_range("port"); + } + return port; + } catch (const std::exception&) { + throw std::runtime_error("invalid port: " + value); + } +} + +int parseScale(const std::string& value) +{ + try { + const int scale = std::stoi(value); + if (scale < 1 || scale > 16) { + throw std::out_of_range("scale"); + } + return scale; + } catch (const std::exception&) { + throw std::runtime_error("invalid server scale: " + value + " (expected 1 through 16)"); + } +} + +std::string lower(std::string value) +{ + std::transform(value.begin(), value.end(), value.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + return value; +} + +Quality parseQuality(const std::string& value) +{ + const std::string normalized = lower(value); + if (normalized == "low") { + return Quality::Low; + } + if (normalized == "medium" || normalized == "med") { + return Quality::Medium; + } + if (normalized == "high") { + return Quality::High; + } + throw std::runtime_error("invalid quality: " + value + " (expected low, medium, or high)"); +} + +std::string readPasswordFile(const std::string& path) +{ + std::ifstream file(path); + if (!file) { + throw std::runtime_error("failed to open password file: " + path); + } + + std::string password; + std::getline(file, password); + return password; +} + +void parseTarget(Options& options, const std::string& target) +{ + const auto colon = target.rfind(':'); + if (colon == std::string::npos || colon == 0 || colon == target.size() - 1) { + options.host = target; + return; + } + + options.host = target.substr(0, colon); + options.port = parsePort(target.substr(colon + 1)); +} + +std::string promptLine(const std::string& label, const std::string& defaultValue = {}) +{ + std::cout << label; + if (!defaultValue.empty()) { + std::cout << " [" << defaultValue << "]"; + } + std::cout << ": " << std::flush; + + std::string value; + std::getline(std::cin, value); + return value.empty() ? defaultValue : value; +} + +std::string promptPassword(const std::string& label) +{ + std::cout << label << ": " << std::flush; + + termios oldTerm {}; + const bool canHide = isatty(STDIN_FILENO) && tcgetattr(STDIN_FILENO, &oldTerm) == 0; + if (canHide) { + termios newTerm = oldTerm; + newTerm.c_lflag &= ~ECHO; + tcsetattr(STDIN_FILENO, TCSAFLUSH, &newTerm); + } + + std::string value; + std::getline(std::cin, value); + + if (canHide) { + tcsetattr(STDIN_FILENO, TCSAFLUSH, &oldTerm); + std::cout << '\n'; + } + + return value; +} + +} + +Options parseArgs(int argc, char** argv) +{ + Options options; + + for (int i = 1; i < argc; ++i) { + const std::string arg = argv[i]; + const auto requireValue = [&](const char* name) -> std::string { + if (i + 1 >= argc) { + throw std::runtime_error(std::string("missing value for ") + name); + } + return argv[++i]; + }; + + if (arg == "--help" || arg == "-h") { + usage(argv[0], 0); + } else if (arg == "--host") { + options.host = requireValue("--host"); + } else if (arg == "--port") { + options.port = parsePort(requireValue("--port")); + } else if (arg == "--username") { + options.username = requireValue("--username"); + } else if (arg == "--password") { + options.password = requireValue("--password"); + } else if (arg == "--password-file") { + options.password = readPasswordFile(requireValue("--password-file")); + } else if (arg == "--quality") { + options.quality = parseQuality(requireValue("--quality")); + } else if (arg == "--server-scale") { + options.serverScale = parseScale(requireValue("--server-scale")); + } else if (arg == "--no-prompt") { + options.prompt = false; + } else if (arg == "--view-only") { + options.viewOnly = true; + } else if (arg == "--shared") { + options.shared = true; + } else if (arg == "--title") { + options.title = requireValue("--title"); + } else if (!arg.empty() && arg[0] == '-') { + throw std::runtime_error("unknown option: " + arg); + } else if (options.host.empty()) { + parseTarget(options, arg); + } else { + throw std::runtime_error("unexpected argument: " + arg); + } + } + + if (const char* envUsername = std::getenv("WAYVIEWER_USERNAME")) { + if (options.username.empty()) { + options.username = envUsername; + } + } + + if (const char* envPassword = std::getenv("WAYVIEWER_PASSWORD")) { + if (options.password.empty()) { + options.password = envPassword; + } + } + + return options; +} + +void promptForMissingOptions(Options& options) +{ + if (!options.prompt) { + if (options.host.empty()) { + throw std::runtime_error("missing VNC host"); + } + return; + } + + if (options.host.empty()) { + const std::string target = promptLine("VNC host or host:port"); + if (target.empty()) { + throw std::runtime_error("missing VNC host"); + } + parseTarget(options, target); + } + + if (options.username.empty()) { + options.username = promptLine("Username (blank if not required)"); + } + + if (options.password.empty()) { + options.password = promptPassword("Password"); + } +} + +} diff --git a/src/options.hpp b/src/options.hpp new file mode 100644 index 0000000..67b17ee --- /dev/null +++ b/src/options.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include + +namespace wayviewer { + +enum class Quality { + Low, + Medium, + High, +}; + +struct Options { + std::string host; + int port = 5900; + std::string username; + std::string password; + Quality quality = Quality::Medium; + int serverScale = 0; + bool prompt = true; + bool viewOnly = false; + bool shared = false; + std::string title = "wayviewer"; +}; + +Options parseArgs(int argc, char** argv); +void promptForMissingOptions(Options& options); + +} diff --git a/src/sdl_app.cpp b/src/sdl_app.cpp new file mode 100644 index 0000000..2589407 --- /dev/null +++ b/src/sdl_app.cpp @@ -0,0 +1,296 @@ +#include "sdl_app.hpp" + +#include "input.hpp" +#include "options.hpp" +#include "viewer_state.hpp" +#include "vnc_client.hpp" + +#include +#include + +#include +#include +#include +#include + +namespace wayviewer { +namespace { + +struct SdlSession { + SdlSession() + { + if (SDL_Init(SDL_INIT_VIDEO) != 0) { + throw std::runtime_error(std::string("SDL_Init failed: ") + SDL_GetError()); + } + } + + ~SdlSession() + { + SDL_Quit(); + } +}; + +using WindowPtr = std::unique_ptr; +using RendererPtr = std::unique_ptr; +using TexturePtr = std::unique_ptr; + +WindowPtr createWindow(const Options& options) +{ + WindowPtr window( + SDL_CreateWindow(options.title.c_str(), + SDL_WINDOWPOS_CENTERED, + SDL_WINDOWPOS_CENTERED, + 1024, + 768, + SDL_WINDOW_RESIZABLE | SDL_WINDOW_ALLOW_HIGHDPI), + SDL_DestroyWindow); + if (!window) { + throw std::runtime_error(std::string("SDL_CreateWindow failed: ") + SDL_GetError()); + } + return window; +} + +RendererPtr createRenderer(SDL_Window* window) +{ + RendererPtr renderer(SDL_CreateRenderer(window, -1, SDL_RENDERER_ACCELERATED), SDL_DestroyRenderer); + if (!renderer) { + renderer.reset(SDL_CreateRenderer(window, -1, SDL_RENDERER_SOFTWARE)); + } + if (!renderer) { + throw std::runtime_error(std::string("SDL_CreateRenderer failed: ") + SDL_GetError()); + } + return renderer; +} + +void connectClient(rfbClient* client, const Options& options) +{ + int initArgc = 1; + char programName[] = "wayviewer"; + char* initArgv[] = {programName, nullptr}; + if (!rfbInitClient(client, &initArgc, initArgv)) { + throw std::runtime_error("failed to connect or authenticate to VNC server"); + } + if (options.serverScale > 1 && !SendScaleSetting(client, options.serverScale)) { + std::cerr << "wayviewer: server did not accept framebuffer scale request\n"; + } + SendIncrementalFramebufferUpdateRequest(client); +} + +void sendPendingMotion(rfbClient* client, bool pendingMotion, int x, int y, int mask) +{ + if (pendingMotion) { + SendPointerEvent(client, x, y, mask); + } +} + +int handleServerMessages(rfbClient* client, ViewerState& state) +{ + int handledMessages = 0; + while (handledMessages < 256) { + const int ready = WaitForMessage(client, 0); + if (ready < 0) { + state.connected = false; + break; + } + if (ready == 0) { + break; + } + if (!HandleRFBServerMessage(client)) { + state.connected = false; + break; + } + ++handledMessages; + } + return handledMessages; +} + +void updateTexture(TexturePtr& texture, + SDL_Renderer* renderer, + ViewerState& state, + int& textureW, + int& textureH, + int& textureBytesPerPixel, + bool& needsRender) +{ + const int remoteW = state.width; + const int remoteH = state.height; + const int bytesPerPixel = state.bytesPerPixel; + if (remoteW <= 0 || remoteH <= 0) { + return; + } + + if (!texture || textureW != remoteW || textureH != remoteH || textureBytesPerPixel != bytesPerPixel) { + const Uint32 textureFormat = bytesPerPixel == 2 ? SDL_PIXELFORMAT_RGB565 : SDL_PIXELFORMAT_XRGB8888; + texture.reset(SDL_CreateTexture(renderer, textureFormat, SDL_TEXTUREACCESS_STREAMING, remoteW, remoteH)); + textureW = remoteW; + textureH = remoteH; + textureBytesPerPixel = bytesPerPixel; + state.dirtyRects.clear(); + markDirty(state, 0, 0, remoteW, remoteH); + state.resized = false; + } + + if (!texture || !state.dirty) { + return; + } + + const int pitch = remoteW * bytesPerPixel; + if (state.dirtyRects.empty()) { + SDL_UpdateTexture(texture.get(), nullptr, state.framebuffer.data(), pitch); + } else { + for (const SDL_Rect& rect : state.dirtyRects) { + const auto offset = (static_cast(rect.y) * remoteW + rect.x) * bytesPerPixel; + SDL_UpdateTexture(texture.get(), &rect, state.framebuffer.data() + offset, pitch); + } + } + state.dirtyRects.clear(); + state.dirty = false; + needsRender = true; +} + +void renderFrame(SDL_Renderer* renderer, SDL_Texture* texture, bool& needsRender) +{ + if (!needsRender) { + return; + } + + SDL_SetRenderDrawColor(renderer, 16, 16, 16, 255); + SDL_RenderClear(renderer); + if (texture) { + SDL_RenderCopy(renderer, texture, nullptr, nullptr); + } + SDL_RenderPresent(renderer); + needsRender = false; +} + +} + +int runViewer(int argc, char** argv) +{ + Options options = parseArgs(argc, argv); + promptForMissingOptions(options); + + SDL_SetHint(SDL_HINT_RENDER_SCALE_QUALITY, sdlScaleFilter(options.quality)); + SdlSession sdl; + + WindowPtr window = createWindow(options); + RendererPtr renderer = createRenderer(window.get()); + + ViewerState state; + ClientContext context; + RfbClientPtr client = createClient(options, state, context); + connectClient(client.get(), options); + + state.connected = true; + TexturePtr texture(nullptr, SDL_DestroyTexture); + int textureW = 0; + int textureH = 0; + int textureBytesPerPixel = 0; + int buttonMask = 0; + bool running = true; + bool needsRender = true; + + while (running && state.connected) { + const int inputRemoteW = state.width; + const int inputRemoteH = state.height; + + SDL_Event event; + bool handledEvent = false; + bool pendingMotion = false; + int pendingMotionX = 0; + int pendingMotionY = 0; + int pendingMotionMask = 0; + while (SDL_PollEvent(&event)) { + handledEvent = true; + switch (event.type) { + case SDL_QUIT: + running = false; + break; + case SDL_WINDOWEVENT: + if (event.window.event == SDL_WINDOWEVENT_RESIZED || + event.window.event == SDL_WINDOWEVENT_SIZE_CHANGED || + event.window.event == SDL_WINDOWEVENT_EXPOSED) { + needsRender = true; + } + break; + case SDL_KEYDOWN: + case SDL_KEYUP: + if (!options.viewOnly) { + const auto key = mapSdlKey(event.key.keysym.sym); + if (key != 0) { + SendKeyEvent(client.get(), key, event.type == SDL_KEYDOWN); + } + } + break; + case SDL_MOUSEMOTION: + if (!options.viewOnly) { + int windowW = 0; + int windowH = 0; + SDL_GetWindowSize(window.get(), &windowW, &windowH); + const auto [remoteX, remoteY] = localToRemote(event.motion.x, + event.motion.y, + windowW, + windowH, + inputRemoteW, + inputRemoteH); + buttonMask = buttonMaskFromMouseState(event.motion.state); + pendingMotion = true; + pendingMotionX = remoteX; + pendingMotionY = remoteY; + pendingMotionMask = buttonMask; + } + break; + case SDL_MOUSEBUTTONDOWN: + case SDL_MOUSEBUTTONUP: + if (!options.viewOnly) { + int windowW = 0; + int windowH = 0; + SDL_GetWindowSize(window.get(), &windowW, &windowH); + const auto [remoteX, remoteY] = localToRemote(event.button.x, + event.button.y, + windowW, + windowH, + inputRemoteW, + inputRemoteH); + const int bit = 1 << (event.button.button - 1); + if (event.type == SDL_MOUSEBUTTONDOWN) { + buttonMask |= bit; + } else { + buttonMask &= ~bit; + } + SendPointerEvent(client.get(), remoteX, remoteY, buttonMask); + } + break; + case SDL_MOUSEWHEEL: + if (!options.viewOnly) { + int localX = 0; + int localY = 0; + SDL_GetMouseState(&localX, &localY); + int windowW = 0; + int windowH = 0; + SDL_GetWindowSize(window.get(), &windowW, &windowH); + const auto [remoteX, remoteY] = localToRemote(localX, localY, windowW, windowH, inputRemoteW, inputRemoteH); + const int wheelMask = event.wheel.y > 0 ? rfbButton4Mask : rfbButton5Mask; + SendPointerEvent(client.get(), remoteX, remoteY, wheelMask); + SendPointerEvent(client.get(), remoteX, remoteY, 0); + } + break; + default: + break; + } + } + + sendPendingMotion(client.get(), pendingMotion, pendingMotionX, pendingMotionY, pendingMotionMask); + const int handledMessages = handleServerMessages(client.get(), state); + updateTexture(texture, renderer.get(), state, textureW, textureH, textureBytesPerPixel, needsRender); + renderFrame(renderer.get(), texture.get(), needsRender); + + if (!handledEvent && handledMessages == 0 && !needsRender) { + SDL_Delay(1); + } + } + + return 0; +} + +} diff --git a/src/sdl_app.hpp b/src/sdl_app.hpp new file mode 100644 index 0000000..2667b47 --- /dev/null +++ b/src/sdl_app.hpp @@ -0,0 +1,7 @@ +#pragma once + +namespace wayviewer { + +int runViewer(int argc, char** argv); + +} diff --git a/src/viewer_state.cpp b/src/viewer_state.cpp new file mode 100644 index 0000000..2ef6a76 --- /dev/null +++ b/src/viewer_state.cpp @@ -0,0 +1,35 @@ +#include "viewer_state.hpp" + +#include + +namespace wayviewer { + +void markDirty(ViewerState& state, int x, int y, int w, int h) +{ + if (state.width <= 0 || state.height <= 0) { + return; + } + + const int left = std::max(0, x); + const int top = std::max(0, y); + const int right = std::min(state.width, x + w); + const int bottom = std::min(state.height, y + h); + if (left >= right || top >= bottom) { + return; + } + + state.dirty = true; + if (state.dirtyRects.empty()) { + state.dirtyRects.push_back(SDL_Rect{left, top, right - left, bottom - top}); + return; + } + + SDL_Rect& merged = state.dirtyRects.front(); + const int mergedLeft = std::min(merged.x, left); + const int mergedTop = std::min(merged.y, top); + const int mergedRight = std::max(merged.x + merged.w, right); + const int mergedBottom = std::max(merged.y + merged.h, bottom); + merged = SDL_Rect{mergedLeft, mergedTop, mergedRight - mergedLeft, mergedBottom - mergedTop}; +} + +} diff --git a/src/viewer_state.hpp b/src/viewer_state.hpp new file mode 100644 index 0000000..ea523bd --- /dev/null +++ b/src/viewer_state.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include + +#include +#include + +namespace wayviewer { + +struct ViewerState { + std::vector framebuffer; + std::vector dirtyRects; + int width = 0; + int height = 0; + int bytesPerPixel = 4; + bool dirty = false; + bool resized = false; + bool connected = false; +}; + +void markDirty(ViewerState& state, int x, int y, int w, int h); + +} diff --git a/src/vnc_client.cpp b/src/vnc_client.cpp new file mode 100644 index 0000000..09d52f5 --- /dev/null +++ b/src/vnc_client.cpp @@ -0,0 +1,178 @@ +#include "vnc_client.hpp" + +#include +#include +#include +#include + +namespace wayviewer { +namespace { + +int clientContextTag = 0; + +void applyQuality(rfbClient* client, Quality quality) +{ + client->appData.forceTrueColour = TRUE; + client->appData.encodingsString = "copyrect tight zrle hextile raw"; + + switch (quality) { + case Quality::Low: + client->appData.requestedDepth = 16; + client->appData.compressLevel = 1; + client->appData.qualityLevel = 3; + client->appData.enableJPEG = TRUE; + break; + case Quality::Medium: + client->appData.requestedDepth = 24; + client->appData.compressLevel = 5; + client->appData.qualityLevel = 7; + client->appData.enableJPEG = TRUE; + break; + case Quality::High: + client->appData.requestedDepth = 24; + client->appData.compressLevel = 9; + client->appData.qualityLevel = 9; + client->appData.enableJPEG = TRUE; + break; + } +} + +void configurePixelFormat(rfbClient* client, Quality quality) +{ + if (quality == Quality::Low) { + client->format.bitsPerPixel = 16; + client->format.depth = 16; + client->format.bigEndian = 0; + client->format.trueColour = TRUE; + client->format.redMax = 31; + client->format.greenMax = 63; + client->format.blueMax = 31; + client->format.redShift = 11; + client->format.greenShift = 5; + client->format.blueShift = 0; + return; + } + + client->format.bitsPerPixel = 32; + client->format.depth = 24; + client->format.bigEndian = 0; + client->format.trueColour = TRUE; + client->format.redMax = 255; + client->format.greenMax = 255; + client->format.blueMax = 255; + client->format.redShift = 16; + client->format.greenShift = 8; + client->format.blueShift = 0; +} + +char* getPassword(rfbClient* client) +{ + const auto* context = static_cast(rfbClientGetClientData(client, &clientContextTag)); + const std::string& password = context->options->password; + auto* copy = static_cast(std::malloc(password.size() + 1)); + if (!copy) { + return nullptr; + } + std::memcpy(copy, password.c_str(), password.size() + 1); + return copy; +} + +rfbCredential* getCredential(rfbClient* client, int credentialType) +{ + if (credentialType != rfbCredentialTypeUser) { + return nullptr; + } + + const auto* context = static_cast(rfbClientGetClientData(client, &clientContextTag)); + auto* credential = static_cast(std::calloc(1, sizeof(rfbCredential))); + if (!credential) { + return nullptr; + } + + credential->userCredential.username = strdup(context->options->username.c_str()); + credential->userCredential.password = strdup(context->options->password.c_str()); + if (!credential->userCredential.username || !credential->userCredential.password) { + std::free(credential->userCredential.username); + std::free(credential->userCredential.password); + std::free(credential); + return nullptr; + } + + return credential; +} + +rfbBool allocateFramebuffer(rfbClient* client) +{ + auto* context = static_cast(rfbClientGetClientData(client, &clientContextTag)); + auto& state = *context->state; + const int width = client->width; + const int height = client->height; + + if (width <= 0 || height <= 0) { + return FALSE; + } + + state.width = width; + state.height = height; + state.bytesPerPixel = std::max(1, client->format.bitsPerPixel / 8); + state.framebuffer.assign(static_cast(width) * height * state.bytesPerPixel, 0); + client->frameBuffer = state.framebuffer.data(); + state.dirtyRects.clear(); + markDirty(state, 0, 0, width, height); + state.resized = true; + return TRUE; +} + +void framebufferUpdate(rfbClient* client, int x, int y, int w, int h) +{ + const auto* context = static_cast(rfbClientGetClientData(client, &clientContextTag)); + markDirty(*context->state, x, y, w, h); +} + +void finishedFramebufferUpdate(rfbClient* client) +{ + SendIncrementalFramebufferUpdateRequest(client); +} + +} + +const char* sdlScaleFilter(Quality quality) +{ + switch (quality) { + case Quality::Low: return "nearest"; + case Quality::Medium: return "linear"; + case Quality::High: return "linear"; + } + return "linear"; +} + +RfbClientPtr createClient(Options& options, ViewerState& state, ClientContext& context) +{ + const int clientBytesPerPixel = options.quality == Quality::Low ? 2 : 4; + const int bitsPerSample = options.quality == Quality::Low ? 5 : 8; + RfbClientPtr client(rfbGetClient(bitsPerSample, 3, clientBytesPerPixel), rfbClientCleanup); + if (!client) { + throw std::runtime_error("failed to allocate VNC client"); + } + + context.options = &options; + context.state = &state; + + rfbClientSetClientData(client.get(), &clientContextTag, &context); + client->serverHost = strdup(options.host.c_str()); + client->serverPort = options.port; + client->appData.shareDesktop = options.shared ? TRUE : FALSE; + client->appData.viewOnly = options.viewOnly ? TRUE : FALSE; + client->appData.scaleSetting = options.serverScale; + applyQuality(client.get(), options.quality); + client->MallocFrameBuffer = allocateFramebuffer; + client->GotFrameBufferUpdate = framebufferUpdate; + client->FinishedFrameBufferUpdate = finishedFramebufferUpdate; + client->GetPassword = getPassword; + client->GetCredential = getCredential; + configurePixelFormat(client.get(), options.quality); + + return client; +} + +} diff --git a/src/vnc_client.hpp b/src/vnc_client.hpp new file mode 100644 index 0000000..8876dbd --- /dev/null +++ b/src/vnc_client.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "options.hpp" +#include "viewer_state.hpp" + +#include + +#include + +namespace wayviewer { + +struct ClientContext { + Options* options = nullptr; + ViewerState* state = nullptr; +}; + +using RfbClientPtr = std::unique_ptr; + +const char* sdlScaleFilter(Quality quality); +RfbClientPtr createClient(Options& options, ViewerState& state, ClientContext& context); + +}