diff --git a/docs/NATIVE_TERMINAL_SPEC.md b/docs/NATIVE_TERMINAL_SPEC.md new file mode 100644 index 0000000..e165a34 --- /dev/null +++ b/docs/NATIVE_TERMINAL_SPEC.md @@ -0,0 +1,75 @@ +# Real integrated terminal + +## Decisions + +| Decision | Source | Status | +| --- | --- | --- | +| Real terminal on macOS and Windows, no separate command entry field | USER | CONFIRMED | +| macOS zsh with PTY and a single terminal input/output surface | USER | CONFIRMED | +| Windows ConPTY, retain existing cmd.exe default | USER | CONFIRMED | +| VT screen/keyboard processing through SHA-256-pinned MIT libvterm 0.3.3 release archive | IMPLEMENTATION | CONFIRMED | +| One shell per workspace view, lazy startup on terminal activation | USER | CONFIRMED | +| Use selected workspace as startup directory; home if no workspace | USER | CONFIRMED | +| 2,000 scrollback lines, 1 MiB queued input | USER | CONFIRMED | +| Screen limit: 512 rows by 1024 columns | AGENT_PARAMETER | ASSUMED | +| Preserve build/debug/Git subprocess APIs and present their logs as output, not shell output | IMPLEMENTATION | CONFIRMED | +| Do not silently fall back to pipe input on unsupported ConPTY systems | USER | CONFIRMED | +| macOS production shell `/bin/zsh -l -i`; tests use `-f -i` | USER | CONFIRMED | +| Retain live shell while changing or hiding panels; resize never restarts | USER | CONFIRMED | +| Reactivating Terminal after shell exit starts a new shell; Start also restarts | DELEGATED | ASSUMED | +| Native wheel scroll and selection; Select All selects visible grid | DELEGATED | ASSUMED | +| Clipboard line endings normalize CRLF/LF to Return bytes | IMPLEMENTATION | CONFIRMED | +| Static underline cursor; IME composition text is drawn at the cursor | IMPLEMENTATION | CONFIRMED | +| No OSC52 clipboard writes or shell-triggered external actions | USER | CONFIRMED | + +## Shared base and category + +One feature category: real integrated terminal, branch `feature/native-terminal`. +The shared base is the public screen/session/key/snapshot contract in terminal.h, +the existing status/error conventions and reproducibly built libvterm target. +Validate this base before implementing its native consumers. + +## Implementation units + +1. Shared screen model: VT parsing, Unicode cells, colors, cursor, alternate + screen, bounded history, snapshots, key/paste encoding and regression tests. +2. Session transports: macOS PTY controlling terminal, Windows ConPTY, async + input/output, resize, lifecycle/termination and real shell tests. +3. Native terminal surfaces: direct text/key input, copy/paste, selection, resize, + scrollback, cursor and ANSI rendering; remove separate shell entry/send UI. +4. Cross-platform integration, lifetime/error tests, packaging licenses and + independent review; one PR targeting dev. No automatic merge is requested. + +### Implementation state + +| Unit | State | Evidence | +| --- | --- | --- | +| Screen model | COMPLETE | VT regression executable; C ABI preserved | +| PTY/ConPTY sessions | IMPLEMENTED; Windows runtime validation pending | macOS real-shell tests; Windows CI initially exposed redirected standard-handle inheritance, fixed with null `STARTF_USESTDHANDLES` | +| Native grid integration | IMPLEMENTED; final independent review pending | macOS grid/keyboard runtime and workspace/editor layout tests; Windows grid runtime test included for CI | +| Integration/license/audit | Local validation COMPLETE; parent reviews and Windows CI pending | full macOS build and 40/40 CTest pass; libvterm license verified in built app; one draft PR managed by parent | + +Private macOS child cleanup obtains Mach audit tokens and signals through +`proc_signal_with_audittoken`, which validates PID generations in the kernel. +Stop first terminates captured descendants, sends shell SIGHUP, and escalates +the still-owned root after 200 ms if needed. Readers poll at most 50 ms per wait; +post-root-exit drain is capped at 500 ms for descendants retaining the slave. +Output EOF alone never kills a live shell: it waits for the actual exit status. +Windows owns a kill-on-close Job Object, cancels synchronous writer I/O, closes +ConPTY outside the model/pseudoconsole locks, and drains until actual pipe EOF. +The test-only `AXYNE_TERMINAL_TRACE` switch emits API status codes and byte counts, +never child output, environment contents or command strings. + +## Required audit + +Private terminal implementation uses C++17 standard mutexes, worker threads and +RAII behind the frozen C ABI; existing C17 modules and native C/Objective-C +adapters are unchanged. This is an IMPLEMENTATION choice. Screen bounds are +512 rows and 1024 columns (AGENT_PARAMETER / ASSUMED). Manual model operations +never notify; reader notifications run after releasing model and queue locks. + +Prove isatty/prompt, direct input, Tab/history, Ctrl+C without killing the shell, +screen cursor/ANSI semantics, resizing, shell exit/stop/restart and teardown. +Keep actual terminal output free of `[stderr]` prefixes. Unsupported Windows +versions must report a clear error. Keep runtime UTF-8/CJK and clipboard input +separate from app/editor shortcuts and preserve ordinary build/Git/debug output. diff --git a/include/axyne/terminal.h b/include/axyne/terminal.h new file mode 100644 index 0000000..ce8b506 --- /dev/null +++ b/include/axyne/terminal.h @@ -0,0 +1,90 @@ +#ifndef AXYNE_TERMINAL_H +#define AXYNE_TERMINAL_H + +#include +#include +#include "axyne/status.h" + +#ifdef __cplusplus +extern "C" { +#endif + +typedef struct AxyneTerminal AxyneTerminal; +typedef void (*AxyneTerminalChangedFn)(AxyneTerminal *, void *); +typedef void (*AxyneTerminalExitFn)(AxyneTerminal *, int, void *); + +typedef struct AxyneTerminalSpec { + const char *executable; + const char *const *arguments; + size_t argument_count; + const char *working_directory; + AxyneTerminalChangedFn on_changed; + AxyneTerminalExitFn on_exit; + void *user_data; +} AxyneTerminalSpec; + +typedef enum AxyneTerminalKey { + AXYNE_TERMINAL_ENTER = 1, AXYNE_TERMINAL_TAB, + AXYNE_TERMINAL_BACKSPACE, AXYNE_TERMINAL_ESCAPE, + AXYNE_TERMINAL_UP, AXYNE_TERMINAL_DOWN, + AXYNE_TERMINAL_LEFT, AXYNE_TERMINAL_RIGHT, + AXYNE_TERMINAL_INSERT, AXYNE_TERMINAL_DELETE, + AXYNE_TERMINAL_HOME, AXYNE_TERMINAL_END, + AXYNE_TERMINAL_PAGEUP, AXYNE_TERMINAL_PAGEDOWN, + AXYNE_TERMINAL_F1 = 257 +} AxyneTerminalKey; +enum { AXYNE_TERMINAL_SHIFT = 1, AXYNE_TERMINAL_ALT = 2, + AXYNE_TERMINAL_CTRL = 4 }; +enum { AXYNE_TERMINAL_BOLD = 1, AXYNE_TERMINAL_ITALIC = 2, + AXYNE_TERMINAL_UNDERLINE = 4, AXYNE_TERMINAL_REVERSE = 8 }; + +typedef struct AxyneTerminalCell { + uint32_t chars[6]; + uint32_t foreground, background; /* RGB, default palette already resolved */ + unsigned attributes; + int width; /* 0: wide-character continuation; 1 or 2: leading cell */ +} AxyneTerminalCell; + +typedef struct AxyneTerminalSnapshot { + int rows, columns; + int cursor_row, cursor_column, cursor_visible; + int alternate_screen; + int scroll_offset; + size_t history_lines; + AxyneTerminalCell *cells; +} AxyneTerminalSnapshot; + +/* The model supports deterministic VT tests without starting a child. + * Feed/snapshot/key/resize are internally synchronized. Callbacks notify only; + * native adapters marshal repaint/exit to their UI thread. Session output + * changes and exit callbacks run serially on the session reader thread, with + * no model locks held. Exit is delivered once, after output is drained; no + * further session notifications follow it. Callbacks may begin before start + * returns, so create/initialize user_data before calling start. They may query + * snapshots or enqueue input but must not start or release a session. Manual + * model operations (feed without a child, resize, scroll, clear) do not deliver + * notifications: callers schedule their own repaint. Inputs are copied by + * start. user_data stays valid until release finishes. Release terminates the + * session and joins workers; no callbacks can arrive after it returns. */ +AxyneStatus axyne_terminal_create(int rows, int columns, size_t history_limit, + AxyneTerminal **out, AxyneError *error); +AxyneStatus axyne_terminal_start(AxyneTerminal *, const AxyneTerminalSpec *, AxyneError *); +AxyneStatus axyne_terminal_write(AxyneTerminal *, const char *, size_t, AxyneError *); +AxyneStatus axyne_terminal_key(AxyneTerminal *, AxyneTerminalKey, unsigned, AxyneError *); +AxyneStatus axyne_terminal_unichar(AxyneTerminal *, uint32_t, unsigned, AxyneError *); +AxyneStatus axyne_terminal_paste(AxyneTerminal *, const char *, size_t, AxyneError *); +AxyneStatus axyne_terminal_resize(AxyneTerminal *, int rows, int columns, AxyneError *); +void axyne_terminal_feed(AxyneTerminal *, const char *, size_t); +void axyne_terminal_scroll(AxyneTerminal *, int lines); +void axyne_terminal_clear(AxyneTerminal *); +void axyne_terminal_set_colors(AxyneTerminal *, uint32_t foreground, uint32_t background); +int axyne_terminal_running(AxyneTerminal *); +AxyneStatus axyne_terminal_snapshot(AxyneTerminal *, AxyneTerminalSnapshot *, AxyneError *); +void axyne_terminal_snapshot_free(AxyneTerminalSnapshot *); +AxyneStatus axyne_terminal_terminate(AxyneTerminal *, AxyneError *); +void axyne_terminal_release(AxyneTerminal *); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/features/terminal/CMakeLists.txt b/src/features/terminal/CMakeLists.txt new file mode 100644 index 0000000..0e41c96 --- /dev/null +++ b/src/features/terminal/CMakeLists.txt @@ -0,0 +1,50 @@ +# Upstream 0.3.3 release includes generated encoding tables, unlike Git tags. +# Pin its complete archive so Windows needs no Perl/source-generation tools. +enable_language(CXX) +FetchContent_Declare(axyne_vterm_source + URL https://www.leonerd.org.uk/code/libvterm/libvterm-0.3.3.tar.gz + DOWNLOAD_EXTRACT_TIMESTAMP TRUE + URL_HASH SHA256=09156f43dd2128bd347cbeebe50d9a571d32c64e0cf18d211197946aff7226e0) +FetchContent_GetProperties(axyne_vterm_source) +if(NOT axyne_vterm_source_POPULATED) + cmake_policy(PUSH) + if(POLICY CMP0169) + cmake_policy(SET CMP0169 OLD) + endif() + FetchContent_Populate(axyne_vterm_source) + cmake_policy(POP) +endif() +add_library(axyne_terminal STATIC "${CMAKE_CURRENT_LIST_DIR}/terminal.cpp") +target_include_directories(axyne_terminal PUBLIC "${PROJECT_SOURCE_DIR}/include") +target_link_libraries(axyne_terminal PRIVATE axyne_vterm) +set_target_properties(axyne_terminal PROPERTIES CXX_STANDARD 17 CXX_STANDARD_REQUIRED ON) +if(WIN32) + target_sources(axyne_terminal PRIVATE "${CMAKE_CURRENT_LIST_DIR}/terminal_windows.cpp") +else() + find_package(Threads REQUIRED) + target_sources(axyne_terminal PRIVATE "${CMAKE_CURRENT_LIST_DIR}/terminal_posix.cpp") + target_link_libraries(axyne_terminal PRIVATE Threads::Threads) +endif() +if(TARGET axyne_ui) + target_link_libraries(axyne_ui PRIVATE axyne_terminal) +endif() +file(GLOB AXYNE_VTERM_SOURCES CONFIGURE_DEPENDS "${axyne_vterm_source_SOURCE_DIR}/src/*.c") +add_library(axyne_vterm STATIC ${AXYNE_VTERM_SOURCES}) +target_include_directories(axyne_vterm SYSTEM PUBLIC "${axyne_vterm_source_SOURCE_DIR}/include") +target_include_directories(axyne_vterm PRIVATE "${axyne_vterm_source_SOURCE_DIR}/src") +if(MSVC) + target_compile_options(axyne_vterm PRIVATE /utf-8) +endif() +if(APPLE) + add_custom_command(TARGET axyne POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${axyne_vterm_source_SOURCE_DIR}/LICENSE" + "$/Resources/libvterm-LICENSE.txt") + install(FILES "${axyne_vterm_source_SOURCE_DIR}/LICENSE" + DESTINATION "${AXYNE_MACOS_BUNDLE_NAME}/Contents/Resources" + RENAME libvterm-LICENSE.txt CONFIGURATIONS Release) +elseif(WIN32) + add_custom_command(TARGET axyne POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_if_different + "${axyne_vterm_source_SOURCE_DIR}/LICENSE" "$/libvterm-LICENSE.txt") + install(FILES "${axyne_vterm_source_SOURCE_DIR}/LICENSE" + DESTINATION . RENAME libvterm-LICENSE.txt CONFIGURATIONS Release) +endif() diff --git a/src/features/terminal/terminal.cpp b/src/features/terminal/terminal.cpp new file mode 100644 index 0000000..4c72d1d --- /dev/null +++ b/src/features/terminal/terminal.cpp @@ -0,0 +1,217 @@ +#include "terminal_internal.hpp" +#include +#include +#include +#include +#include + +static constexpr size_t input_limit = 1024 * 1024; +static bool dimensions(int rows, int cols) { + return rows >= 1 && rows <= 512 && cols >= 1 && cols <= 1024; +} +AxyneStatus terminal_result(AxyneError *e, AxyneStatus s, const char *message) { + if (e) { e->code = s; std::snprintf(e->message, sizeof e->message, "%s", message); } + return s; +} +AxyneStatus terminal_enqueue(AxyneTerminal *t, const char *data, size_t len, AxyneError *e) { + std::lock_guard lock(t->queue_mutex); + if (!t->running || t->stopping) + return terminal_result(e, AXYNE_STATUS_NOT_FOUND, "Terminal session is not running"); + if (len > input_limit - t->queued_bytes) + return terminal_result(e, AXYNE_STATUS_BUSY, "Terminal input queue is full (1 MiB)"); + try { if (len) t->writes.emplace_back(data, len); } + catch (const std::bad_alloc &) { return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } + t->queued_bytes += len; + t->queue_ready.notify_one(); + return terminal_result(e, AXYNE_STATUS_OK); +} +static void output(const char *s, size_t n, void *context) { + auto *t = static_cast(context); + if (t->encoding) { + try { t->encoded.append(s, n); } + catch (...) { t->encoding_failed = true; } + } + else if (t->running) terminal_enqueue(t, s, n, nullptr); +} +static int property(VTermProp prop, VTermValue *v, void *context) { + auto *t = static_cast(context); + if (prop == VTERM_PROP_CURSORVISIBLE) t->cursor_visible = v->boolean; + if (prop == VTERM_PROP_ALTSCREEN) { t->alternate = v->boolean; t->scroll = 0; } + return 1; +} +static int pushline(int cols, const VTermScreenCell *cells, void *context) { + auto *t = static_cast(context); + if (!t->history_limit || t->alternate) return 1; + try { t->history.emplace_back(cells, cells + cols); } + catch (const std::bad_alloc &) { return 0; } + if (t->scroll) ++t->scroll; + if (t->history.size() > t->history_limit) t->history.pop_front(); + t->scroll = std::min(t->scroll, static_cast(t->history.size())); + return 1; +} +static int popline(int cols, VTermScreenCell *cells, void *context) { + auto *t = static_cast(context); + if (t->history.empty() || t->alternate) return 0; + const auto &line = t->history.back(); + VTermColor fg, bg; + vterm_state_get_default_colors(vterm_obtain_state(t->vt), &fg, &bg); + for (int c = 0; c < cols; ++c) { + if (c < static_cast(line.size())) cells[c] = line[c]; + else { cells[c] = {}; cells[c].width = 1; cells[c].fg = fg; cells[c].bg = bg; } + } + t->history.pop_back(); + t->scroll = std::min(t->scroll, static_cast(t->history.size())); + return 1; +} +static int clear_history(void *context) { + auto *t = static_cast(context); + t->history.clear(); t->scroll = 0; return 1; +} +static const VTermScreenCallbacks callbacks = { + nullptr, nullptr, nullptr, property, nullptr, nullptr, pushline, popline, clear_history +}; +extern "C" AxyneStatus axyne_terminal_create(int rows, int cols, size_t limit, + AxyneTerminal **out, AxyneError *e) try { + if (!out || !dimensions(rows, cols) || limit > 2000) + return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT, "Terminal dimensions exceed 512 rows / 1024 columns or history exceeds 2000 lines"); + *out = nullptr; + auto *t = new (std::nothrow) AxyneTerminal; + if (!t) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + t->vt = vterm_new(rows, cols); + if (!t->vt) { delete t; return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } + t->history_limit = limit; + vterm_set_utf8(t->vt, 1); + t->screen = vterm_obtain_screen(t->vt); + vterm_screen_enable_altscreen(t->screen, 1); + vterm_screen_set_callbacks(t->screen, &callbacks, t); + vterm_output_set_callback(t->vt, output, t); + vterm_screen_reset(t->screen, 1); + *out = t; + return terminal_result(e, AXYNE_STATUS_OK); +} catch (...) { + if (out) *out = nullptr; + return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY, "Cannot allocate terminal model"); +} +extern "C" void axyne_terminal_feed(AxyneTerminal *t, const char *s, size_t n) try { + if (!t || (!s && n)) return; + std::lock_guard lock(t->model_mutex); + vterm_input_write(t->vt, s, n); vterm_screen_flush_damage(t->screen); +} +catch (...) {} +extern "C" AxyneStatus axyne_terminal_write(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) try { + if (!t || (!s && n)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return terminal_enqueue(t, s, n, e); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +template static AxyneStatus encode(AxyneTerminal *t, F f, AxyneError *e) { + if (!t) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + std::lock_guard lock(t->model_mutex); + t->encoding = true; t->encoding_failed = false; t->encoded.clear(); + try { f(); } + catch (const std::bad_alloc &) { t->encoding = false; return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); } + t->encoding = false; t->scroll = 0; + if (t->encoding_failed) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + return terminal_enqueue(t, t->encoded.data(), t->encoded.size(), e); +} +extern "C" AxyneStatus axyne_terminal_key(AxyneTerminal *t, AxyneTerminalKey k, unsigned mods, AxyneError *e) try { + if ((k < 1 || k > AXYNE_TERMINAL_PAGEDOWN) && (k < 257 || k > 280)) + return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return encode(t, [&]{ vterm_keyboard_key(t->vt, static_cast(k), static_cast(mods & 7)); }, e); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" AxyneStatus axyne_terminal_unichar(AxyneTerminal *t, uint32_t c, unsigned mods, AxyneError *e) try { + if (c > 0x10ffff || (c >= 0xd800 && c <= 0xdfff)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return encode(t, [&]{ vterm_keyboard_unichar(t->vt, c, static_cast(mods & 7)); }, e); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" AxyneStatus axyne_terminal_paste(AxyneTerminal *t, const char *s, size_t n, AxyneError *e) try { + if ((!s && n) || n > input_limit - 12) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + return encode(t, [&]{ vterm_keyboard_start_paste(t->vt); t->encoded.append(s ? s : "", n); vterm_keyboard_end_paste(t->vt); }, e); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" AxyneStatus axyne_terminal_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) try { + if (!t || !dimensions(rows, cols)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + { std::lock_guard lock(t->model_mutex); + vterm_set_size(t->vt, rows, cols); vterm_screen_flush_damage(t->screen); } + return t->running ? terminal_transport_resize(t, rows, cols, e) : terminal_result(e, AXYNE_STATUS_OK); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" void axyne_terminal_scroll(AxyneTerminal *t, int lines) try { + if (!t) return; + std::lock_guard lock(t->model_mutex); + t->scroll = t->alternate ? 0 : static_cast(std::clamp(static_cast(t->scroll) + lines, 0LL, static_cast(t->history.size()))); +} +catch (...) {} +extern "C" void axyne_terminal_clear(AxyneTerminal *t) try { + if (!t) return; + std::lock_guard lock(t->model_mutex); + t->history.clear(); t->scroll = 0; vterm_screen_reset(t->screen, 1); +} +catch (...) {} +extern "C" void axyne_terminal_set_colors(AxyneTerminal *t, uint32_t fg, uint32_t bg) try { + if (!t) return; + std::lock_guard lock(t->model_mutex); + VTermColor f, b; vterm_color_rgb(&f, fg >> 16, fg >> 8, fg); vterm_color_rgb(&b, bg >> 16, bg >> 8, bg); + vterm_screen_set_default_colors(t->screen, &f, &b); +} +catch (...) {} +static uint32_t rgb(AxyneTerminal *t, VTermColor color) { + vterm_screen_convert_color_to_rgb(t->screen, &color); + return (color.rgb.red << 16) | (color.rgb.green << 8) | color.rgb.blue; +} +extern "C" AxyneStatus axyne_terminal_snapshot(AxyneTerminal *t, AxyneTerminalSnapshot *s, AxyneError *e) try { + if (!t || !s) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + std::lock_guard lock(t->model_mutex); + *s = {}; vterm_get_size(t->vt, &s->rows, &s->columns); + s->cells = static_cast(std::calloc(static_cast(s->rows) * s->columns, sizeof *s->cells)); + if (!s->cells) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + VTermPos pos; vterm_state_get_cursorpos(vterm_obtain_state(t->vt), &pos); + s->cursor_row = pos.row; s->cursor_column = pos.col; s->cursor_visible = t->cursor_visible && !t->scroll; + s->alternate_screen = t->alternate; s->scroll_offset = t->scroll; s->history_lines = t->history.size(); + VTermColor fg, bg; vterm_state_get_default_colors(vterm_obtain_state(t->vt), &fg, &bg); + for (int r = 0; r < s->rows; ++r) for (int c = 0; c < s->columns; ++c) { + VTermScreenCell cell{}; cell.width = 1; cell.fg = fg; cell.bg = bg; + int line = static_cast(t->history.size()) - t->scroll + r; + if (line < static_cast(t->history.size())) { + const auto &h = t->history[line]; if (c < static_cast(h.size())) cell = h[c]; + } else vterm_screen_get_cell(t->screen, {r - t->scroll, c}, &cell); + auto &dest = s->cells[r * s->columns + c]; + std::memcpy(dest.chars, cell.chars, sizeof dest.chars); + dest.width = cell.chars[0] == UINT32_MAX ? 0 : cell.width; + if (!dest.width) std::memset(dest.chars, 0, sizeof dest.chars); + dest.foreground = rgb(t, cell.fg); dest.background = rgb(t, cell.bg); + dest.attributes = (cell.attrs.bold ? AXYNE_TERMINAL_BOLD : 0) | (cell.attrs.italic ? AXYNE_TERMINAL_ITALIC : 0) | + (cell.attrs.underline ? AXYNE_TERMINAL_UNDERLINE : 0) | (cell.attrs.reverse ? AXYNE_TERMINAL_REVERSE : 0); + } + return terminal_result(e, AXYNE_STATUS_OK); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" void axyne_terminal_snapshot_free(AxyneTerminalSnapshot *s) try { if (s) { std::free(s->cells); *s = {}; } } +catch (...) {} +extern "C" int axyne_terminal_running(AxyneTerminal *t) try { return t && t->running; } +catch (...) { return 0; } +extern "C" AxyneStatus axyne_terminal_start(AxyneTerminal *t, const AxyneTerminalSpec *spec, AxyneError *e) try { + if (!t || !spec || !spec->executable || (spec->argument_count && !spec->arguments)) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + if (t->running) return terminal_result(e, AXYNE_STATUS_BUSY); + terminal_transport_destroy(t); + t->changed = spec->on_changed; t->exited = spec->on_exit; t->context = spec->user_data; + t->stopping = false; + { std::lock_guard lock(t->queue_mutex); t->writes.clear(); t->queued_bytes = 0; } + return terminal_transport_start(t, spec, e); +} catch (const std::bad_alloc &) { + if (t) terminal_transport_destroy(t); + return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY, "Cannot allocate terminal session"); +} catch (...) { + if (t) terminal_transport_destroy(t); + return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Cannot initialize terminal session"); +} +extern "C" AxyneStatus axyne_terminal_terminate(AxyneTerminal *t, AxyneError *e) try { + if (!t) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + terminal_transport_stop(t); return terminal_result(e, AXYNE_STATUS_OK); +} +catch (...) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Terminal operation failed"); } +extern "C" void axyne_terminal_release(AxyneTerminal *t) try { + if (!t) return; + terminal_transport_destroy(t); vterm_free(t->vt); delete t; +} +catch (...) {} diff --git a/src/features/terminal/terminal_internal.hpp b/src/features/terminal/terminal_internal.hpp new file mode 100644 index 0000000..75fb795 --- /dev/null +++ b/src/features/terminal/terminal_internal.hpp @@ -0,0 +1,35 @@ +#pragma once +#include "axyne/terminal.h" +#include +#include +#include +#include +#include +#include +#include +#include + +struct AxyneTerminal { + std::mutex model_mutex, queue_mutex; + std::condition_variable queue_ready; + VTerm *vt = nullptr; + VTermScreen *screen = nullptr; + std::deque> history; + size_t history_limit = 0, queued_bytes = 0; + int scroll = 0, cursor_visible = 1, alternate = 0; + std::deque writes; + std::string encoded; + bool encoding = false, encoding_failed = false; + std::atomic running{false}, stopping{false}; + std::thread reader, writer; + AxyneTerminalChangedFn changed = nullptr; + AxyneTerminalExitFn exited = nullptr; + void *context = nullptr; + void *transport = nullptr; +}; +AxyneStatus terminal_result(AxyneError *, AxyneStatus, const char * = ""); +AxyneStatus terminal_enqueue(AxyneTerminal *, const char *, size_t, AxyneError *); +AxyneStatus terminal_transport_start(AxyneTerminal *, const AxyneTerminalSpec *, AxyneError *); +AxyneStatus terminal_transport_resize(AxyneTerminal *, int, int, AxyneError *); +void terminal_transport_stop(AxyneTerminal *); +void terminal_transport_destroy(AxyneTerminal *); diff --git a/src/features/terminal/terminal_posix.cpp b/src/features/terminal/terminal_posix.cpp new file mode 100644 index 0000000..8c7e61c --- /dev/null +++ b/src/features/terminal/terminal_posix.cpp @@ -0,0 +1,257 @@ +#include "terminal_internal.hpp" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef __APPLE__ +#include +#include +#endif + +extern char **environ; +struct PosixTerminal { + std::mutex child_mutex; + int master = -1; + pid_t child = -1; // Kept unreaped until session teardown; cannot be reused. + bool child_exited = false; + std::chrono::steady_clock::time_point stop_started = std::chrono::steady_clock::time_point::max(); +}; +#ifdef __APPLE__ +static bool process_token(pid_t pid, audit_token_t *token) { + mach_port_t task = MACH_PORT_NULL; + if (task_name_for_pid(mach_task_self(),pid,&task) != KERN_SUCCESS) return false; + mach_msg_type_number_t count = TASK_AUDIT_TOKEN_COUNT; + kern_return_t result = task_info(task,TASK_AUDIT_TOKEN,reinterpret_cast(token),&count); + mach_port_deallocate(mach_task_self(),task); + return result == KERN_SUCCESS && token->val[5] == static_cast(pid); +} +static void kill_owned_descendants(pid_t root) { + // Kernel audit tokens include the PID generation. Never signal a sampled + // numeric PID/PGID that could have been reused after enumeration. + struct Identity {pid_t pid; audit_token_t token;}; + Identity pending[1024]{}; + pid_t children[1024]{}; + int count = 1; pending[0].pid = root; + for(int at=0;at(pending[at].pid),children,sizeof children); + for(int i=0;i(sizeof(pid_t)) && count<1024;++i) { + audit_token_t token{};proc_bsdinfo info{};pid_t pid=children[i]; + if(pid<=0||!process_token(pid,&token))continue; + if(proc_pidinfo(pid,PROC_PIDTBSDINFO,0,&info,sizeof info)!=sizeof info||info.pbi_ppid!=static_cast(pending[at].pid))continue; + pending[count++]={pid,token}; + } + } + for(int i=count-1;i>0;--i) (void)proc_signal_with_audittoken(&pending[i].token,SIGKILL); +} +#endif +static void escalate_stop(AxyneTerminal *t, PosixTerminal *p) { + if (!t->stopping) return; + std::lock_guard lock(p->child_mutex); + if (p->child <= 0 || std::chrono::steady_clock::now() - p->stop_started < std::chrono::milliseconds(200)) return; + siginfo_t info{}; + if (waitid(P_PID,p->child,&info,WEXITED|WNOHANG|WNOWAIT)<0 && errno==ECHILD) {p->child=-1;return;} + kill(-p->child,SIGKILL); kill(p->child,SIGKILL); +} +static void nonblocking(int fd) { fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK); } +static void cloexec(int fd) { fcntl(fd, F_SETFD, FD_CLOEXEC); } +static AxyneStatus failure(AxyneError *e, const char *what) { + std::string message = std::string(what) + ": " + std::strerror(errno); + return terminal_result(e, AXYNE_STATUS_IO_ERROR, message.c_str()); +} +static void writer(AxyneTerminal *t, PosixTerminal *p) { + // Ignore SIGPIPE on this worker only; do not change the application's policy. + sigset_t set; sigemptyset(&set); sigaddset(&set, SIGPIPE); pthread_sigmask(SIG_BLOCK, &set, nullptr); + while (!t->stopping) { + std::string data; + { + std::unique_lock lock(t->queue_mutex); + t->queue_ready.wait(lock, [&]{ return t->stopping || !t->writes.empty(); }); + if (t->stopping) break; + data = std::move(t->writes.front()); t->writes.pop_front(); + } + size_t at = 0; + while (at < data.size() && !t->stopping) { + pollfd fd{p->master, POLLOUT, 0}; + int ready = poll(&fd, 1, 50); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0 || (fd.revents & (POLLHUP | POLLERR | POLLNVAL))) break; + if (!ready) continue; + ssize_t n = write(p->master, data.data() + at, data.size() - at); + if (n > 0) at += static_cast(n); + else if (n < 0 && errno != EAGAIN && errno != EINTR) break; + } + { std::lock_guard lock(t->queue_mutex); t->queued_bytes -= data.size(); } + } +} +static void reader(AxyneTerminal *t, PosixTerminal *p) { + using Clock = std::chrono::steady_clock; + auto end = Clock::time_point::max(); + bool eof = false; + int code = -1; + while (!eof) { + escalate_stop(t,p); + // WNOWAIT deliberately retains PID/session ownership until stop and join. + siginfo_t info{}; + { std::lock_guard lock(p->child_mutex); + if (!p->child_exited && waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT) == 0 && info.si_pid) { + p->child_exited = true; + code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status; + end = Clock::now() + std::chrono::milliseconds(500); + } + } + if (t->stopping && end == Clock::time_point::max()) end = Clock::now() + std::chrono::milliseconds(500); + pollfd fd{p->master, POLLIN, 0}; + int ready = poll(&fd, 1, 50); + if (ready < 0 && errno == EINTR) continue; + if (ready < 0) break; + if (ready && (fd.revents & (POLLIN | POLLHUP | POLLERR))) { + char buffer[16384]; + // Bounded per turn even when a child produces output continuously. + for (int batch = 0; batch < 16; ++batch) { + ssize_t n = read(p->master, buffer, sizeof buffer); + if (n > 0) { + axyne_terminal_feed(t, buffer, static_cast(n)); + if (t->changed) t->changed(t, t->context); + } else { + if (n == 0 || (errno != EINTR && errno != EAGAIN)) eof = true; + break; + } + } + } + if (Clock::now() >= end) break; + } + // EOF may race the first WNOHANG check. Observe the root after draining, + // retaining its zombie rather than reaping the session/group identity. + if (code == -1) { + siginfo_t info{}; + for (;;) { + escalate_stop(t,p); + { std::lock_guard lock(p->child_mutex); + int result = waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT); + if (result < 0 || info.si_pid) { + p->child_exited = true; + if (info.si_pid) code = info.si_code == CLD_EXITED ? info.si_status : 128 + info.si_status; + break; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + t->running = false; t->stopping = true; t->queue_ready.notify_all(); + if (t->exited) t->exited(t, code, t->context); +} +AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { + auto *p = new (std::nothrow) PosixTerminal; + if (!p) return terminal_result(e, AXYNE_STATUS_OUT_OF_MEMORY); + t->transport = p; // Exception cleanup owns every opened descriptor. + p->master = posix_openpt(O_RDWR | O_NOCTTY | O_CLOEXEC); + if (p->master < 0 || grantpt(p->master) || unlockpt(p->master)) { + auto result = failure(e, "Cannot allocate PTY"); terminal_transport_destroy(t); return result; + } + // All strings, argv, and environment are constructed before fork. + std::string slave_name = ptsname(p->master); + std::vector env; + for (char **entry = environ; *entry; ++entry) { + if (std::strncmp(*entry, "TERM=", 5) && std::strncmp(*entry, "COLORTERM=", 10)) env.emplace_back(*entry); + } + env.emplace_back("TERM=xterm-256color"); env.emplace_back("COLORTERM=truecolor"); + std::vector envp; for (auto &entry : env) envp.push_back(entry.data()); envp.push_back(nullptr); + std::vector args; args.emplace_back(s->executable); + for (size_t i = 0; i < s->argument_count; ++i) { + if (!s->arguments[i]) { terminal_transport_destroy(t); return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); } + args.emplace_back(s->arguments[i]); + } + std::vector argv; for (auto &arg : args) argv.push_back(arg.data()); argv.push_back(nullptr); + std::string cwd = s->working_directory ? s->working_directory : ""; + int handshake[2]; + if (pipe(handshake)) { auto result = failure(e, "Cannot create startup handshake"); terminal_transport_destroy(t); return result; } + cloexec(handshake[0]); cloexec(handshake[1]); + int rows, cols; { std::lock_guard lock(t->model_mutex); vterm_get_size(t->vt, &rows, &cols); } + winsize size{}; size.ws_row = static_cast(rows); size.ws_col = static_cast(cols); + p->child = fork(); + if (!p->child) { + close(handshake[0]); + int saved = 0; + if (setsid() < 0) saved = errno; + int slave = saved ? -1 : open(slave_name.c_str(), O_RDWR); + if (slave < 0 && !saved) saved = errno; + if (!saved && ioctl(slave, TIOCSCTTY, 0) < 0) saved = errno; + if (!saved && ioctl(slave, TIOCSWINSZ, &size) < 0) saved = errno; + for (int fd = 0; fd < 3 && !saved; ++fd) if (dup2(slave, fd) < 0) saved = errno; + if (slave > 2) close(slave); + close(p->master); + if (!saved && !cwd.empty() && chdir(cwd.c_str())) saved = errno; + if (!saved) { + sigset_t empty; sigemptyset(&empty); sigprocmask(SIG_SETMASK, &empty, nullptr); + for (int signal : {SIGINT, SIGQUIT, SIGTERM, SIGHUP, SIGPIPE, SIGTSTP, SIGTTIN, SIGTTOU}) ::signal(signal, SIG_DFL); + execve(argv[0], argv.data(), envp.data()); saved = errno; + } + (void)write(handshake[1], &saved, sizeof saved); _exit(127); + } + close(handshake[1]); + if (p->child < 0) { auto result = failure(e, "Cannot fork terminal"); close(handshake[0]); terminal_transport_destroy(t); return result; } + int saved = 0; ssize_t count; + do { count = read(handshake[0], &saved, sizeof saved); } while (count < 0 && errno == EINTR); + close(handshake[0]); + if (count != 0) { + if (count < 0) saved = errno; + kill(p->child, SIGKILL); while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} + p->child = -1; terminal_transport_destroy(t); errno = saved; return failure(e, "Cannot start terminal shell"); + } + nonblocking(p->master); t->transport = p; t->running = true; + try { t->writer = std::thread(writer, t, p); t->reader = std::thread(reader, t, p); } + catch (...) { terminal_transport_destroy(t); return terminal_result(e, AXYNE_STATUS_IO_ERROR, "Cannot create terminal workers"); } + return terminal_result(e, AXYNE_STATUS_OK); +} +AxyneStatus terminal_transport_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) { + auto *p = static_cast(t->transport); + winsize size{}; size.ws_row = static_cast(rows); size.ws_col = static_cast(cols); + if (p && ioctl(p->master, TIOCSWINSZ, &size) < 0) return failure(e, "Cannot resize terminal"); + return terminal_result(e, AXYNE_STATUS_OK); +} +void terminal_transport_stop(AxyneTerminal *t) { + auto *p = static_cast(t->transport); + bool already = t->stopping.exchange(true); t->queue_ready.notify_all(); + if (!p || p->child <= 0) return; + std::lock_guard lock(p->child_mutex); + siginfo_t info{}; + if (waitid(P_PID, p->child, &info, WEXITED | WNOHANG | WNOWAIT) < 0 && errno == ECHILD) { + p->child = -1; return; // Never signal an externally reaped/reused ID. + } + // The shell's session/process-group leader remains owned and unreaped. + // A terminal hangup reaches its foreground group via the tty driver; + // do not signal a numeric foreground PGID which might have been reused. + if (p->stop_started == std::chrono::steady_clock::time_point::max()) { + p->stop_started = std::chrono::steady_clock::now(); +#ifdef __APPLE__ + kill_owned_descendants(p->child); +#endif + if (!p->child_exited) kill(p->child, SIGHUP); + } + // zsh owns job identities and can hang up its foreground/background jobs + // on SIGHUP. Allow that cleanup before escalation on the polling reader. + if (already || !t->reader.joinable()) { + kill(-p->child,SIGKILL); kill(p->child,SIGKILL); + } +} +void terminal_transport_destroy(AxyneTerminal *t) { + terminal_transport_stop(t); + if (t->reader.joinable()) t->reader.join(); + if (t->writer.joinable()) t->writer.join(); + auto *p = static_cast(t->transport); + if (p) { + // Closing the controlling master hangs up any remaining foreground job. + if (p->master >= 0) close(p->master); + if (p->child > 0) while (waitpid(p->child, nullptr, 0) < 0 && errno == EINTR) {} + delete p; t->transport = nullptr; + } + t->running = false; +} diff --git a/src/features/terminal/terminal_windows.cpp b/src/features/terminal/terminal_windows.cpp new file mode 100644 index 0000000..230a2eb --- /dev/null +++ b/src/features/terminal/terminal_windows.cpp @@ -0,0 +1,175 @@ +#define WIN32_LEAN_AND_MEAN +#include +#include "terminal_internal.hpp" +#include +#include +#include +#include + +using CreatePC = HRESULT (WINAPI *)(COORD, HANDLE, HANDLE, DWORD, HANDLE *); +using ResizePC = HRESULT (WINAPI *)(HANDLE, COORD); +using ClosePC = void (WINAPI *)(HANDLE); +struct WindowsTerminal { + HANDLE input = nullptr, output = nullptr, process = nullptr, job = nullptr, pc = nullptr; + ResizePC resize = nullptr; ClosePC close = nullptr; + std::mutex pc_mutex; + std::thread closer; + std::atomic closed{false}; + DWORD exit_code = 0; + bool trace = false; + std::atomic read_bytes{0}, written_bytes{0}; +}; +static AxyneStatus failure(AxyneError *e, const char *message) { return terminal_result(e, AXYNE_STATUS_IO_ERROR, message); } +static std::wstring wide(const char *s) { + if (!s) return {}; + int n = MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, nullptr, 0); + if (!n) return {}; + std::wstring result(n, 0); MultiByteToWideChar(CP_UTF8, MB_ERR_INVALID_CHARS, s, -1, result.data(), n); result.resize(n-1); return result; +} +static std::wstring quote(const std::wstring &s) { + std::wstring result = L"\""; size_t slashes = 0; + for (wchar_t c : s) { + if (c == L'\\') { ++slashes; continue; } + result.append(c == L'"' ? slashes * 2 + 1 : slashes, L'\\'); slashes = 0; result += c; + } + result.append(slashes * 2, L'\\'); result += L'"'; return result; +} +static void writer(AxyneTerminal *t, WindowsTerminal *p) { + while (!t->stopping) { + std::string data; + { std::unique_lock lock(t->queue_mutex); + t->queue_ready.wait(lock, [&]{ return t->stopping || !t->writes.empty(); }); + if (t->stopping) break; + data = std::move(t->writes.front()); t->writes.pop_front(); } + size_t at = 0; + while (at < data.size() && !t->stopping) { + DWORD n = 0; + if (!WriteFile(p->input, data.data()+at, static_cast(data.size()-at), &n, nullptr) || !n) { + if(p->trace) std::fprintf(stderr,"terminal writer stopped: Win32=%lu\n",GetLastError()); + break; + } + p->written_bytes += n; + at += n; + } + { std::lock_guard lock(t->queue_mutex); t->queued_bytes -= data.size(); } + } +} +static void reader(AxyneTerminal *t, WindowsTerminal *p) { + for (;;) { + DWORD available = 0; + if (!PeekNamedPipe(p->output, nullptr, 0, nullptr, &available, nullptr)) { + if(p->trace) std::fprintf(stderr,"terminal reader EOF: Win32=%lu\n",GetLastError()); + break; + } + if (available) { + char buffer[16384]; DWORD n = 0; + if (!ReadFile(p->output, buffer, sizeof buffer, &n, nullptr) || !n) break; + axyne_terminal_feed(t, buffer, n); + p->read_bytes += n; + if (t->changed) t->changed(t, t->context); + } else { + // ClosePseudoConsole can return before its final output on newer + // Windows versions. Only a broken pipe is stream EOF. + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + } + // Exit is serialized behind the final frame and ClosePseudoConsole. + while (!p->closed) std::this_thread::sleep_for(std::chrono::milliseconds(10)); + t->running = false; + if(p->trace) std::fprintf(stderr,"terminal exit: code=%lu read=%llu written=%llu\n",p->exit_code,p->read_bytes.load(),p->written_bytes.load()); + if (t->exited) t->exited(t, static_cast(p->exit_code), t->context); +} +static void closer(AxyneTerminal *t, WindowsTerminal *p) { + while (!t->stopping && WaitForSingleObject(p->process, 20) == WAIT_TIMEOUT) {} + t->stopping = true; t->queue_ready.notify_all(); + TerminateJobObject(p->job, 1); + // Cancel synchronous writer I/O before joining, keeping the reader alive + // while ClosePseudoConsole emits its final frame (Microsoft's contract). + if (t->writer.joinable()) CancelSynchronousIo(t->writer.native_handle()); + WaitForSingleObject(p->process, INFINITE); + if (!GetExitCodeProcess(p->process, &p->exit_code)) p->exit_code = static_cast(-1); + HANDLE pc; + { std::lock_guard lock(p->pc_mutex); pc = p->pc; p->pc = nullptr; } + if (pc) p->close(pc); // No mutex held while the reader drains the last frame. + p->closed = true; +} +AxyneStatus terminal_transport_start(AxyneTerminal *t, const AxyneTerminalSpec *s, AxyneError *e) { + HMODULE kernel = GetModuleHandleW(L"kernel32.dll"); + auto create = reinterpret_cast(GetProcAddress(kernel, "CreatePseudoConsole")); + auto resize = reinterpret_cast(GetProcAddress(kernel, "ResizePseudoConsole")); + auto closepc = reinterpret_cast(GetProcAddress(kernel, "ClosePseudoConsole")); + if (!create || !resize || !closepc) return terminal_result(e, AXYNE_STATUS_UNSUPPORTED, "Real terminals require Windows 10 1809 or later (ConPTY)"); + std::wstring executable = wide(s->executable), cwd = wide(s->working_directory), command = quote(executable); + for (size_t i = 0; i < s->argument_count; ++i) { + if (!s->arguments[i]) return terminal_result(e, AXYNE_STATUS_INVALID_ARGUMENT); + command += L" " + quote(wide(s->arguments[i])); + } + // Finish every allocating preparation before handles or workers exist. + SIZE_T bytes = 0; InitializeProcThreadAttributeList(nullptr, 1, 0, &bytes); + std::vector storage(bytes); + auto *p = new WindowsTerminal; p->resize = resize; p->close = closepc; t->transport = p; + p->trace = std::getenv("AXYNE_TERMINAL_TRACE") != nullptr; + HANDLE inread = nullptr, outwrite = nullptr; + if (!CreatePipe(&inread, &p->input, nullptr, 0) || !CreatePipe(&p->output, &outwrite, nullptr, 0)) { + if (inread) CloseHandle(inread); if (outwrite) CloseHandle(outwrite); terminal_transport_destroy(t); return failure(e, "Cannot create ConPTY channels"); + } + int rows, cols; { std::lock_guard lock(t->model_mutex); vterm_get_size(t->vt, &rows, &cols); } + HRESULT hr = create({static_cast(cols), static_cast(rows)}, inread, outwrite, 0, &p->pc); + if(p->trace) std::fprintf(stderr,"terminal create-pc: HRESULT=%08lx\n",static_cast(hr)); + if (FAILED(hr)) { CloseHandle(inread); CloseHandle(outwrite); terminal_transport_destroy(t); return failure(e, "Cannot create pseudoconsole"); } + // Start the drain thread before any startup error can close the console. + try { t->reader = std::thread(reader, t, p); } + catch (...) { CloseHandle(inread); CloseHandle(outwrite); CloseHandle(p->output); p->output = nullptr; terminal_transport_destroy(t); return failure(e, "Cannot create terminal reader"); } + STARTUPINFOEXW si{}; si.StartupInfo.cb = sizeof si; + // Windows otherwise duplicates redirected parent standard handles even + // with bInheritHandles=FALSE. Null std handles are filled from ConPTY. + // https://github.com/microsoft/terminal/discussions/15814 + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.lpAttributeList = reinterpret_cast(storage.data()); + bool initialized = InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &bytes) != 0; + bool ready = initialized && UpdateProcThreadAttribute(si.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, p->pc, sizeof p->pc, nullptr, nullptr); + if(p->trace) std::fprintf(stderr,"terminal attributes: initialized=%d attached=%d Win32=%lu\n",initialized,ready,ready?0:GetLastError()); + p->job = CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits{}; limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ready = ready && p->job && SetInformationJobObject(p->job, JobObjectExtendedLimitInformation, &limits, sizeof limits); + PROCESS_INFORMATION pi{}; + bool spawned = ready && CreateProcessW(executable.c_str(), command.data(), nullptr, nullptr, FALSE, + EXTENDED_STARTUPINFO_PRESENT | CREATE_SUSPENDED | CREATE_UNICODE_ENVIRONMENT, nullptr, cwd.empty() ? nullptr : cwd.c_str(), &si.StartupInfo, &pi); + if(p->trace) std::fprintf(stderr,"terminal create-process: ok=%d Win32=%lu pid=%lu\n",spawned,spawned?0:GetLastError(),pi.dwProcessId); + if (initialized) DeleteProcThreadAttributeList(si.lpAttributeList); + CloseHandle(inread); CloseHandle(outwrite); + if (!spawned) { terminal_transport_destroy(t); return failure(e, "Cannot start terminal shell"); } + p->process = pi.hProcess; + if (!AssignProcessToJobObject(p->job, pi.hProcess)) { + TerminateProcess(pi.hProcess, 1); CloseHandle(pi.hThread); terminal_transport_destroy(t); return failure(e, "Cannot own terminal process tree"); + } + t->running = true; + try { t->writer = std::thread(writer, t, p); p->closer = std::thread(closer, t, p); } + catch (...) { TerminateProcess(pi.hProcess, 1); CloseHandle(pi.hThread); terminal_transport_destroy(t); return failure(e, "Cannot create terminal workers"); } + ResumeThread(pi.hThread); CloseHandle(pi.hThread); return terminal_result(e, AXYNE_STATUS_OK); +} +AxyneStatus terminal_transport_resize(AxyneTerminal *t, int rows, int cols, AxyneError *e) { + auto *p = static_cast(t->transport); + std::lock_guard lock(p->pc_mutex); + return p->pc && FAILED(p->resize(p->pc, {static_cast(cols), static_cast(rows)})) ? failure(e, "Cannot resize pseudoconsole") : terminal_result(e, AXYNE_STATUS_OK); +} +void terminal_transport_stop(AxyneTerminal *t) { + t->stopping = true; t->queue_ready.notify_all(); + auto *p = static_cast(t->transport); + if (p && p->job) TerminateJobObject(p->job, 1); + if (t->writer.joinable()) CancelSynchronousIo(t->writer.native_handle()); +} +void terminal_transport_destroy(AxyneTerminal *t) { + terminal_transport_stop(t); + auto *p = static_cast(t->transport); + if (!p) return; + if (p->closer.joinable()) p->closer.join(); + else { + if (p->pc) { p->close(p->pc); p->pc = nullptr; } + p->closed = true; + } + if (t->reader.joinable()) t->reader.join(); + if (t->writer.joinable()) { CancelSynchronousIo(t->writer.native_handle()); t->writer.join(); } + for (HANDLE handle : {p->input, p->output, p->process, p->job}) if (handle) CloseHandle(handle); + delete p; t->transport = nullptr; t->running = false; +} diff --git a/src/features/ui/CMakeLists.txt b/src/features/ui/CMakeLists.txt index e20cfd6..c2cf601 100644 --- a/src/features/ui/CMakeLists.txt +++ b/src/features/ui/CMakeLists.txt @@ -1,12 +1,14 @@ if(WIN32) add_library(axyne_ui STATIC "${CMAKE_CURRENT_LIST_DIR}/platform/windows/ui_windows.c" + "${CMAKE_CURRENT_LIST_DIR}/platform/windows/terminal_view_windows.c" "${CMAKE_CURRENT_LIST_DIR}/syntax.c" "${CMAKE_CURRENT_LIST_DIR}/platform/windows/preferences_window_windows.c") - target_link_libraries(axyne_ui PRIVATE user32 gdi32 comdlg32 shell32 ole32) + target_link_libraries(axyne_ui PRIVATE user32 gdi32 comdlg32 shell32 ole32 imm32) elseif(APPLE) add_library(axyne_ui STATIC "${CMAKE_CURRENT_LIST_DIR}/platform/macos/ui_macos.m" + "${CMAKE_CURRENT_LIST_DIR}/platform/macos/terminal_view_macos.m" "${CMAKE_CURRENT_LIST_DIR}/syntax.c" "${CMAKE_CURRENT_LIST_DIR}/platform/macos/preferences_window_macos.m" "${CMAKE_CURRENT_LIST_DIR}/platform/macos/popup_menu_macos.m" @@ -19,6 +21,7 @@ target_include_directories(axyne_ui PRIVATE "${axyne_scintilla_SOURCE_DIR}/include" ) target_link_libraries(axyne PRIVATE axyne_ui) +target_link_libraries(axyne_ui PRIVATE axyne_terminal) if(MSVC) target_compile_options(axyne_ui PRIVATE /W4 /utf-8) diff --git a/src/features/ui/platform/macos/terminal_view_macos.h b/src/features/ui/platform/macos/terminal_view_macos.h new file mode 100644 index 0000000..50c90f0 --- /dev/null +++ b/src/features/ui/platform/macos/terminal_view_macos.h @@ -0,0 +1,13 @@ +#import +#include "axyne/terminal.h" +@interface AxyneTerminalView : NSView +- (AxyneStatus)start:(const AxyneTerminalSpec *)spec error:(AxyneError *)error; +- (void)stop; +- (void)shutdown; +- (BOOL)running; +- (AxyneTerminal *)terminal; +- (void)setFont:(NSFont *)font foreground:(uint32_t)fg background:(uint32_t)bg; +- (void)copy:(id)sender; +- (void)paste:(id)sender; +- (void)selectAll:(id)sender; +@end diff --git a/src/features/ui/platform/macos/terminal_view_macos.m b/src/features/ui/platform/macos/terminal_view_macos.m new file mode 100644 index 0000000..b10b4e5 --- /dev/null +++ b/src/features/ui/platform/macos/terminal_view_macos.m @@ -0,0 +1,187 @@ +#import "terminal_view_macos.h" +#include +@class AxyneTerminalView; +@interface AxyneTerminalLife : NSObject { +@public AxyneTerminalView *view; // Main-thread-only weak target; blocks own the box. + atomic_int pending; +} +@end +@implementation AxyneTerminalLife +@end +@interface AxyneTerminalView () { + AxyneTerminal *_terminal; + AxyneTerminalLife *_life; + NSFont *_font; + NSMutableAttributedString *_marked; + CGFloat _cellWidth, _cellHeight; + NSInteger _selectionStart, _selectionEnd; + uint32_t _foreground, _background; +} +@end +static NSColor *color(uint32_t rgb) { + return [NSColor colorWithSRGBRed:((rgb >> 16) & 255)/255.0 green:((rgb >> 8)&255)/255.0 blue:(rgb&255)/255.0 alpha:1]; +} +static NSString *cellString(const AxyneTerminalCell *cell) { + NSMutableString *text = [NSMutableString string]; + for (int n = 0; n < 6 && cell->chars[n]; ++n) { + uint32_t c = cell->chars[n]; + if (c <= 0xffff) { unichar u = c; [text appendString:[NSString stringWithCharacters:&u length:1]]; } + else if (c <= 0x10ffff) { unichar pair[] = {0xd800 + ((c-0x10000)>>10), 0xdc00 + ((c-0x10000)&1023)}; [text appendString:[NSString stringWithCharacters:pair length:2]]; } + } + return [text length] ? text : @" "; +} +static void changed(AxyneTerminal *terminal, void *context) { + (void)terminal; + AxyneTerminalLife *life = (AxyneTerminalLife *)context; + if (atomic_exchange(&life->pending, 1)) return; + dispatch_async(dispatch_get_main_queue(), ^{ + atomic_store(&life->pending, 0); + [life->view setNeedsDisplay:YES]; + NSView *parent = [life->view superview]; + if ([parent respondsToSelector:@selector(refreshActionControls)]) + [parent performSelector:@selector(refreshActionControls)]; + }); +} +static void exited(AxyneTerminal *terminal, int code, void *context) { + (void)code; changed(terminal, context); +} +@implementation AxyneTerminalView +- (id)initWithFrame:(NSRect)frame { + self = [super initWithFrame:frame]; + if (self) { + _life = [AxyneTerminalLife new]; _life->view = self; + atomic_init(&_life->pending, 0); + _marked = [[NSMutableAttributedString alloc] init]; + _selectionStart = _selectionEnd = -1; + axyne_terminal_create(24, 80, 2000, &_terminal, NULL); + [self setFont:[NSFont monospacedSystemFontOfSize:12 weight:NSFontWeightRegular] foreground:0xd4d4d4 background:0x181a1f]; + } + return self; +} +- (BOOL)isFlipped { return YES; } +- (BOOL)acceptsFirstResponder { return YES; } +- (AxyneTerminal *)terminal { return _terminal; } +- (BOOL)running { return axyne_terminal_running(_terminal); } +- (AxyneStatus)start:(const AxyneTerminalSpec *)spec error:(AxyneError *)error { + AxyneTerminalSpec owned = *spec; + owned.on_changed = changed; owned.on_exit = exited; owned.user_data = _life; + AxyneStatus result = axyne_terminal_start(_terminal, &owned, error); + [self setNeedsDisplay:YES]; return result; +} +- (void)stop { axyne_terminal_terminate(_terminal, NULL); } +- (void)shutdown { + _life->view = nil; + if (_terminal) { axyne_terminal_release(_terminal); _terminal = NULL; } +} +- (void)dealloc { [self shutdown]; [_life release]; [_font release]; [_marked release]; [super dealloc]; } +- (void)setFont:(NSFont *)font foreground:(uint32_t)fg background:(uint32_t)bg { + [font retain]; [_font release]; _font = font; _foreground = fg; _background = bg; + _cellWidth = MAX(1, ceil([@"M" sizeWithAttributes:@{NSFontAttributeName:font}].width)); + _cellHeight = MAX(1, ceil([font ascender]-[font descender]+[font leading]+2)); + axyne_terminal_set_colors(_terminal, fg, bg); [self resizeGrid]; [self setNeedsDisplay:YES]; +} +- (void)resizeGrid { + int rows = MAX(1, MIN(512, (int)((NSHeight([self bounds])-8)/_cellHeight))); + int cols = MAX(1, MIN(1024, (int)((NSWidth([self bounds])-8)/_cellWidth))); + AxyneTerminalSnapshot s = {0}; + if (axyne_terminal_snapshot(_terminal, &s, NULL) == AXYNE_STATUS_OK) { + BOOL different = s.rows != rows || s.columns != cols; axyne_terminal_snapshot_free(&s); + if (different) axyne_terminal_resize(_terminal, rows, cols, NULL); + } +} +- (void)setFrameSize:(NSSize)size { [super setFrameSize:size]; [self resizeGrid]; } +- (void)drawRect:(NSRect)dirty { + (void)dirty; [color(_background) setFill]; NSRectFill([self bounds]); + AxyneTerminalSnapshot s = {0}; if (axyne_terminal_snapshot(_terminal, &s, NULL) != AXYNE_STATUS_OK) return; + NSInteger first = MIN(_selectionStart, _selectionEnd), last = MAX(_selectionStart, _selectionEnd); + for (int r=0; rwidth) continue; + uint32_t fg=cell->foreground, bg=cell->background; + if (cell->attributes & AXYNE_TERMINAL_REVERSE) { uint32_t temp=fg; fg=bg; bg=temp; } + NSRect rect=NSMakeRect(4+c*_cellWidth, 4+r*_cellHeight, _cellWidth*MAX(1,cell->width), _cellHeight); + BOOL selected = first >= 0 && index >= first && index <= last; + [(selected ? [NSColor selectedTextBackgroundColor] : color(bg)) setFill]; NSRectFill(rect); + if (!cell->width) continue; + NSFontTraitMask traits = (cell->attributes & AXYNE_TERMINAL_BOLD ? NSBoldFontMask : 0) | (cell->attributes & AXYNE_TERMINAL_ITALIC ? NSItalicFontMask : 0); + NSFont *font = traits ? [[NSFontManager sharedFontManager] convertFont:_font toHaveTrait:traits] : _font; + [cellString(cell) drawAtPoint:rect.origin withAttributes:@{NSFontAttributeName:font, NSForegroundColorAttributeName:selected ? [NSColor selectedTextColor] : color(fg), NSUnderlineStyleAttributeName:@(cell->attributes & AXYNE_TERMINAL_UNDERLINE ? NSUnderlineStyleSingle : 0)}]; + } + if (s.cursor_visible && [[self window] firstResponder] == self) { + [color(_foreground) setFill]; NSRectFill(NSMakeRect(4+s.cursor_column*_cellWidth, 4+(s.cursor_row+1)*_cellHeight-2, _cellWidth, 2)); + } + if ([_marked length]) [_marked drawAtPoint:NSMakePoint(4+s.cursor_column*_cellWidth,4+s.cursor_row*_cellHeight)]; + axyne_terminal_snapshot_free(&s); +} +- (NSInteger)cellAtPoint:(NSPoint)point { + AxyneTerminalSnapshot s={0}; if (axyne_terminal_snapshot(_terminal,&s,NULL)!=AXYNE_STATUS_OK) return -1; + int c=MAX(0,MIN(s.columns-1,(int)((point.x-4)/_cellWidth))), r=MAX(0,MIN(s.rows-1,(int)((point.y-4)/_cellHeight))); + NSInteger index=r*s.columns+c; axyne_terminal_snapshot_free(&s); return index; +} +- (void)mouseDown:(NSEvent *)event { + [[self window] makeFirstResponder:self]; _selectionStart=_selectionEnd=[self cellAtPoint:[self convertPoint:[event locationInWindow] fromView:nil]]; [self setNeedsDisplay:YES]; +} +- (void)mouseDragged:(NSEvent *)event { _selectionEnd=[self cellAtPoint:[self convertPoint:[event locationInWindow] fromView:nil]]; [self setNeedsDisplay:YES]; } +- (void)scrollWheel:(NSEvent *)event { axyne_terminal_scroll(_terminal, (int)round([event scrollingDeltaY])); _selectionStart=_selectionEnd=-1; [self setNeedsDisplay:YES]; } +- (void)copy:(id)sender { + (void)sender; if (_selectionStart<0) return; + AxyneTerminalSnapshot s={0}; if (axyne_terminal_snapshot(_terminal,&s,NULL)!=AXYNE_STATUS_OK) return; + NSInteger first=MIN(_selectionStart,_selectionEnd), last=MIN(MAX(_selectionStart,_selectionEnd),s.rows*s.columns-1); + NSMutableString *value=[NSMutableString string]; + for (NSInteger i=first;i<=last;++i) { if (i>first && i%s.columns==0) [value appendString:@"\n"]; if (s.cells[i].width) [value appendString:cellString(&s.cells[i])]; } + [[NSPasteboard generalPasteboard] clearContents]; [[NSPasteboard generalPasteboard] setString:value forType:NSPasteboardTypeString]; axyne_terminal_snapshot_free(&s); +} +- (void)paste:(id)sender { + (void)sender; NSString *value=[[NSPasteboard generalPasteboard] stringForType:NSPasteboardTypeString]; + value=[[value stringByReplacingOccurrencesOfString:@"\r\n" withString:@"\n"] stringByReplacingOccurrencesOfString:@"\n" withString:@"\r"]; + NSData *data=[value dataUsingEncoding:NSUTF8StringEncoding]; axyne_terminal_paste(_terminal,[data bytes],[data length],NULL); [self setNeedsDisplay:YES]; +} +- (void)selectAll:(id)sender { + (void)sender; AxyneTerminalSnapshot s={0}; + if(axyne_terminal_snapshot(_terminal,&s,NULL)==AXYNE_STATUS_OK) { + _selectionStart=0; _selectionEnd=s.rows*s.columns-1; + axyne_terminal_snapshot_free(&s); [self setNeedsDisplay:YES]; + } +} +- (BOOL)performKeyEquivalent:(NSEvent *)event { + if ([[self window] firstResponder]!=self) return NO; + NSEventModifierFlags flags=[event modifierFlags]; NSString *key=[event charactersIgnoringModifiers]; + if (flags & NSEventModifierFlagCommand) { + if ([key isEqualToString:@"c"]) { [self copy:nil]; return YES; } + if ([key isEqualToString:@"v"]) { [self paste:nil]; return YES; } + if ([key isEqualToString:@"a"]) { [self selectAll:nil]; return YES; } + return NO; + } + [self keyDown:event]; return YES; +} +- (void)keyDown:(NSEvent *)event { + unsigned mods=([event modifierFlags]&NSEventModifierFlagShift ? AXYNE_TERMINAL_SHIFT : 0) | ([event modifierFlags]&NSEventModifierFlagOption ? AXYNE_TERMINAL_ALT : 0) | ([event modifierFlags]&NSEventModifierFlagControl ? AXYNE_TERMINAL_CTRL : 0); + AxyneTerminalKey key=0; + switch ([event keyCode]) { + case 36: case 76: key=AXYNE_TERMINAL_ENTER; break; case 48:key=AXYNE_TERMINAL_TAB;break; case 51:key=AXYNE_TERMINAL_BACKSPACE;break; + case 53:key=AXYNE_TERMINAL_ESCAPE;break;case 123:key=AXYNE_TERMINAL_LEFT;break;case 124:key=AXYNE_TERMINAL_RIGHT;break;case 125:key=AXYNE_TERMINAL_DOWN;break;case 126:key=AXYNE_TERMINAL_UP;break; + case 115:key=AXYNE_TERMINAL_HOME;break;case 119:key=AXYNE_TERMINAL_END;break;case 116:key=AXYNE_TERMINAL_PAGEUP;break;case 121:key=AXYNE_TERMINAL_PAGEDOWN;break;case 117:key=AXYNE_TERMINAL_DELETE;break; + } + NSString *chars=[event charactersIgnoringModifiers]; + if (!key && [chars length] && [chars characterAtIndex:0]>=NSF1FunctionKey && [chars characterAtIndex:0]<=NSF24FunctionKey) key=(AxyneTerminalKey)(AXYNE_TERMINAL_F1+[chars characterAtIndex:0]-NSF1FunctionKey); + if (key) axyne_terminal_key(_terminal,key,mods,NULL); + else if ((mods&AXYNE_TERMINAL_CTRL) && [chars length]) axyne_terminal_unichar(_terminal,[chars characterAtIndex:0],mods,NULL); + else [self interpretKeyEvents:@[event]]; + _selectionStart=_selectionEnd=-1; [self setNeedsDisplay:YES]; +} +- (void)insertText:(id)value replacementRange:(NSRange)range { + (void)range; NSString *text=[value isKindOfClass:[NSAttributedString class]] ? [value string] : value; + for (NSUInteger i=0;i<[text length];++i) { uint32_t c=[text characterAtIndex:i]; if (c>=0xd800 && c<=0xdbff && i+1<[text length]) { uint32_t low=[text characterAtIndex:i+1]; if (low>=0xdc00 && low<=0xdfff) { ++i;c=0x10000+((c-0xd800)<<10)+low-0xdc00; } } axyne_terminal_unichar(_terminal,c,0,NULL); } + [self unmarkText]; [self setNeedsDisplay:YES]; +} +- (void)setMarkedText:(id)value selectedRange:(NSRange)selected replacementRange:(NSRange)range { (void)selected;(void)range; [_marked setAttributedString:[value isKindOfClass:[NSAttributedString class]] ? value : [[[NSAttributedString alloc] initWithString:value attributes:@{NSFontAttributeName:_font,NSForegroundColorAttributeName:color(_foreground),NSUnderlineStyleAttributeName:@1}] autorelease]]; [self setNeedsDisplay:YES]; } +- (void)unmarkText { [_marked deleteCharactersInRange:NSMakeRange(0,[_marked length])]; } +- (BOOL)hasMarkedText { return [_marked length]>0; } +- (NSRange)markedRange { return [_marked length] ? NSMakeRange(0,[_marked length]) : NSMakeRange(NSNotFound,0); } +- (NSRange)selectedRange { return NSMakeRange(NSNotFound,0); } +- (NSArray *)validAttributesForMarkedText { return @[]; } +- (NSAttributedString *)attributedSubstringForProposedRange:(NSRange)range actualRange:(NSRangePointer)actual { (void)range;if(actual)*actual=NSMakeRange(NSNotFound,0);return nil; } +- (NSUInteger)characterIndexForPoint:(NSPoint)point { (void)point;return NSNotFound; } +- (NSRect)firstRectForCharacterRange:(NSRange)range actualRange:(NSRangePointer)actual { (void)range;if(actual)*actual=NSMakeRange(0,0); AxyneTerminalSnapshot s={0};axyne_terminal_snapshot(_terminal,&s,NULL);NSRect rect=NSMakeRect(4+s.cursor_column*_cellWidth,4+s.cursor_row*_cellHeight,_cellWidth,_cellHeight);axyne_terminal_snapshot_free(&s);return [[self window] convertRectToScreen:[self convertRect:rect toView:nil]]; } +- (void)doCommandBySelector:(SEL)selector { (void)selector; } +@end diff --git a/src/features/ui/platform/macos/ui_macos.m b/src/features/ui/platform/macos/ui_macos.m index 637ea98..923ba14 100644 --- a/src/features/ui/platform/macos/ui_macos.m +++ b/src/features/ui/platform/macos/ui_macos.m @@ -1,4 +1,5 @@ #import +#import "terminal_view_macos.h" #include #include #include @@ -1437,7 +1438,7 @@ @interface AxyneWorkspaceView : NSView