diff --git a/client/AGENTS.md b/client/AGENTS.md index 6b252fbb8..f66e7e2ec 100644 --- a/client/AGENTS.md +++ b/client/AGENTS.md @@ -86,6 +86,10 @@ storage. Closing a popup cancels without joining; the main loop reaps finished jobs and metaserver teardown joins all workers before endpoint/toolkit cleanup. Retained workers share a bounded job budget, including cancelled requests. +- The optional shared chat pilot owns one nonpersistent local-echo widget in + `shared_chat_pilot`; it has no network authority. Keep its build/runtime gates, + bounded input drain, control-epoch cancellation, exact whole-frame presentation + acknowledgment and native text ownership barriers. See `doc/SHARED_CHAT_PILOT.md`. - Capture privacy is owned by `capture_privacy`: private UI retention and frame composition deny new screenshots, video frames, and diagnostic captures until a successfully presented clean frame. UI close/reset and renderer recovery diff --git a/client/CMakeLists.txt b/client/CMakeLists.txt index cf183c27d..ddf8021ff 100644 --- a/client/CMakeLists.txt +++ b/client/CMakeLists.txt @@ -353,6 +353,7 @@ endif () # Compile the sources into an executable. add_executable(${EXECUTABLE} WIN32 ${SOURCES}) +include(cmake/SharedChatPilot.cmake) if (PACKAGE_TYPE STREQUAL "appimage") set(ATRINIK_APPIMAGE_SOURCE_REVISION "" CACHE STRING "Exact source revision embedded in the AppImage identity") diff --git a/client/INSTALL b/client/INSTALL index 90d691f27..36ca62016 100644 --- a/client/INSTALL +++ b/client/INSTALL @@ -100,3 +100,7 @@ Windows toolchain and recursive runtime packaging requirements are documented in `../docs/WINDOWS_TOOLCHAIN.md`. Use the required neutral `ATRINIK_WINDOWS_TOOLCHAIN_FILE`, `ATRINIK_WINDOWS_TARGET` and `ATRINIK_WINDOWS_RUNTIME_DIR` producer variables with plain CMake. + +The experimental shared local-echo panel is OFF by default and requires explicit +external SDK and verified font inputs. See `doc/SHARED_CHAT_PILOT.md`; enabling +it is a source-build experiment and does not change default package dependencies. diff --git a/client/cmake/SharedChatPilot.cmake b/client/cmake/SharedChatPilot.cmake new file mode 100644 index 000000000..ec49ee8f6 --- /dev/null +++ b/client/cmake/SharedChatPilot.cmake @@ -0,0 +1,38 @@ +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +option(ATRINIK_SHARED_CHAT_PILOT "Build experimental local-only shared chat panel" OFF) +if(ATRINIK_SHARED_CHAT_PILOT) + set(ATRINIK_SHARED_CHAT_INCLUDE_DIR "" CACHE PATH "Explicit ABI 2 header directory") + set(ATRINIK_SHARED_CHAT_LIBRARY "" CACHE FILEPATH "Explicit CPU prototype library") + set(ATRINIK_SHARED_CHAT_LINK_LIBRARIES "" CACHE STRING "Explicit platform static-library closure") + set(ATRINIK_SHARED_CHAT_FONT_MANIFEST "" CACHE FILEPATH "Explicit verified font-pack manifest") + foreach(input ATRINIK_SHARED_CHAT_INCLUDE_DIR ATRINIK_SHARED_CHAT_LIBRARY ATRINIK_SHARED_CHAT_FONT_MANIFEST) + if(NOT IS_ABSOLUTE "${${input}}" OR NOT EXISTS "${${input}}") + message(FATAL_ERROR "Shared chat pilot requires explicit existing absolute ${input}") + endif() + endforeach() + if(NOT EXISTS "${ATRINIK_SHARED_CHAT_INCLUDE_DIR}/tilewright_ui.h") + message(FATAL_ERROR "Shared chat pilot header missing") + endif() + execute_process(COMMAND "${Python3_EXECUTABLE}" "${CMAKE_CURRENT_SOURCE_DIR}/tools/shared_chat_fonts.py" + "${ATRINIK_SHARED_CHAT_FONT_MANIFEST}" "${CMAKE_CURRENT_BINARY_DIR}/shared_chat_fonts.h" + RESULT_VARIABLE font_result) + if(NOT font_result EQUAL 0) + message(FATAL_ERROR "Shared chat pilot font validation failed") + endif() + target_sources(${EXECUTABLE} PRIVATE src/client/shared_chat_pilot.c) + target_compile_definitions(${EXECUTABLE} PRIVATE ATRINIK_SHARED_CHAT_PILOT TWUI_CPU_PROTOTYPE) + target_include_directories(${EXECUTABLE} PRIVATE "${ATRINIK_SHARED_CHAT_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") + target_link_libraries(${EXECUTABLE} PRIVATE "${ATRINIK_SHARED_CHAT_LIBRARY}" ${ATRINIK_SHARED_CHAT_LINK_LIBRARIES}) +endif() +if(ATRINIK_SHARED_CHAT_PILOT AND BUILD_TESTING) + add_executable(client-shared-chat-pilot-tests src/tests/shared_chat_pilot.c src/client/capture_privacy.c) + atrinik_configure_target(client-shared-chat-pilot-tests) + target_compile_definitions(client-shared-chat-pilot-tests PRIVATE ATRINIK_SHARED_CHAT_PILOT TWUI_CPU_PROTOTYPE) + target_include_directories(client-shared-chat-pilot-tests PRIVATE src/include + "${ATRINIK_SHARED_CHAT_INCLUDE_DIR}" "${CMAKE_CURRENT_BINARY_DIR}") + target_link_libraries(client-shared-chat-pilot-tests PRIVATE SDL3::SDL3 + "${ATRINIK_SHARED_CHAT_LIBRARY}" ${ATRINIK_SHARED_CHAT_LINK_LIBRARIES}) + add_test(NAME client-shared-chat-pilot COMMAND client-shared-chat-pilot-tests) + set_tests_properties(client-shared-chat-pilot PROPERTIES ENVIRONMENT "SDL_VIDEODRIVER=dummy") +endif() diff --git a/client/doc/SHARED_CHAT_PILOT.md b/client/doc/SHARED_CHAT_PILOT.md new file mode 100644 index 000000000..5eafcefe6 --- /dev/null +++ b/client/doc/SHARED_CHAT_PILOT.md @@ -0,0 +1,82 @@ +# Optional local shared chat panel + +This experimental client adapter puts a shared CPU-rendered chat panel in one +existing widget slot. It is disabled at build time and at runtime. Existing +Classic chat is unchanged. The panel labels itself **Local echo**: all text, +including slash-prefixed text, is accepted into its private in-memory history. +It never sends a command or packet. This is a candidate embedding experiment, +not chat replacement or a qualified distribution feature. + +## Explicit source-build inputs + +Configure the client with these additional CMake options in an owned +Classic-derived wrapper profile/build plan: + +``` +-DATRINIK_SHARED_CHAT_PILOT=ON +-DATRINIK_SHARED_CHAT_INCLUDE_DIR=/absolute/sdk/include +-DATRINIK_SHARED_CHAT_LIBRARY=/absolute/sdk/lib/libtilewright_ui_ffi.a +-DATRINIK_SHARED_CHAT_LINK_LIBRARIES=dl;pthread;m +-DATRINIK_SHARED_CHAT_FONT_MANIFEST=/absolute/font-pack/manifest.json +``` + +The library must provide the embedded ChatPanel ABI 2 and CPU prototype exports. +The linker closure shown is for the Linux static candidate only; other targets +need their own verified closure. No Rust compiler, downloads or new fonts are +required by the default build. CMake does not fetch or build the optional SDK. +Default packages do not install any SDK or font-pack assets. Supplying this +option does not establish redistribution or platform qualification. + +The explicit schema-1 font manifest supplies an ordered `fonts` array. Each +entry names `family`, relative `path`, `bytes` and `sha256`. Configure verifies +font sizes, hashes and containment in the manifest directory and emits a private +build header. Runtime reads only those named paths and the shared library +reverifies their hashes. The pack is bounded to 32 fonts, 32 MiB each and 64 MiB +total. Missing, changed or incompatible inputs leave a visible generic error; +text is never logged in that error. + +Run only an isolated wrapper-managed client with +`ATRINIK_SHARED_CHAT_PILOT=1` to create the panel. The fixed 480×240 widget does +not persist its layout, transcript, draft, selection or history. It is admitted +only when fully inside the window at 1:1 scale. Click its composer to focus; +Escape releases focus. An outside click releases focus and is consumed. Standard +selection keys and explicit Ctrl+A/C/X/V gestures use shared text state. +Existing popups, menus and focused Classic chat/command inputs retain precedence. +Release the existing input (Escape) before focusing the pilot. After +they close, click the pilot to focus again. + +## Boundaries and validation + +The host retains its single SDL window, event poller, GPU renderer and presenter. +The adapter copies at most 256 native events per drain, bounds copied text to +4096 bytes and fences both physical Enter keys on any text/composition activity +in that drain. Ordinary editing and clipboard keys remain usable alongside +committed text; non-Enter keys are quarantined during actual native composition. +Control-epoch changes cancel held Classic movement and pointer +gestures, even when the final shared state allows world input. Host key +quarantine survives widget/context destruction and reconstruction until matching +key-up events arrive; a copied native drain also finishes after destruction. Native text +sessions are restarted on ownership barriers; timestamps retire late text. +Clipboard and visual movement additionally require shared physical-key effect +permission. There is no implicit world hit from the embedded panel. + +The panel renders a bounded 480×218 premultiplied RGBA surface through +`gpu_renderer_draw_surface`, inside normal widget order. The host acknowledges +its exact prepared stamp only after successful whole-frame presentation. Move, +resize, focus, modal and renderer-recovery barriers invalidate displayed +geometry before input. IME rectangles come from the acknowledged shared layout. + +History is limited to 32 messages and 32 KiB; the composer and messages to +4096 bytes; pending actions to eight and prepared frames to three. Acceptance +copies an action once into local history and acknowledges its exact batch ID. +Retained pilot content denies screenshot/video/diagnostic admission through +`capture_privacy`. Hiding retains privacy. Destruction drops private memory but +capture remains denied until a successfully presented clean frame. + +The enabled `client-shared-chat-pilot` CTest exercises the production adapter +with the actual shared ABI/CPU renderer and a small fake Classic host. It checks +ownership barriers, physical Enter identities, composition fences, output +backpressure, exact presentation, stale attachment geometry, alpha mode and +private-content lifetime. It uses the SDL dummy video driver and is **not** a +real GPU or game embedding qualification. Required wrapper builds, native +coverage and isolated real-runtime acceptance remain separate evidence. diff --git a/client/src/client/capture_privacy.c b/client/src/client/capture_privacy.c index 09c0124a7..ec9a8d53e 100644 --- a/client/src/client/capture_privacy.c +++ b/client/src/client/capture_privacy.c @@ -5,6 +5,14 @@ static bool blocked = true; static bool composing; static bool composition_sensitive; +static bool shared_chat_retained; + +void capture_privacy_shared_chat_retained(bool retained) { + shared_chat_retained = retained; + if (retained) { + capture_privacy_block(); + } +} void capture_privacy_block(void) { blocked = true; @@ -16,7 +24,7 @@ void capture_privacy_block(void) { void capture_privacy_frame_begin(bool sensitive_active) { blocked = true; composing = true; - composition_sensitive = sensitive_active; + composition_sensitive = sensitive_active || shared_chat_retained; } void capture_privacy_frame_end(bool presented) { @@ -27,5 +35,5 @@ void capture_privacy_frame_end(bool presented) { } bool capture_privacy_allowed(bool sensitive_active) { - return !blocked && !composing && !sensitive_active; + return !blocked && !composing && !sensitive_active && !shared_chat_retained; } diff --git a/client/src/client/main.c b/client/src/client/main.c index 2ee7d35ff..42e8f2bf4 100644 --- a/client/src/client/main.c +++ b/client/src/client/main.c @@ -33,6 +33,7 @@ #include #include #include +#include #include #include #include @@ -859,6 +860,7 @@ bool gpu_renderer_recovery_republish_test(void) { static bool gpu_renderer_recover_frame(unsigned int *attempts, const char *context, const gpu_renderer_recreation_diagnostic_t *consumed) { + shared_chat_pilot_invalidate(); capture_privacy_block(); HARD_ASSERT(attempts != NULL); HARD_ASSERT(context != NULL); @@ -1448,6 +1450,7 @@ int main(int argc, char *argv[]) { } render_profiler_end(RENDER_PROFILE_GAME, profile_game_started); + shared_chat_pilot_sync(); update = 0; if (window_is_active()) { @@ -1548,6 +1551,7 @@ int main(int argc, char *argv[]) { bool frame_presented = false; if (update) { bool presented = gpu_renderer_present(); + shared_chat_pilot_presented(presented); frame_presented = presented; capture_privacy_frame_end(presented); map_benchmark_statistics_present(presented); diff --git a/client/src/client/shared_chat_pilot.c b/client/src/client/shared_chat_pilot.c new file mode 100644 index 000000000..b7ae047ca --- /dev/null +++ b/client/src/client/shared_chat_pilot.c @@ -0,0 +1,669 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ +/* One private local-echo widget. The host owns every native/GPU lifetime. */ +#include +#include +#ifdef ATRINIK_SHARED_CHAT_PILOT_TEST +#include "../tests/shared_chat_host.h" +#else +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#endif +#include +#include +#include "shared_chat_fonts.h" + +#define PILOT_BYTES 4096U +#define PILOT_WIDTH 480 +#define PILOT_HEIGHT 240 +#define PILOT_TITLE 22 +#define PILOT_BATCH 256U + +static struct { + widgetdata *widget; + twui_context *ui; + SDL_Surface *pixels; + twui_stamp prepared; + uint64_t epoch, session_start, accepted; + uint32_t error_status; + uint32_t pressed[SDL_SCANCODE_COUNT]; + bool owned[SDL_SCANCODE_COUNT]; + bool failed, pending, text_owner, blocked, batch_ime, batch_composition, native_composing, + attachment, bounds_valid; + int x, y, w, h; +} pilot; + +/* Retired presses belong to the host event stream, not a particular widget. + * Keep them quarantined across /resetwidgets and periods with no pilot. */ +static bool retired_keys[SDL_SCANCODE_COUNT]; + +static void cancel_world(void) { + for (size_t i = 0; i < SDL_SCANCODE_COUNT; ++i) { + if (keys[i].pressed) + pilot.owned[i] = true; + } + memset(keys, 0, sizeof(keys)); + keybind_movement_focus_lost(); + keybind_movement_flush(); + event_dragging_stop(); + widgets_cancel_pointer_gestures(); +} + +static void fail_closed(void) { + pilot.failed = true; + cancel_world(); + if (pilot.widget != NULL) + pilot.widget->redraw = 2; + /* No text or transcript enters diagnostics. */ +} + +static bool state(twui_state *out) { + *out = (twui_state){.struct_size = sizeof(*out)}; + if (pilot.ui != NULL && twui_get_state(pilot.ui, out) == TWUI_OK) + return true; + fail_closed(); + return false; +} + +static void native_barrier(void) { + pilot.session_start = SDL_GetTicksNS(); + pilot.native_composing = false; + if (!SDL_StopTextInput(ScreenWindow) || !SDL_StartTextInput(ScreenWindow)) + fail_closed(); +} + +static void control(void) { + twui_control value = {.struct_size = sizeof(value)}; + if (twui_get_control(pilot.ui, &value) != TWUI_OK) { + fail_closed(); + return; + } + if (value.epoch != pilot.epoch) { + pilot.epoch = value.epoch; + cancel_world(); + /* Retire native composition even when the final owner is unchanged. */ + native_barrier(); + } + twui_state current; + if (state(¤t)) + pilot.text_owner = current.focused && !current.suspended && !current.modal_open; +} + +static twui_status apply(twui_input input, uint32_t *consumed) { + input.struct_size = sizeof(input); + uint32_t ignored = 1; + twui_status result = twui_apply(pilot.ui, &input, consumed != NULL ? consumed : &ignored); + control(); + pilot.error_status = result; + if (pilot.widget != NULL) + pilot.widget->redraw = 1; + /* Ordinary validation/backpressure errors preserve state and consume input. */ + if (result >= TWUI_WRONG_THREAD) + fail_closed(); + return result; +} + +static twui_status +text(uint32_t kind, const char *bytes, size_t size, uint64_t start, uint64_t length) { + twui_input input = {.kind = kind, + .text = (const uint8_t *)bytes, + .text_len = size, + .anchor = start, + .caret = length}; + if (kind != TWUI_RECEIVE) { + twui_state current; + if (!state(¤t)) + return TWUI_POISONED; + input.widget_generation = current.widget_generation; + input.control_epoch = current.control_epoch; + input.text_revision = current.text_revision; + } + return apply(input, NULL); +} + +bool shared_chat_pilot_text_owner(void) { + return pilot.widget != NULL && pilot.text_owner; +} + +void shared_chat_pilot_invalidate(void) { + if (pilot.ui == NULL || pilot.failed) + return; + pilot.pending = false; + if (twui_invalidate_surface(pilot.ui) != TWUI_OK) + fail_closed(); + control(); + pilot.widget->redraw = 1; +} + +static bool visible(void) { + return pilot.widget != NULL && pilot.widget->env == NULL && pilot.widget->show && + !pilot.widget->hidden && cpl.state == ST_PLAY; +} + +void shared_chat_pilot_sync(void) { + if (pilot.widget == NULL) + return; + int width = 0, height = 0; + bool size_ok = SDL_GetWindowSize(ScreenWindow, &width, &height); + int x = widget_x(pilot.widget), y = widget_y(pilot.widget); + int w = widget_w(pilot.widget), h = widget_h(pilot.widget); + bool valid = size_ok && w == PILOT_WIDTH && h == PILOT_HEIGHT && x >= 0 && y >= 0 && + x <= width - w && y <= height - h; + pilot.bounds_valid = valid; + if (!pilot.attachment || x != pilot.x || y != pilot.y || w != pilot.w || h != pilot.h) { + shared_chat_pilot_invalidate(); + pilot.x = x; + pilot.y = y; + pilot.w = w; + pilot.h = h; + pilot.attachment = true; + } + bool blocked = !visible() || !valid || popup_get_head() != NULL || + cur_widget[MENU_ID] != NULL || widgets_text_input_active() || + !(SDL_GetWindowFlags(ScreenWindow) & SDL_WINDOW_INPUT_FOCUS); + if (blocked != pilot.blocked) { + pilot.blocked = blocked; + if (pilot.failed) { + /* Failed contexts still yield to native modal/text ownership. */ + cancel_world(); + native_barrier(); + if (blocked) + pilot.text_owner = false; + } else { + (void)apply((twui_input){.kind = TWUI_SET_SUSPENDED, .value = blocked}, NULL); + shared_chat_pilot_invalidate(); + } + } +} + +static void accept_actions(void) { + for (unsigned int count = 0; count < 8 && !pilot.failed; ++count) { + twui_action action = {.struct_size = sizeof(action)}; + twui_status status = twui_peek_action(pilot.ui, &action); + if (status == TWUI_EMPTY) + return; + if (status != TWUI_OK || action.kind != TWUI_ACTION_SUBMIT_CHAT) { + fail_closed(); + return; + } + if (action.batch_id != pilot.accepted) { + uint8_t bytes[PILOT_BYTES]; + uint64_t size = 0; + if (twui_copy_action(pilot.ui, action.batch_id, bytes, sizeof(bytes), &size) != + TWUI_OK || + text(TWUI_RECEIVE, (const char *)bytes, (size_t)size, 0, 0) != TWUI_OK) { + fail_closed(); + return; + } + pilot.accepted = action.batch_id; + } + if (twui_ack_action(pilot.ui, action.batch_id) != TWUI_OK) { + fail_closed(); + return; + } + } +} + +static void draw(widgetdata *widget) { + if (!visible()) + return; + text_show_format_root(FONT_ARIAL11, + widget_x(widget), + widget_y(widget), + COLOR_WHITE, + 0, + NULL, + "%s", + pilot.failed ? "Local echo unavailable" + : pilot.error_status ? "Local echo - input rejected" + : "Local echo only - Escape releases focus"); + if (pilot.failed || !pilot.attachment || !pilot.bounds_valid) + return; + accept_actions(); + if (pilot.failed) + return; + if (pilot.pending) { + if (twui_discard_prepared(pilot.ui, &pilot.prepared) != TWUI_OK) { + fail_closed(); + return; + } + pilot.pending = false; + } + twui_layout layout = {.struct_size = sizeof(layout), + .viewport_width = PILOT_WIDTH, + .viewport_height = PILOT_HEIGHT - PILOT_TITLE, + .viewport_scale = 1, + .bounds_width = PILOT_WIDTH, + .bounds_height = PILOT_HEIGHT - PILOT_TITLE, + .clip_width = PILOT_WIDTH, + .clip_height = PILOT_HEIGHT - PILOT_TITLE}; + twui_image image = {.struct_size = sizeof(image)}; + if (twui_set_layout(pilot.ui, &layout) != TWUI_OK || + twui_render_prepare_current(pilot.ui, &image) != TWUI_OK) { + fail_closed(); + return; + } + control(); + pilot.prepared = image.stamp; + pilot.pending = true; + if (image.width != PILOT_WIDTH || image.height != PILOT_HEIGHT - PILOT_TITLE || + image.stride != PILOT_WIDTH * 4 || + image.byte_length != PILOT_WIDTH * (PILOT_HEIGHT - PILOT_TITLE) * 4) { + fail_closed(); + return; + } + if (pilot.pixels == NULL) { + pilot.pixels = + SDL_CreateSurface(PILOT_WIDTH, PILOT_HEIGHT - PILOT_TITLE, SDL_PIXELFORMAT_RGBA32); + if (pilot.pixels == NULL || + !SDL_SetSurfaceBlendMode(pilot.pixels, SDL_BLENDMODE_BLEND_PREMULTIPLIED)) { + fail_closed(); + return; + } + } + uint64_t required = 0; + if (pilot.pixels->pitch != (int)image.stride || + twui_copy_rgba(pilot.ui, + &image.stamp, + pilot.pixels->pixels, + image.byte_length, + &required) != TWUI_OK || + required != image.byte_length) { + fail_closed(); + return; + } + gpu_renderer_surface_changed(pilot.pixels); + SDL_FRect destination = {(float)pilot.x, + (float)(pilot.y + PILOT_TITLE), + PILOT_WIDTH, + PILOT_HEIGHT - PILOT_TITLE}; + if (!gpu_renderer_draw_surface(pilot.pixels, NULL, &destination)) + shared_chat_pilot_invalidate(); +} + +void shared_chat_pilot_presented(bool presented) { + if (pilot.ui == NULL || pilot.failed) + return; + if (!presented) { + shared_chat_pilot_invalidate(); + return; + } + if (!pilot.pending) + return; + pilot.pending = false; + if (twui_ack_presented(pilot.ui, &pilot.prepared) != TWUI_OK) { + fail_closed(); + return; + } + twui_rect rect = {.struct_size = sizeof(rect)}; + if (pilot.text_owner && twui_get_ime_rect(pilot.ui, &pilot.prepared, &rect) == TWUI_OK) { + SDL_Rect area = {pilot.x + (int)rect.x, + pilot.y + PILOT_TITLE + (int)rect.y, + (int)SDL_ceilf(rect.width), + (int)SDL_ceilf(rect.height)}; + if (area.w < 1) + area.w = 1; + if (area.h < 1) + area.h = 1; + if (!SDL_SetTextInputArea(ScreenWindow, &area, 0)) + fail_closed(); + } +} + +static void destroy(widgetdata *widget) { + (void)widget; + cancel_world(); + if (pilot.ui != NULL) + (void)twui_destroy(pilot.ui); + if (pilot.pixels != NULL) { + gpu_renderer_invalidate_surface(pilot.pixels); + SDL_DestroySurface(pilot.pixels); + } + bool was_owner = pilot.text_owner; + for (size_t i = 0; i < SDL_SCANCODE_COUNT; ++i) { + retired_keys[i] = retired_keys[i] || pilot.owned[i]; + } + memset(&pilot, 0, sizeof(pilot)); + if (was_owner) { + (void)SDL_StopTextInput(ScreenWindow); + (void)SDL_StartTextInput(ScreenWindow); + } + capture_privacy_shared_chat_retained(false); +} + +void shared_chat_pilot_create(void) { + const char *enabled = getenv("ATRINIK_SHARED_CHAT_PILOT"); + if (pilot.widget != NULL || enabled == NULL || strcmp(enabled, "1") != 0) + return; + widgetdata *widget = create_widget(LABEL_ID); + widget->type = LABEL_ID; + widget->name = xstrdup("Local echo"); + widget->id = NULL; + widget->save = widget->required = widget->moveable = widget->resizeable = 0; + widget->show = widget->redraw = 1; + widget->hidden = 0; + widget->w = PILOT_WIDTH; + widget->h = PILOT_HEIGHT; + widget->x = 16; + widget->y = 48; + widget->texture_type = WIDGET_TEXTURE_TYPE_NONE; + widget->draw_func = draw; + widget->deinit_func = destroy; + widget->menu_handle_func = NULL; + pilot.widget = widget; + capture_privacy_shared_chat_retained(true); + twui_config config = + {sizeof(config), TWUI_ABI_VERSION, PILOT_BYTES, 32, 32768, PILOT_BYTES, 8, 3}; + if (twui_abi_version() != TWUI_ABI_VERSION || + twui_create_with_composition(&config, TWUI_COMPOSITION_CHAT_PANEL, &pilot.ui) != TWUI_OK) { + fail_closed(); + return; + } + twui_font fonts[32] = {{0}}; + uint8_t *allocated[32] = {0}; + bool good = true; + for (size_t i = 0; i < arraysize(pilot_fonts); ++i) { + const struct pilot_font_file *file = &pilot_fonts[i]; + FILE *stream = fopen(file->path, "rb"); + allocated[i] = malloc(file->size); + if (stream == NULL || allocated[i] == NULL) + good = false; + else if (fread(allocated[i], 1, file->size, stream) != file->size || fgetc(stream) != EOF) + good = false; + if (stream != NULL && fclose(stream) != 0) + good = false; + fonts[i] = (twui_font){.struct_size = sizeof(fonts[i]), + .family = (const uint8_t *)file->family, + .family_bytes = strlen(file->family), + .bytes = allocated[i], + .byte_length = file->size}; + memcpy(fonts[i].sha256, file->sha256, 32); + if (!good) + break; + } + if (good) + good = twui_set_fonts(pilot.ui, fonts, arraysize(pilot_fonts)) == TWUI_OK; + for (size_t i = 0; i < arraysize(allocated); ++i) + free(allocated[i]); + if (!good) { + fail_closed(); + return; + } + const char *notice = "Local echo: messages and /commands stay on this device."; + (void)text(TWUI_RECEIVE, notice, strlen(notice), 0, 0); + shared_chat_pilot_sync(); +} + +static uint32_t key_value(SDL_Keycode key, SDL_Keymod modifiers) { + if (key == SDLK_RETURN || key == SDLK_KP_ENTER) + return TWUI_KEY_ENTER; + if (key == SDLK_BACKSPACE) + return TWUI_KEY_BACKSPACE; + if (key == SDLK_DELETE) + return TWUI_KEY_DELETE; + if (key == SDLK_ESCAPE) + return TWUI_KEY_ESCAPE; + if (key == SDLK_A && (modifiers & SDL_KMOD_CTRL)) + return TWUI_KEY_SELECT_ALL; + return TWUI_KEY_OTHER; +} + +static void key_effect(const SDL_KeyboardEvent *event) { + uint32_t allowed = 0; + if (twui_key_effect_allowed(pilot.ui, event->scancode, &allowed) != TWUI_OK) { + fail_closed(); + return; + } + if (!allowed) + return; + if (event->key == SDLK_ESCAPE) { + (void)apply((twui_input){.kind = TWUI_SET_FOCUS}, NULL); + return; + } + uint32_t motion = event->key == SDLK_LEFT ? 1 + : event->key == SDLK_RIGHT ? 2 + : event->key == SDLK_HOME ? 3 + : event->key == SDLK_END ? 4 + : 0; + twui_stamp displayed = {.struct_size = sizeof(displayed)}; + if (motion && twui_get_displayed(pilot.ui, &displayed) == TWUI_OK) + (void)twui_move_visual(pilot.ui, + &displayed, + event->scancode, + motion, + !!(event->mod & SDL_KMOD_SHIFT)); + if (event->repeat || !(event->mod & SDL_KMOD_CTRL)) + return; + twui_state current; + if (!state(¤t) || current.preedit_bytes != 0) + return; + if (event->key == SDLK_V) { + char *bytes = SDL_GetClipboardText(); + if (bytes != NULL) { + size_t size = strnlen(bytes, PILOT_BYTES + 1); + if (size <= PILOT_BYTES) + (void)text(TWUI_COMMIT, bytes, size, 0, 0); + SDL_free(bytes); + } + } else if (event->key == SDLK_C || event->key == SDLK_X) { + uint8_t bytes[PILOT_BYTES + 1]; + uint64_t size = 0; + if (twui_copy_text(pilot.ui, TWUI_TEXT_COMPOSER, 0, bytes, PILOT_BYTES, &size) != TWUI_OK) + return; + uint64_t first = current.anchor < current.caret ? current.anchor : current.caret; + uint64_t last = current.anchor > current.caret ? current.anchor : current.caret; + if (first < last && last <= size) { + bytes[last] = 0; + if (SDL_SetClipboardText((const char *)bytes + first) && event->key == SDLK_X) + (void)text(TWUI_COMMIT, "", 0, 0, 0); + } + } +} + +bool shared_chat_pilot_event(SDL_Event *event) { + bool keyboard = event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP; + if (keyboard && event->key.scancode > SDL_SCANCODE_UNKNOWN && + event->key.scancode < SDL_SCANCODE_COUNT && retired_keys[event->key.scancode]) { + keys[event->key.scancode].pressed = 0; + if (event->type == SDL_EVENT_KEY_UP) { + retired_keys[event->key.scancode] = false; + } + return true; + } + if (pilot.widget == NULL) + return false; + shared_chat_pilot_sync(); + switch (event->type) { + case SDL_EVENT_WINDOW_RESIZED: + case SDL_EVENT_WINDOW_PIXEL_SIZE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_SCALE_CHANGED: + case SDL_EVENT_WINDOW_DISPLAY_CHANGED: + case SDL_EVENT_WINDOW_MINIMIZED: + case SDL_EVENT_WINDOW_RESTORED: + shared_chat_pilot_invalidate(); + return false; + case SDL_EVENT_WINDOW_FOCUS_LOST: + case SDL_EVENT_WINDOW_FOCUS_GAINED: + if (!pilot.failed) + (void)apply((twui_input){.kind = TWUI_SET_WINDOW_FOCUS, + .value = event->type == SDL_EVENT_WINDOW_FOCUS_GAINED}, + NULL); + shared_chat_pilot_invalidate(); + return false; + default: + break; + } + bool text_event = event->type == SDL_EVENT_TEXT_INPUT || event->type == SDL_EVENT_TEXT_EDITING; + if (text_event && event->common.timestamp < pilot.session_start) + return true; + if (pilot.failed) { + if (keyboard && event->key.scancode > SDL_SCANCODE_UNKNOWN && + event->key.scancode < SDL_SCANCODE_COUNT && + (pilot.text_owner || pilot.owned[event->key.scancode])) { + keys[event->key.scancode].pressed = 0; + if (event->type == SDL_EVENT_KEY_UP) + pilot.owned[event->key.scancode] = false; + return true; + } + if (text_event) + return pilot.text_owner; + if (EVENT_IS_MOUSE(event)) + return get_widget_owner(event_mouse_x(event), event_mouse_y(event), NULL, NULL) == + pilot.widget; + return false; + } + if (keyboard) { + SDL_Scancode physical = event->key.scancode; + if (physical <= SDL_SCANCODE_UNKNOWN || physical >= SDL_SCANCODE_COUNT) + return pilot.text_owner; + bool down = event->type == SDL_EVENT_KEY_DOWN; + bool was_owned = pilot.owned[physical]; + /* New modal/input presses belong to that owner. Retired presses remain quarantined. */ + if (pilot.blocked && !was_owned) + return false; + if (down && pilot.pressed[physical] == 0) + pilot.pressed[physical] = key_value(event->key.key, event->key.mod); + uint32_t value = pilot.pressed[physical] ? pilot.pressed[physical] + : key_value(event->key.key, event->key.mod); + uint32_t consumed = 1; + twui_status status = + apply((twui_input){.kind = down ? TWUI_KEY_DOWN : TWUI_KEY_UP, + .value = value, + .physical_key = physical, + .key_repeat = down && event->key.repeat, + /* Ordinary text fences Enter only; other keys are + * IME-owned only during an actual preedit session. */ + .platform_composing = + down && (pilot.native_composing || pilot.batch_composition || + (value == TWUI_KEY_ENTER && pilot.batch_ime))}, + &consumed); + bool owned = was_owned || consumed != 0 || status != TWUI_OK; + pilot.owned[physical] = down && owned; + if (down && status == TWUI_OK && !pilot.blocked) + key_effect(&event->key); + if (!down) + pilot.pressed[physical] = 0; + /* Classic records pressed before routing: owned keys never remain held. */ + if (owned) + keys[physical].pressed = 0; + return owned; + } + if (text_event) { + if (!pilot.text_owner) + return false; + const char *bytes = + event->type == SDL_EVENT_TEXT_INPUT ? event->text.text : event->edit.text; + size_t size = strnlen(bytes, PILOT_BYTES + 1); + pilot.native_composing = event->type == SDL_EVENT_TEXT_EDITING && size != 0; + if (size <= PILOT_BYTES) { + if (event->type == SDL_EVENT_TEXT_INPUT) + (void)text(TWUI_COMMIT, bytes, size, 0, 0); + else + (void)text(TWUI_PREEDIT, + bytes, + size, + event->edit.start < 0 ? UINT64_MAX : (uint64_t)event->edit.start, + event->edit.length < 0 ? UINT64_MAX : (uint64_t)event->edit.length); + } + return true; + } + if (pilot.blocked) + return false; + if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN || event->type == SDL_EVENT_MOUSE_BUTTON_UP || + event->type == SDL_EVENT_MOUSE_MOTION || event->type == SDL_EVENT_MOUSE_WHEEL) { + int x = event_mouse_x(event), y = event_mouse_y(event); + bool inside = get_widget_owner(x, y, NULL, NULL) == pilot.widget; + if (!inside) { + /* Explicit outside click retires focus; the same click cannot fire a world action. */ + if (pilot.text_owner && event->type == SDL_EVENT_MOUSE_BUTTON_DOWN) { + (void)apply((twui_input){.kind = TWUI_SET_FOCUS}, NULL); + return true; + } + return false; + } + if (event->type == SDL_EVENT_MOUSE_BUTTON_DOWN && event->button.button == SDL_BUTTON_LEFT) { + twui_stamp stamp = {.struct_size = sizeof(stamp)}; + uint32_t consumed = 1; + if (twui_get_displayed(pilot.ui, &stamp) == TWUI_OK) + (void)twui_pointer(pilot.ui, + &stamp, + x - pilot.x, + y - pilot.y - PILOT_TITLE, + &consumed); + control(); + pilot.widget->redraw = 1; + } else if (event->type == SDL_EVENT_MOUSE_WHEEL) { + twui_stamp stamp = {.struct_size = sizeof(stamp)}; + uint32_t consumed = 1; + if (twui_get_displayed(pilot.ui, &stamp) == TWUI_OK) + (void)twui_scroll_at(pilot.ui, + &stamp, + x - pilot.x, + y - pilot.y - PILOT_TITLE, + event->wheel.y > 0 ? 1 + : event->wheel.y < 0 ? -1 + : 0, + &consumed); + control(); + pilot.widget->redraw = 1; + } + return true; + } + return false; +} + +/* A bounded drain copies SDL-owned text before the next pump. Any text activity + * fences Enter throughout the batch, including commit-before-key ordering. */ +bool shared_chat_pilot_poll(SDL_Event *event) { + static SDL_Event batch[PILOT_BATCH]; + static char strings[PILOT_BATCH][PILOT_BYTES + 2]; + static unsigned int count, next; + /* Finish an already copied drain even if its widget was destroyed while + * dispatching the previous event. In particular, do not lose key releases. */ + if (next < count) { + *event = batch[next++]; + return true; + } + if (pilot.widget == NULL) + return SDL_PollEvent(event); + if (next == count) { + next = count = 0; + twui_state current; + pilot.batch_ime = !pilot.failed && state(¤t) && current.preedit_bytes != 0; + pilot.batch_composition = pilot.batch_ime || pilot.native_composing; + while (count < PILOT_BATCH && SDL_PollEvent(&batch[count])) { + SDL_Event *item = &batch[count]; + const char *bytes = item->type == SDL_EVENT_TEXT_INPUT ? item->text.text + : item->type == SDL_EVENT_TEXT_EDITING ? item->edit.text + : NULL; + if (bytes != NULL) { + size_t size = strnlen(bytes, PILOT_BYTES + 1); + memcpy(strings[count], bytes, size); + strings[count][size] = 0; + if (item->type == SDL_EVENT_TEXT_INPUT) + item->text.text = strings[count]; + else + item->edit.text = strings[count]; + pilot.batch_ime = true; + if (item->type == SDL_EVENT_TEXT_EDITING) + pilot.batch_composition = true; + } + ++count; + } + if (count == PILOT_BATCH) + pilot.batch_ime = true; + } + if (next == count) + return false; + *event = batch[next++]; + return true; +} diff --git a/client/src/events/event.c b/client/src/events/event.c index 2dd30d880..9d39d2c96 100644 --- a/client/src/events/event.c +++ b/client/src/events/event.c @@ -29,6 +29,7 @@ */ #include +#include #include #include #include @@ -172,6 +173,8 @@ int Event_PollInputDevice(void) { static Uint32 Ticks = 0; static bool close_logged = false, quit_logged = false; + shared_chat_pilot_sync(); + /* Execute mouse actions, even if mouse button is being held. */ if ((SDL_GetTicks() - Ticks > 125) || !Ticks) { if (cpl.state >= ST_PLAY && !live_movement_enabled()) { @@ -185,7 +188,7 @@ int Event_PollInputDevice(void) { } } - while (SDL_PollEvent(&event)) { + while (shared_chat_pilot_poll(&event)) { if (live_movement_enabled() && live_movement_input_is_gameplay(event.type)) { continue; } @@ -215,6 +218,10 @@ int Event_PollInputDevice(void) { tooltip_dismiss(); } + if (shared_chat_pilot_event(&event)) { + continue; + } + if (event.type == SDL_EVENT_KEY_DOWN && event.key.key == SDLK_PRINTSCREEN) { screenshot_create(NULL); continue; @@ -333,6 +340,7 @@ int Event_PollInputDevice(void) { } } + shared_chat_pilot_sync(); if (!live_movement_enabled()) { keybind_state_ensure(); keybind_movement_flush(); diff --git a/client/src/gui/toolkit/include/widget.h b/client/src/gui/toolkit/include/widget.h index 9f0be2eeb..26aa8c330 100644 --- a/client/src/gui/toolkit/include/widget.h +++ b/client/src/gui/toolkit/include/widget.h @@ -472,6 +472,10 @@ extern int tooltip_need_redraw(void); extern widgetdata *cur_widget[TOTAL_SUBWIDGETS]; extern widgetevent widget_mouse_event; +/** Cancel gestures without completing a drag or firing a world action. */ +extern void widgets_cancel_pointer_gestures(void); +/** True while an existing Classic chat/command input owns text. */ +extern bool widgets_text_input_active(void); extern int widget_id_from_name(const char *name); diff --git a/client/src/gui/toolkit/popup.c b/client/src/gui/toolkit/popup.c index 62e2a2fea..dafe4c1df 100644 --- a/client/src/gui/toolkit/popup.c +++ b/client/src/gui/toolkit/popup.c @@ -29,6 +29,7 @@ * @author Zoey Rose */ +#include #include #include #include @@ -116,6 +117,7 @@ popup_struct *popup_create(texture_struct *texture) { popup->selection_start = popup->selection_end = -1; popup->redraw = 1; popup->modal = 1; + shared_chat_pilot_sync(); return popup; } @@ -140,6 +142,7 @@ void popup_destroy(popup_struct *popup) { } DL_DELETE(popup_head, popup); + shared_chat_pilot_sync(); SDL_DestroySurface(popup->surface); free(popup->buf); diff --git a/client/src/gui/toolkit/text_input.c b/client/src/gui/toolkit/text_input.c index 5c39bdf00..2f568228d 100644 --- a/client/src/gui/toolkit/text_input.c +++ b/client/src/gui/toolkit/text_input.c @@ -37,6 +37,7 @@ #include #include #include +#include #include #include #include @@ -376,7 +377,9 @@ static void multiline_show(text_input_struct *input, SDL_Surface *surface, bool } SDL_Rect area = {.x = input->px + input->coords.x, .y = input->py + y, .w = input->coords.w, .h = height}; - SDL_SetTextInputArea(ScreenWindow, &area, cursor_x - input->coords.x); + if (!shared_chat_pilot_text_owner()) { + SDL_SetTextInputArea(ScreenWindow, &area, cursor_x - input->coords.x); + } } if (end == input->num) { break; @@ -458,7 +461,9 @@ static void text_input_show_impl(text_input_struct *text_input, .h = text_input->coords.h, }; - SDL_SetTextInputArea(ScreenWindow, &input_area, TEXT_INPUT_PADDING + box.w); + if (!shared_chat_pilot_text_owner()) { + SDL_SetTextInputArea(ScreenWindow, &input_area, TEXT_INPUT_PADDING + box.w); + } } sb = stringbuffer_new(); diff --git a/client/src/gui/toolkit/widget.c b/client/src/gui/toolkit/widget.c index 28dc957b4..1e9f5fa53 100644 --- a/client/src/gui/toolkit/widget.c +++ b/client/src/gui/toolkit/widget.c @@ -78,6 +78,7 @@ #include #include #include +#include static widgetdata def_widget[TOTAL_SUBWIDGETS]; static const char *const widget_names[TOTAL_SUBWIDGETS] = {"map", @@ -149,6 +150,26 @@ static widgetresize widget_event_resize = {0, NULL, 0, 0}; * */ static int IsMouseExclusive = 0; + +bool widgets_text_input_active(void) { + for (widgetdata *widget = cur_widget[INPUT_ID]; widget != NULL; widget = widget->type_next) { + if (widget->show && !widget->hidden) return true; + } + for (widgetdata *widget = cur_widget[CHATWIN_ID]; widget != NULL; widget = widget->type_next) { + textwin_struct *chat = TEXTWIN(widget); + if (widget->show && !widget->hidden && chat != NULL && chat->tab_selected < chat->tabs_num && + chat->tabs[chat->tab_selected].text_input.focus) return true; + } + return false; +} + +void widgets_cancel_pointer_gestures(void) { + IsMouseExclusive = 0; + memset(&widget_mouse_event, 0, sizeof(widget_mouse_event)); + memset(&widget_event_move, 0, sizeof(widget_event_move)); + memset(&widget_event_resize, 0, sizeof(widget_event_resize)); +} + static bool widget_fixture_read_only; #ifdef ATRINIK_WIDGET_TESTS @@ -375,6 +396,7 @@ static void toolkit_widget_init_from(const char *interface_path) { } } + shared_chat_pilot_create(); widget_enforce_map_priority(); widgets_ensure_onscreen(); } @@ -405,6 +427,7 @@ void toolkit_widget_fixture_init(const char *interface_path, const char *layout_ SetPriorityWidget(widget); } } + shared_chat_pilot_create(); widget_enforce_map_priority(); widgets_ensure_onscreen(); } diff --git a/client/src/include/capture_privacy.h b/client/src/include/capture_privacy.h index 898d3eaae..b1f2abb32 100644 --- a/client/src/include/capture_privacy.h +++ b/client/src/include/capture_privacy.h @@ -8,6 +8,8 @@ /* Client thread only. Starts denied. Private UI and renderer recovery invalidate * the completed frame; closing/resetting UI never grants capture permission. */ void capture_privacy_block(void); +/* Private pilot retention; clearing it never clears the presented-frame latch. */ +void capture_privacy_shared_chat_retained(bool retained); /* Snapshot privacy before composition. A private load during composition also * taints that frame, even if the UI closes before presentation. */ void capture_privacy_frame_begin(bool sensitive_active); diff --git a/client/src/include/shared_chat_pilot.h b/client/src/include/shared_chat_pilot.h new file mode 100644 index 000000000..a6823558a --- /dev/null +++ b/client/src/include/shared_chat_pilot.h @@ -0,0 +1,33 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ +#ifndef SHARED_CHAT_PILOT_H +#define SHARED_CHAT_PILOT_H +#include +#include +#ifdef ATRINIK_SHARED_CHAT_PILOT +void shared_chat_pilot_create(void); +void shared_chat_pilot_sync(void); +void shared_chat_pilot_invalidate(void); +void shared_chat_pilot_presented(bool presented); +bool shared_chat_pilot_event(SDL_Event *event); +bool shared_chat_pilot_poll(SDL_Event *event); +bool shared_chat_pilot_text_owner(void); +#else +static inline void shared_chat_pilot_create(void) {} +static inline void shared_chat_pilot_sync(void) {} +static inline void shared_chat_pilot_invalidate(void) {} +static inline void shared_chat_pilot_presented(bool presented) { + (void)presented; +} +static inline bool shared_chat_pilot_event(SDL_Event *event) { + (void)event; + return false; +} +static inline bool shared_chat_pilot_poll(SDL_Event *event) { + return SDL_PollEvent(event); +} +static inline bool shared_chat_pilot_text_owner(void) { + return false; +} +#endif +#endif diff --git a/client/src/tests/capture_privacy.c b/client/src/tests/capture_privacy.c index 5190a6215..472d0492b 100644 --- a/client/src/tests/capture_privacy.c +++ b/client/src/tests/capture_privacy.c @@ -42,5 +42,19 @@ int main(void) { capture_privacy_frame_begin(false); capture_privacy_frame_end(false); /* Failed clean redraw is conservative. */ CHECK(!capture_privacy_allowed(false)); + capture_privacy_frame_begin(false); + capture_privacy_frame_end(true); + capture_privacy_shared_chat_retained(true); + CHECK(!capture_privacy_allowed(false)); + capture_privacy_frame_begin(false); + capture_privacy_shared_chat_retained(false); /* Close during composition. */ + capture_privacy_frame_end(true); + CHECK(!capture_privacy_allowed(false)); + capture_privacy_frame_begin(false); + capture_privacy_frame_end(false); + CHECK(!capture_privacy_allowed(false)); + capture_privacy_frame_begin(false); + capture_privacy_frame_end(true); + CHECK(capture_privacy_allowed(false)); return 0; } diff --git a/client/src/tests/shared_chat_host.h b/client/src/tests/shared_chat_host.h new file mode 100644 index 000000000..769a17381 --- /dev/null +++ b/client/src/tests/shared_chat_host.h @@ -0,0 +1,122 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ +/* Narrow fake host for exercising the production adapter and real shared ABI. */ +#include +#include +#include +#include +#define arraysize(x) (sizeof(x) / sizeof((x)[0])) +#define ST_PLAY 1 +#define LABEL_ID 0 +#define MENU_ID 1 +#define INPUT_ID 2 +#define WIDGET_TEXTURE_TYPE_NONE 0 +#define FONT_ARIAL11 NULL +#define COLOR_WHITE "white" +typedef struct widgetdata { + struct widgetdata *env; + int x, y, w, h, type, save, required, moveable, resizeable, show, redraw, hidden, texture_type; + char *name, *id; + void (*draw_func)(struct widgetdata *); + void (*deinit_func)(struct widgetdata *); + void *menu_handle_func; +} widgetdata; +static widgetdata *cur_widget[3]; +static struct { + int pressed; +} keys[SDL_SCANCODE_COUNT]; +static struct { + int state; +} cpl = {ST_PLAY}; +static SDL_Window *ScreenWindow; +static unsigned int cancelled, drawn; +static bool drawing_ok = true, popup_open, classic_input; +static SDL_WindowFlags test_window_flags = SDL_WINDOW_INPUT_FOCUS; +static SDL_WindowFlags test_get_window_flags(SDL_Window *window) { + (void)window; + return test_window_flags; +} +#define SDL_GetWindowFlags test_get_window_flags +static void keybind_movement_focus_lost(void) { + ++cancelled; +} +static void keybind_movement_flush(void) {} +static void event_dragging_stop(void) {} +static void widgets_cancel_pointer_gestures(void) {} +static bool widgets_text_input_active(void) { + return classic_input; +} +static void *popup_get_head(void) { + return popup_open ? &popup_open : NULL; +} +static int widget_x(widgetdata *w) { + return w->x; +} +static int widget_y(widgetdata *w) { + return w->y; +} +static int widget_w(widgetdata *w) { + return w->w; +} +static int widget_h(widgetdata *w) { + return w->h; +} +static widgetdata *create_widget(int type) { + widgetdata *w = calloc(1, sizeof(*w)); + cur_widget[type] = w; + return w; +} +static widgetdata *get_widget_owner(int x, int y, void *start, void *end) { + (void)start; + (void)end; + widgetdata *w = cur_widget[LABEL_ID]; + return w && w->show && x >= w->x && x < w->x + w->w && y >= w->y && y < w->y + w->h ? w : NULL; +} +static char *xstrdup(const char *text) { + char *copy = malloc(strlen(text) + 1); + return strcpy(copy, text); +} +static void text_show_format_root(void *font, + int x, + int y, + const char *color, + int flags, + void *box, + const char *format, + ...) { + (void)font; + (void)x; + (void)y; + (void)color; + (void)flags; + (void)box; + (void)format; +} +static void gpu_renderer_surface_changed(SDL_Surface *surface) { + (void)surface; +} +static void gpu_renderer_invalidate_surface(SDL_Surface *surface) { + (void)surface; +} +static bool +gpu_renderer_draw_surface(SDL_Surface *surface, const SDL_Rect *source, const SDL_FRect *dest) { + (void)source; + (void)dest; + SDL_BlendMode mode = SDL_BLENDMODE_NONE; + if (!SDL_GetSurfaceBlendMode(surface, &mode) || mode != SDL_BLENDMODE_BLEND_PREMULTIPLIED) + abort(); + ++drawn; + return drawing_ok; +} +#define EVENT_IS_MOUSE(e) \ + ((e)->type >= SDL_EVENT_MOUSE_MOTION && (e)->type <= SDL_EVENT_MOUSE_WHEEL) +static int event_mouse_x(SDL_Event *e) { + return (int)(e->type == SDL_EVENT_MOUSE_WHEEL ? e->wheel.mouse_x + : e->type == SDL_EVENT_MOUSE_MOTION ? e->motion.x + : e->button.x); +} +static int event_mouse_y(SDL_Event *e) { + return (int)(e->type == SDL_EVENT_MOUSE_WHEEL ? e->wheel.mouse_y + : e->type == SDL_EVENT_MOUSE_MOTION ? e->motion.y + : e->button.y); +} diff --git a/client/src/tests/shared_chat_pilot.c b/client/src/tests/shared_chat_pilot.c new file mode 100644 index 000000000..238a09215 --- /dev/null +++ b/client/src/tests/shared_chat_pilot.c @@ -0,0 +1,349 @@ +/* Copyright 2026 The Atrinik Project + * SPDX-License-Identifier: GPL-2.0-or-later */ +#define ATRINIK_SHARED_CHAT_PILOT_TEST +#include "../client/shared_chat_pilot.c" +#define CHECK(test) \ + do { \ + if (!(test)) { \ + fprintf(stderr, "line %d: %s\n", __LINE__, #test); \ + return 1; \ + } \ + } while (0) + +static SDL_Event key_event(SDL_EventType type, SDL_Scancode physical, SDL_Keycode key) { + SDL_Event e = {0}; + e.type = type; + e.key.scancode = physical; + e.key.key = key; + return e; +} + +/* Mirror Event_PollInputDevice: Classic marks keys before the pilot filter. */ +static bool route_event(SDL_Event *event) { + if ((event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP) && + event->key.scancode > SDL_SCANCODE_UNKNOWN && event->key.scancode < SDL_SCANCODE_COUNT) { + keys[event->key.scancode].pressed = event->type == SDL_EVENT_KEY_DOWN; + } + return shared_chat_pilot_event(event); +} + +static int key_pair(SDL_Scancode physical, SDL_Keycode key, SDL_Keymod modifiers) { + SDL_Event event = key_event(SDL_EVENT_KEY_DOWN, physical, key); + event.key.mod = modifiers; + CHECK(route_event(&event)); + event.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&event)); + return 0; +} + +static int push_key_pair(SDL_Scancode physical, SDL_Keycode key, SDL_Keymod modifiers) { + SDL_Event event = key_event(SDL_EVENT_KEY_DOWN, physical, key); + event.key.mod = modifiers; + CHECK(SDL_PushEvent(&event)); + event.type = SDL_EVENT_KEY_UP; + CHECK(SDL_PushEvent(&event)); + return 0; +} + +static int push_text(const char *bytes, bool preedit) { + SDL_Event event = {0}; + event.type = preedit ? SDL_EVENT_TEXT_EDITING : SDL_EVENT_TEXT_INPUT; + if (preedit) + event.edit.text = bytes; + else + event.text.text = bytes; + event.common.timestamp = SDL_GetTicksNS(); + CHECK(SDL_PushEvent(&event)); + return 0; +} + +static unsigned int drain(void) { + SDL_Event event; + unsigned int count = 0; + while (shared_chat_pilot_poll(&event)) { + (void)route_event(&event); + ++count; + } + return count; +} + +static int expect_composer(const char *expected) { + uint8_t bytes[PILOT_BYTES]; + uint64_t count = 0; + CHECK(twui_copy_text(pilot.ui, TWUI_TEXT_COMPOSER, 0, bytes, sizeof(bytes), &count) == TWUI_OK); + CHECK(count == strlen(expected) && memcmp(bytes, expected, count) == 0); + return 0; +} + +static void destroy_test_widget(void) { + widgetdata *widget = pilot.widget; + destroy(widget); + cur_widget[LABEL_ID] = NULL; + free(widget->name); + free(widget); +} + +static int test_ordinary_editing_drain(void) { + CHECK(key_pair(SDL_SCANCODE_A, SDLK_A, SDL_KMOD_CTRL) == 0); + CHECK(key_pair(SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, 0) == 0); + CHECK(expect_composer("") == 0); + SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST); + CHECK(push_text("a", false) == 0); + CHECK(push_key_pair(SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, 0) == 0); + CHECK(drain() == 3); + CHECK(expect_composer("") == 0); + + CHECK(push_text("ab", false) == 0); + SDL_Event home = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_HOME, SDLK_HOME); + CHECK(SDL_PushEvent(&home)); + CHECK(drain() == 2); + uint32_t allowed = 0; + CHECK(twui_key_effect_allowed(pilot.ui, SDL_SCANCODE_HOME, &allowed) == TWUI_OK && allowed); + /* The text edit invalidates old geometry. Navigation becomes effective only + * after the same text has been displayed, even though the key is UI-owned. */ + draw(pilot.widget); + shared_chat_pilot_presented(true); + home.key.repeat = true; + CHECK(route_event(&home)); + home.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&home)); + twui_state current; + CHECK(state(¤t) && current.caret == 0); + CHECK(push_text("c", false) == 0); + CHECK(push_key_pair(SDL_SCANCODE_DELETE, SDLK_DELETE, 0) == 0); + CHECK(drain() == 3); + CHECK(expect_composer("cb") == 0); + + CHECK(push_text("x", false) == 0); + CHECK(push_key_pair(SDL_SCANCODE_A, SDLK_A, SDL_KMOD_CTRL) == 0); + CHECK(push_key_pair(SDL_SCANCODE_C, SDLK_C, SDL_KMOD_CTRL) == 0); + CHECK(drain() >= 5); /* SDL may additionally report clipboard updates. */ + CHECK(expect_composer("cxb") == 0); + char *copied = SDL_GetClipboardText(); + CHECK(copied != NULL && strcmp(copied, "cxb") == 0); + SDL_free(copied); + CHECK(SDL_SetClipboardText("paste")); + CHECK(push_text("!", false) == 0); + CHECK(push_key_pair(SDL_SCANCODE_V, SDLK_V, SDL_KMOD_CTRL) == 0); + CHECK(drain() >= 3); + CHECK(expect_composer("!paste") == 0); + + /* Actual composition still owns non-Enter editing/helper keys, including + * platforms that report the composition after its triggering key. */ + CHECK(push_key_pair(SDL_SCANCODE_BACKSPACE, SDLK_BACKSPACE, 0) == 0); + CHECK(push_text("preedit", true) == 0); + SDL_Event left = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_LEFT, SDLK_LEFT); + CHECK(SDL_PushEvent(&left)); + CHECK(drain() == 4); + CHECK(expect_composer("!paste") == 0); + CHECK(twui_key_effect_allowed(pilot.ui, SDL_SCANCODE_LEFT, &allowed) == TWUI_OK && !allowed); + CHECK(push_text("", true) == 0); + CHECK(drain() == 1); + left.key.repeat = true; + CHECK(route_event(&left)); + CHECK(twui_key_effect_allowed(pilot.ui, SDL_SCANCODE_LEFT, &allowed) == TWUI_OK && !allowed); + left.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&left)); + CHECK(!pilot.native_composing); + return 0; +} + +static int test_retired_keys(void) { + shared_chat_pilot_create(); + CHECK(pilot.widget != NULL && !pilot.failed); + CHECK(apply((twui_input){.kind = TWUI_SET_FOCUS, .value = 1}, NULL) == TWUI_OK); + SDL_Event held = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_F2, SDLK_F2); + CHECK(route_event(&held)); + CHECK(key_pair(SDL_SCANCODE_ESCAPE, SDLK_ESCAPE, 0) == 0); + CHECK(!shared_chat_pilot_text_owner()); + destroy_test_widget(); + held.key.repeat = true; + CHECK(route_event(&held)); + CHECK(!keys[SDL_SCANCODE_F2].pressed); + shared_chat_pilot_create(); + CHECK(pilot.widget != NULL && !pilot.failed); + CHECK(route_event(&held)); + CHECK(!keys[SDL_SCANCODE_F2].pressed); + held.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&held)); + held.type = SDL_EVENT_KEY_DOWN; + held.key.repeat = false; + CHECK(!route_event(&held)); + CHECK(keys[SDL_SCANCODE_F2].pressed); + held.type = SDL_EVENT_KEY_UP; + CHECK(!route_event(&held)); + + /* A reset during dispatch must finish the copied drain, including the + * release that ends host quarantine after the widget has disappeared. */ + CHECK(apply((twui_input){.kind = TWUI_SET_FOCUS, .value = 1}, NULL) == TWUI_OK); + held = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_F3, SDLK_F3); + CHECK(route_event(&held)); + SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST); + held.key.repeat = true; + CHECK(SDL_PushEvent(&held)); + held.type = SDL_EVENT_KEY_UP; + CHECK(SDL_PushEvent(&held)); + CHECK(shared_chat_pilot_poll(&held) && held.type == SDL_EVENT_KEY_DOWN); + CHECK(route_event(&held)); + destroy_test_widget(); + CHECK(shared_chat_pilot_poll(&held) && held.type == SDL_EVENT_KEY_UP && + held.key.scancode == SDL_SCANCODE_F3); + CHECK(route_event(&held)); + CHECK(!retired_keys[SDL_SCANCODE_F3]); + held.type = SDL_EVENT_KEY_DOWN; + held.key.repeat = false; + CHECK(!route_event(&held)); + held.type = SDL_EVENT_KEY_UP; + CHECK(!route_event(&held)); + return 0; +} + +int main(void) { + CHECK(SDL_Init(SDL_INIT_VIDEO)); + ScreenWindow = SDL_CreateWindow("shared chat adapter test", 800, 600, 0); + CHECK(ScreenWindow != NULL); + CHECK(SDL_setenv_unsafe("ATRINIK_SHARED_CHAT_PILOT", "0", 1) == 0); + shared_chat_pilot_create(); + CHECK(pilot.widget == NULL); + CHECK(SDL_setenv_unsafe("ATRINIK_SHARED_CHAT_PILOT", "1", 1) == 0); + shared_chat_pilot_create(); + CHECK(pilot.widget && !pilot.failed); + CHECK(!capture_privacy_allowed(false)); + SDL_Event key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_W, SDLK_W); + keys[SDL_SCANCODE_W].pressed = 1; + CHECK(!route_event(&key)); + unsigned int before = cancelled; + CHECK(apply((twui_input){.kind = TWUI_SET_FOCUS, .value = 1}, NULL) == TWUI_OK); + CHECK(cancelled > before && !keys[SDL_SCANCODE_W].pressed); + key.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&key)); + CHECK(text(TWUI_COMMIT, "private /local", 14, 0, 0) == TWUI_OK); + draw(pilot.widget); + CHECK(pilot.pending && drawn && !pilot.failed); + twui_stamp stamp = {.struct_size = sizeof(stamp)}; + CHECK(twui_get_displayed(pilot.ui, &stamp) == TWUI_EMPTY); + shared_chat_pilot_presented(false); + CHECK(!pilot.pending); + CHECK(twui_get_displayed(pilot.ui, &stamp) == TWUI_EMPTY); + draw(pilot.widget); + shared_chat_pilot_presented(true); + CHECK(twui_get_displayed(pilot.ui, &stamp) == TWUI_OK); + pilot.widget->x++; + shared_chat_pilot_sync(); + CHECK(twui_get_displayed(pilot.ui, &stamp) == TWUI_EMPTY); + draw(pilot.widget); + shared_chat_pilot_presented(true); + /* Same-drain text/IME fences both physical Enter keys independently. */ + pilot.batch_ime = true; + key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_RETURN, SDLK_RETURN); + CHECK(route_event(&key)); + key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_KP_ENTER, SDLK_KP_ENTER); + CHECK(route_event(&key)); + twui_action action = {.struct_size = sizeof(action)}; + CHECK(twui_peek_action(pilot.ui, &action) == TWUI_EMPTY); + key.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&key)); + pilot.batch_ime = false; + key.type = SDL_EVENT_KEY_DOWN; + CHECK(route_event(&key)); + CHECK(twui_peek_action(pilot.ui, &action) == TWUI_EMPTY); /* Return remains fenced. */ + key.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&key)); + key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); + CHECK(route_event(&key)); + key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_KP_ENTER, SDLK_KP_ENTER); + CHECK(route_event(&key)); + CHECK(twui_peek_action(pilot.ui, &action) == TWUI_OK); + accept_actions(); + uint64_t accepted = pilot.accepted; + accept_actions(); + CHECK(accepted && accepted == pilot.accepted); + twui_state current; + CHECK(state(¤t) && current.history_messages == 2); + /* Modal enter+exit retains its epoch barrier even if final state allows world. */ + before = cancelled; + popup_open = true; + shared_chat_pilot_sync(); + SDL_Event popup_key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_A, SDLK_A); + CHECK(!route_event(&popup_key)); + popup_key.type = SDL_EVENT_KEY_UP; + CHECK(!route_event(&popup_key)); + popup_open = false; + shared_chat_pilot_sync(); + CHECK(cancelled > before); + key.type = SDL_EVENT_KEY_UP; + CHECK(route_event(&key)); + key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); + (void)route_event(&key); + CHECK(apply((twui_input){.kind = TWUI_SET_FOCUS, .value = 1}, NULL) == TWUI_OK); + /* Focus handoff retires late native text even if it reaches the next owner. */ + classic_input = true; + shared_chat_pilot_sync(); + CHECK(!shared_chat_pilot_text_owner()); + SDL_Event late = {0}; + late.type = SDL_EVENT_TEXT_INPUT; + late.text.text = "late"; + late.text.timestamp = pilot.session_start - 1; + CHECK(route_event(&late)); + classic_input = false; + shared_chat_pilot_sync(); + CHECK(apply((twui_input){.kind = TWUI_SET_FOCUS, .value = 1}, NULL) == TWUI_OK); + SDL_Event focus = {0}; + focus.type = SDL_EVENT_WINDOW_FOCUS_LOST; + before = cancelled; + CHECK(!route_event(&focus)); + focus.type = SDL_EVENT_WINDOW_FOCUS_GAINED; + CHECK(!route_event(&focus)); + CHECK(cancelled > before && !shared_chat_pilot_text_owner()); + CHECK(apply((twui_input){.kind = TWUI_SET_FOCUS, .value = 1}, NULL) == TWUI_OK); + /* Actual copied drain sees text even when Enter precedes the commit. */ + SDL_FlushEvents(SDL_EVENT_FIRST, SDL_EVENT_LAST); + key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_RETURN, SDLK_RETURN); + SDL_Event commit = {0}; + commit.type = SDL_EVENT_TEXT_INPUT; + commit.text.text = "a"; + commit.text.timestamp = SDL_GetTicksNS(); + CHECK(SDL_PushEvent(&key)); + CHECK(SDL_PushEvent(&commit)); + unsigned int drained = 0; + while (shared_chat_pilot_poll(&key)) { + (void)route_event(&key); + ++drained; + } + CHECK(drained == 2 && pilot.batch_ime == false); + CHECK(twui_peek_action(pilot.ui, &action) == TWUI_EMPTY); + key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); + CHECK(route_event(&key)); + CHECK(test_ordinary_editing_drain() == 0); + /* Full output queue retains draft; cancellation remains available. */ + for (unsigned int i = 0; i < 8; ++i) { + CHECK(text(TWUI_COMMIT, "x", 1, 0, 0) == TWUI_OK); + CHECK(apply((twui_input){.kind = TWUI_SUBMIT}, NULL) == TWUI_OK); + } + CHECK(text(TWUI_COMMIT, "retained", 8, 0, 0) == TWUI_OK); + CHECK(apply((twui_input){.kind = TWUI_SUBMIT}, NULL) == TWUI_QUEUE_FULL); + CHECK(state(¤t) && current.composer_bytes == 8); + draw(pilot.widget); + shared_chat_pilot_presented(true); + drawing_ok = false; + draw(pilot.widget); + CHECK(!pilot.pending); + CHECK(twui_get_displayed(pilot.ui, &stamp) == TWUI_EMPTY); + drawing_ok = true; + pilot.widget->show = 0; + shared_chat_pilot_sync(); + CHECK(state(¤t) && current.suspended && !current.focused); + CHECK(!capture_privacy_allowed(false)); + destroy_test_widget(); + CHECK(!capture_privacy_allowed(false)); + capture_privacy_frame_begin(false); + capture_privacy_frame_end(false); + CHECK(!capture_privacy_allowed(false)); + capture_privacy_frame_begin(false); + capture_privacy_frame_end(true); + CHECK(capture_privacy_allowed(false)); + CHECK(test_retired_keys() == 0); + SDL_DestroyWindow(ScreenWindow); + SDL_Quit(); + return 0; +} diff --git a/client/tools/shared_chat_fonts.py b/client/tools/shared_chat_fonts.py new file mode 100644 index 000000000..eb3f23b5e --- /dev/null +++ b/client/tools/shared_chat_fonts.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +# Copyright 2026 The Atrinik Project +# SPDX-License-Identifier: GPL-2.0-or-later +"""Validate an explicitly supplied font manifest and emit private build inputs.""" +import hashlib +import json +import pathlib +import sys + +manifest = pathlib.Path(sys.argv[1]).resolve(strict=True) +data = json.loads(manifest.read_text()) +fonts = data["fonts"] +if data["schema"] != 1 or not 1 <= len(fonts) <= 32: + raise ValueError("unsupported font manifest") +rows = [] +total = 0 +for font in fonts: + family = font["family"] + if not isinstance(family, str) or not 1 <= len(family.encode()) <= 128 or any(ord(c) < 32 for c in family): + raise ValueError("invalid family") + path = (manifest.parent / font["path"]).resolve(strict=True) + path.relative_to(manifest.parent) + size = path.stat().st_size + total += size + if not 0 < size <= 32 * 1024 * 1024 or total > 64 * 1024 * 1024: + raise ValueError("font budget exceeded") + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if size != font["bytes"] or digest != font["sha256"]: + raise ValueError("font identity mismatch") + rows.append("{%s, %s, %d, {%s}}" % (json.dumps(family), json.dumps(str(path)), size, + ",".join("0x" + digest[i:i+2] for i in range(0, 64, 2)))) +pathlib.Path(sys.argv[2]).write_text("/* Generated from an explicit verified manifest. */\n" + "static const struct pilot_font_file { const char *family, *path; size_t size; uint8_t sha256[32]; } pilot_fonts[] = {\n" + + ",\n".join(rows) + "\n};\n")