From 71fd77bb7db867bd09d1a412545f68695a57ecda Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 31 Oct 2026 06:15:02 -0500 Subject: [PATCH 1/3] feat(client): add opt-in shared local chat pilot --- client/AGENTS.md | 4 + client/CMakeLists.txt | 1 + client/INSTALL | 4 + client/cmake/SharedChatPilot.cmake | 38 ++ client/doc/SHARED_CHAT_PILOT.md | 78 +++ client/src/client/capture_privacy.c | 12 +- client/src/client/main.c | 4 + client/src/client/shared_chat_pilot.c | 637 ++++++++++++++++++++++++ client/src/events/event.c | 10 +- client/src/gui/toolkit/include/widget.h | 4 + client/src/gui/toolkit/popup.c | 3 + client/src/gui/toolkit/text_input.c | 9 +- client/src/gui/toolkit/widget.c | 23 + client/src/include/capture_privacy.h | 2 + client/src/include/shared_chat_pilot.h | 33 ++ client/src/tests/capture_privacy.c | 14 + client/src/tests/shared_chat_host.h | 122 +++++ client/src/tests/shared_chat_pilot.c | 170 +++++++ client/tools/shared_chat_fonts.py | 34 ++ 19 files changed, 1197 insertions(+), 5 deletions(-) create mode 100644 client/cmake/SharedChatPilot.cmake create mode 100644 client/doc/SHARED_CHAT_PILOT.md create mode 100644 client/src/client/shared_chat_pilot.c create mode 100644 client/src/include/shared_chat_pilot.h create mode 100644 client/src/tests/shared_chat_host.h create mode 100644 client/src/tests/shared_chat_pilot.c create mode 100644 client/tools/shared_chat_fonts.py 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..66189dd51 --- /dev/null +++ b/client/doc/SHARED_CHAT_PILOT.md @@ -0,0 +1,78 @@ +# 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. Control-epoch changes cancel held Classic movement and pointer +gestures, even when the final shared state allows world input. 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..3198fcfbb --- /dev/null +++ b/client/src/client/shared_chat_pilot.c @@ -0,0 +1,637 @@ +/* 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 +#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, attachment, bounds_valid; + int x, y, w, h; +} pilot; + +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(); + 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; + 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) { + 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 keyboard = event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP; + 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 consumed = 1; + twui_status status = + apply((twui_input){.kind = down ? TWUI_KEY_DOWN : TWUI_KEY_UP, + .value = pilot.pressed[physical] + ? pilot.pressed[physical] + : key_value(event->key.key, event->key.mod), + .physical_key = physical, + .key_repeat = down && event->key.repeat, + .platform_composing = down && 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); + 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; + 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; + 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; + } + ++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..0bd87ef32 --- /dev/null +++ b/client/src/tests/shared_chat_pilot.c @@ -0,0 +1,170 @@ +/* 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; +} + +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(!shared_chat_pilot_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(shared_chat_pilot_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(shared_chat_pilot_event(&key)); + key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_KP_ENTER, SDLK_KP_ENTER); + CHECK(shared_chat_pilot_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(shared_chat_pilot_event(&key)); + pilot.batch_ime = false; + key.type = SDL_EVENT_KEY_DOWN; + CHECK(shared_chat_pilot_event(&key)); + CHECK(twui_peek_action(pilot.ui, &action) == TWUI_EMPTY); /* Return remains fenced. */ + key.type = SDL_EVENT_KEY_UP; + CHECK(shared_chat_pilot_event(&key)); + key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); + CHECK(shared_chat_pilot_event(&key)); + key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_KP_ENTER, SDLK_KP_ENTER); + CHECK(shared_chat_pilot_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(!shared_chat_pilot_event(&popup_key)); + popup_key.type = SDL_EVENT_KEY_UP; + CHECK(!shared_chat_pilot_event(&popup_key)); + popup_open = false; + shared_chat_pilot_sync(); + CHECK(cancelled > before); + key.type = SDL_EVENT_KEY_UP; + CHECK(shared_chat_pilot_event(&key)); + key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); + (void)shared_chat_pilot_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(shared_chat_pilot_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(!shared_chat_pilot_event(&focus)); + focus.type = SDL_EVENT_WINDOW_FOCUS_GAINED; + CHECK(!shared_chat_pilot_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)shared_chat_pilot_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(shared_chat_pilot_event(&key)); + /* 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)); + widgetdata *widget = pilot.widget; + destroy(widget); + free(widget->name); + free(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)); + 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") From c1f5695948aab46e3ff55d2397f1c42deb9b1316 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 31 Oct 2026 06:21:25 -0500 Subject: [PATCH 2/3] fix(client): preserve shared chat input ownership barriers --- client/doc/SHARED_CHAT_PILOT.md | 8 +- client/src/client/shared_chat_pilot.c | 47 +++++- client/src/tests/shared_chat_pilot.c | 223 +++++++++++++++++++++++--- 3 files changed, 246 insertions(+), 32 deletions(-) diff --git a/client/doc/SHARED_CHAT_PILOT.md b/client/doc/SHARED_CHAT_PILOT.md index 66189dd51..5eafcefe6 100644 --- a/client/doc/SHARED_CHAT_PILOT.md +++ b/client/doc/SHARED_CHAT_PILOT.md @@ -50,8 +50,12 @@ they close, click the pilot to focus again. 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. Control-epoch changes cancel held Classic movement and pointer -gestures, even when the final shared state allows world input. Native text +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. diff --git a/client/src/client/shared_chat_pilot.c b/client/src/client/shared_chat_pilot.c index 3198fcfbb..cc7b2fb21 100644 --- a/client/src/client/shared_chat_pilot.c +++ b/client/src/client/shared_chat_pilot.c @@ -36,10 +36,15 @@ static struct { uint32_t error_status; uint32_t pressed[SDL_SCANCODE_COUNT]; bool owned[SDL_SCANCODE_COUNT]; - bool failed, pending, text_owner, blocked, batch_ime, attachment, bounds_valid; + 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) @@ -70,6 +75,7 @@ static bool state(twui_state *out) { static void native_barrier(void) { pilot.session_start = SDL_GetTicksNS(); + pilot.native_composing = false; if (!SDL_StopTextInput(ScreenWindow) || !SDL_StartTextInput(ScreenWindow)) fail_closed(); } @@ -138,8 +144,8 @@ void shared_chat_pilot_invalidate(void) { } static bool visible(void) { - return pilot.widget != NULL && pilot.widget->env == NULL && pilot.widget->show && !pilot.widget->hidden && - cpl.state == ST_PLAY; + return pilot.widget != NULL && pilot.widget->env == NULL && pilot.widget->show && + !pilot.widget->hidden && cpl.state == ST_PLAY; } void shared_chat_pilot_sync(void) { @@ -322,6 +328,9 @@ static void destroy(widgetdata *widget) { 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); @@ -460,6 +469,15 @@ static void key_effect(const SDL_KeyboardEvent *event) { } 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(); @@ -483,7 +501,6 @@ bool shared_chat_pilot_event(SDL_Event *event) { default: break; } - bool keyboard = event->type == SDL_EVENT_KEY_DOWN || event->type == SDL_EVENT_KEY_UP; 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; @@ -514,15 +531,19 @@ bool shared_chat_pilot_event(SDL_Event *event) { 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 = pilot.pressed[physical] - ? pilot.pressed[physical] - : key_value(event->key.key, event->key.mod), + .value = value, .physical_key = physical, .key_repeat = down && event->key.repeat, - .platform_composing = down && pilot.batch_ime}, + /* 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; @@ -541,6 +562,7 @@ bool shared_chat_pilot_event(SDL_Event *event) { 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); @@ -604,12 +626,19 @@ 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 @@ -624,6 +653,8 @@ bool shared_chat_pilot_poll(SDL_Event *event) { else item->edit.text = strings[count]; pilot.batch_ime = true; + if (item->type == SDL_EVENT_TEXT_EDITING) + pilot.batch_composition = true; } ++count; } diff --git a/client/src/tests/shared_chat_pilot.c b/client/src/tests/shared_chat_pilot.c index 0bd87ef32..238a09215 100644 --- a/client/src/tests/shared_chat_pilot.c +++ b/client/src/tests/shared_chat_pilot.c @@ -18,6 +18,186 @@ static SDL_Event key_event(SDL_EventType type, SDL_Scancode physical, SDL_Keycod 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); @@ -31,12 +211,12 @@ int main(void) { 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(!shared_chat_pilot_event(&key)); + 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(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); CHECK(text(TWUI_COMMIT, "private /local", 14, 0, 0) == TWUI_OK); draw(pilot.widget); CHECK(pilot.pending && drawn && !pilot.failed); @@ -56,23 +236,23 @@ int main(void) { /* 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(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_KP_ENTER, SDLK_KP_ENTER); - CHECK(shared_chat_pilot_event(&key)); + 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(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); pilot.batch_ime = false; key.type = SDL_EVENT_KEY_DOWN; - CHECK(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); CHECK(twui_peek_action(pilot.ui, &action) == TWUI_EMPTY); /* Return remains fenced. */ key.type = SDL_EVENT_KEY_UP; - CHECK(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); - CHECK(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_KP_ENTER, SDLK_KP_ENTER); - CHECK(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); CHECK(twui_peek_action(pilot.ui, &action) == TWUI_OK); accept_actions(); uint64_t accepted = pilot.accepted; @@ -85,16 +265,16 @@ int main(void) { popup_open = true; shared_chat_pilot_sync(); SDL_Event popup_key = key_event(SDL_EVENT_KEY_DOWN, SDL_SCANCODE_A, SDLK_A); - CHECK(!shared_chat_pilot_event(&popup_key)); + CHECK(!route_event(&popup_key)); popup_key.type = SDL_EVENT_KEY_UP; - CHECK(!shared_chat_pilot_event(&popup_key)); + CHECK(!route_event(&popup_key)); popup_open = false; shared_chat_pilot_sync(); CHECK(cancelled > before); key.type = SDL_EVENT_KEY_UP; - CHECK(shared_chat_pilot_event(&key)); + CHECK(route_event(&key)); key = key_event(SDL_EVENT_KEY_UP, SDL_SCANCODE_RETURN, SDLK_RETURN); - (void)shared_chat_pilot_event(&key); + (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; @@ -104,16 +284,16 @@ int main(void) { late.type = SDL_EVENT_TEXT_INPUT; late.text.text = "late"; late.text.timestamp = pilot.session_start - 1; - CHECK(shared_chat_pilot_event(&late)); + 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(!shared_chat_pilot_event(&focus)); + CHECK(!route_event(&focus)); focus.type = SDL_EVENT_WINDOW_FOCUS_GAINED; - CHECK(!shared_chat_pilot_event(&focus)); + 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. */ @@ -127,13 +307,14 @@ int main(void) { CHECK(SDL_PushEvent(&commit)); unsigned int drained = 0; while (shared_chat_pilot_poll(&key)) { - (void)shared_chat_pilot_event(&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(shared_chat_pilot_event(&key)); + 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); @@ -153,10 +334,7 @@ int main(void) { shared_chat_pilot_sync(); CHECK(state(¤t) && current.suspended && !current.focused); CHECK(!capture_privacy_allowed(false)); - widgetdata *widget = pilot.widget; - destroy(widget); - free(widget->name); - free(widget); + destroy_test_widget(); CHECK(!capture_privacy_allowed(false)); capture_privacy_frame_begin(false); capture_privacy_frame_end(false); @@ -164,6 +342,7 @@ int main(void) { 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; From 16e3c21b51c42e0c4ad158fdb38f2367517b2f78 Mon Sep 17 00:00:00 2001 From: Zoey Rose Date: Sat, 31 Oct 2026 06:26:08 -0500 Subject: [PATCH 3/3] fix(client): include shared chat allocation declaration --- client/src/client/shared_chat_pilot.c | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/client/shared_chat_pilot.c b/client/src/client/shared_chat_pilot.c index cc7b2fb21..b7ae047ca 100644 --- a/client/src/client/shared_chat_pilot.c +++ b/client/src/client/shared_chat_pilot.c @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #endif