diff --git a/CMakeLists.txt b/CMakeLists.txt index 2a83f8a..5d4a218 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,12 +18,17 @@ endif() # Separate archive for MinGW so Linux host builds keep libsashidown.a if(CMAKE_SYSTEM_NAME STREQUAL "Windows") set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a") - set(_MINGW_AR x86_64-w64-mingw32-ar) + # Use whatever archiver CMake found for the active toolchain. Hardcoding + # "x86_64-w64-mingw32-ar" only works on cross-build hosts, where binutils + # installs prefixed names; MSYS2's mingw-w64 toolchain ships plain ar.exe + # and ranlib.exe, so the hardcoded names are not found. + set(_MINGW_AR "${CMAKE_AR}") + set(_MINGW_RANLIB "${CMAKE_RANLIB}") add_custom_command( OUTPUT "${SASHIDOWN_A}" COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a - CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=x86_64-w64-mingw32-ranlib + CC=${CMAKE_C_COMPILER} AR=${_MINGW_AR} RANLIB=${_MINGW_RANLIB} COMMAND ${CMAKE_COMMAND} -E copy_if_different "${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}" COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean @@ -111,9 +116,11 @@ endif() if(CST_BUILD_TUI) if(WIN32) # Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT. + # PATH_SUFFIXES also covers the layout MSYS2's mingw-w64-pdcurses uses, + # which puts the headers under include/pdcurses/ rather than include/. find_path(PDCURSES_INCLUDE_DIR curses.h HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" - PATH_SUFFIXES include) + PATH_SUFFIXES include pdcurses include/pdcurses) find_library(PDCURSES_LIBRARY NAMES pdcurses pdcurses.dll curses HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" @@ -121,7 +128,11 @@ if(CST_BUILD_TUI) if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY) target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR}) target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY}) - target_compile_definitions(cst-agent PRIVATE CST_USE_PDCURSES=1) + # PDCurses suffixes its exported symbols with an ABI tag (endwin_u64_4400 + # etc.). MSYS2 builds the UTF-8 wide variant, so PDC_FORCE_UTF8 has to be + # defined or curses.h maps to the w64 names and the link fails. + target_compile_definitions(cst-agent PRIVATE + CST_USE_PDCURSES=1 PDC_FORCE_UTF8=1) message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})") else() message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)") @@ -143,9 +154,9 @@ else() endif() if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32) - # curl Schannel + networking + # curl Schannel + networking; winmm is needed by PDCurses (PlaySoundW) target_link_libraries(cst-agent PRIVATE - ws2_32 crypt32 secur32 bcrypt advapi32) + ws2_32 crypt32 secur32 bcrypt advapi32 winmm) endif() # --- tests --- diff --git a/docs/README-windows.md b/docs/README-windows.md index a4e6a3c..b2099d4 100644 --- a/docs/README-windows.md +++ b/docs/README-windows.md @@ -41,6 +41,35 @@ cmake --build build-win-tui Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and `libwinpthread-1.dll` into one folder. +## Build natively on Windows (MSYS2) + +The CMake build also works directly on Windows with MSYS2's mingw-w64 +toolchain -- no separate Linux host needed: + +```bash +# in an MSYS2 MINGW64 shell +pacman -S --needed --noconfirm \ + mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake mingw-w64-x86_64-make \ + mingw-w64-x86_64-curl mingw-w64-x86_64-pdcurses + +cd agent +cmake -B build-win -G "MinGW Makefiles" -DCMAKE_BUILD_TYPE=Release +cmake --build build-win --target cst-agent -j"$(nproc)" +``` + +`CST_BUILD_TUI=ON` (the default) picks up MSYS2's PDCurses automatically, +including its `include/pdcurses/` layout; set `-DPDCurses_ROOT=...` only for +a custom PDCurses. + +Two differences from the cross-build release layout above: + +- **Extra DLLs.** MSYS2's curl package is the shared OpenSSL build, so the + executable needs its whole dependency chain beside it. Collect it with + `ldd build-win/cst-agent.exe | grep mingw64`. +- **Only build the `cst-agent` target.** Some sources under `tests/` are + POSIX-only (two-argument `mkdir`, `setenv`, `arpa/inet.h`, `symlink`) and + do not compile with MinGW. The main program is unaffected. + ## Run ```bat diff --git a/include/os_compat.h b/include/os_compat.h index 51ac12e..4d72c7f 100644 --- a/include/os_compat.h +++ b/include/os_compat.h @@ -111,6 +111,40 @@ void os_localtime(const time_t *t, struct tm *out); int os_getpid(void); int os_chmod_private(const char *path); /* best-effort 0600; no-op/ok on Win */ +/* --- UTF-8 -------------------------------------------------------------- + * Encode one code point as UTF-8, independent of the C locale. + * + * wcrtomb() follows setlocale(). On Linux the environment normally selects a + * UTF-8 locale, but on Windows the C runtime maps setlocale(LC_ALL, "") to the + * ANSI code page -- 936 (GBK) on a Chinese system -- so wcrtomb() hands back + * GBK bytes where the protocol requires UTF-8, and the model receives + * mojibake. Windows' C runtime has no ".UTF-8" locale to switch to. + * + * Returns the number of bytes written (1..4), or 0 if the code point cannot be + * encoded. Surrogate halves (as they arrive on 16-bit wchar_t platforms for + * non-BMP code points) return 0 and are skipped. */ +int os_utf8_encode(unsigned long cp, char out[4]); + +/* Decode one UTF-8 sequence from s (at most avail bytes). + * Returns the number of bytes consumed (1..4) and stores the code point in + * *cp, or 0 if the bytes are not a valid, complete, non-overlong sequence. + * + * The counterpart of the encoder above, and needed for the same reason: + * mbrtowc() also follows setlocale(), so on Windows it would decode UTF-8 + * text as GBK and the screen would show mojibake. */ +int os_utf8_decode(const char *s, size_t avail, unsigned long *cp); + +/* --- libc gaps on Windows ------------------------------------------------ + * MinGW-w64 provides neither strcasestr() (a GNU extension) nor + * setenv()/unsetenv() (POSIX): they are simply absent from its headers. + * src/tui.c uses all three. Implemented in os_compat.c for _WIN32 only, + * so the Unix builds keep using the libc versions. */ +#if defined(_WIN32) +char *strcasestr(const char *haystack, const char *needle); +int setenv(const char *name, const char *value, int overwrite); +int unsetenv(const char *name); +#endif + #ifdef __cplusplus } #endif diff --git a/src/llm_openai.c b/src/llm_openai.c index fe18de3..84d896d 100644 --- a/src/llm_openai.c +++ b/src/llm_openai.c @@ -22,6 +22,45 @@ #define ERRBODY_MAX 4096 #define LLM_MAX_RETRIES 3 +/* Point libcurl at a CA bundle on Windows. + * + * An OpenSSL-based libcurl keeps the path it was built with -- MSYS2's is + * "/mingw64/etc/ssl/certs/ca-bundle.crt", which resolves to nothing once the + * binary runs outside an MSYS2 shell, and that build ignores CURL_CA_BUNDLE. + * Every HTTPS request then dies with CURLE_SSL_CACERT_BADFILE (77). + * + * Order: $CST_CA_BUNDLE, then ca-bundle.crt beside the executable. If neither + * exists nothing is set, so a libcurl linked against Schannel (the Windows + * certificate store) behaves exactly as before. */ +static void llm_apply_ca_bundle(CURL *curl) { +#if defined(_WIN32) + static char ca[OS_PATH_MAX]; + static int checked; + if (!checked) { + checked = 1; + const char *env = getenv("CST_CA_BUNDLE"); + if (env && env[0]) { + snprintf(ca, sizeof(ca), "%s", env); + } else { + char dir[OS_PATH_MAX]; + if (os_exe_dir(dir, sizeof(dir)) == 0) + os_path_join(ca, sizeof(ca), dir, "ca-bundle.crt"); + } + if (ca[0]) { + FILE *f = fopen(ca, "rb"); + if (f) + fclose(f); + else + ca[0] = '\0'; + } + } + if (ca[0]) + curl_easy_setopt(curl, CURLOPT_CAINFO, ca); +#else + (void)curl; +#endif +} + /* --- wire body ----------------------------------------------------------- */ static size_t wire_tool_cap(const cst_llm_request_t *req) { @@ -621,6 +660,7 @@ int cst_llm_chat_stream(const cst_llm_request_t *req, char *err, ret = -1; break; } + llm_apply_ca_bundle(curl); sse_reset(&s); s.curl = curl; if (rec_dir && rec_dir[0]) { @@ -765,10 +805,11 @@ int cst_llm_list_models(const char *api_base, const char *api_key, } CURL *curl = curl_easy_init(); if (!curl) { - snprintf(err, err_sz, "curl init failed"); - return -1; - } - char url[1024]; + snprintf(err, err_sz, "curl init failed"); + return -1; + } + llm_apply_ca_bundle(curl); + char url[1024]; size_t bl = strlen(api_base); while (bl && api_base[bl - 1] == '/') bl--; diff --git a/src/os_compat.c b/src/os_compat.c index 7fa3c73..a579117 100644 --- a/src/os_compat.c +++ b/src/os_compat.c @@ -1168,3 +1168,120 @@ void os_proc_kill(os_proc_t *p) { } } #endif + +/* --- UTF-8 --------------------------------------------------------------- + * See the declaration in os_compat.h for why wcrtomb() is not used. */ +int os_utf8_encode(unsigned long cp, char out[4]) { + if (cp < 0x80) { + out[0] = (char)cp; + return 1; + } + if (cp < 0x800) { + out[0] = (char)(0xC0 | (cp >> 6)); + out[1] = (char)(0x80 | (cp & 0x3F)); + return 2; + } + if (cp >= 0xD800 && cp <= 0xDFFF) + return 0; /* lone surrogate half from a 16-bit wchar_t */ + if (cp < 0x10000) { + out[0] = (char)(0xE0 | (cp >> 12)); + out[1] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[2] = (char)(0x80 | (cp & 0x3F)); + return 3; + } + if (cp <= 0x10FFFF) { + out[0] = (char)(0xF0 | (cp >> 18)); + out[1] = (char)(0x80 | ((cp >> 12) & 0x3F)); + out[2] = (char)(0x80 | ((cp >> 6) & 0x3F)); + out[3] = (char)(0x80 | (cp & 0x3F)); + return 4; + } + return 0; +} + +/* See the declaration in os_compat.h for why mbrtowc() is not used. */ +int os_utf8_decode(const char *s, size_t avail, unsigned long *cp) { + const unsigned char *p = (const unsigned char *)s; + int need, i; + unsigned long v; + static const unsigned long MINV[4] = {0, 0x80, 0x800, 0x10000}; + + if (!s || !cp || avail == 0) + return 0; + if (p[0] < 0x80) { + *cp = p[0]; + return 1; + } + if ((p[0] & 0xE0) == 0xC0) { + need = 1; + v = p[0] & 0x1Fu; + } else if ((p[0] & 0xF0) == 0xE0) { + need = 2; + v = p[0] & 0x0Fu; + } else if ((p[0] & 0xF8) == 0xF0) { + need = 3; + v = p[0] & 0x07u; + } else { + return 0; /* not a UTF-8 lead byte */ + } + if (avail < (size_t)need + 1) + return 0; /* truncated */ + for (i = 1; i <= need; i++) { + if ((p[i] & 0xC0) != 0x80) + return 0; /* bad continuation byte */ + v = (v << 6) | (p[i] & 0x3Fu); + } + if (v < MINV[need] || (v >= 0xD800 && v <= 0xDFFF) || v > 0x10FFFF) + return 0; /* overlong, surrogate, or out of range */ + *cp = v; + return need + 1; +} + +/* --- libc gaps on Windows ----------------------------------------------- + * strcasestr() is a GNU extension and setenv()/unsetenv() are POSIX; glibc + * has all three, MinGW-w64 has none of them. src/tui.c needs them (command + * filtering and ESCDELAY), so the Windows build supplies its own. + * + * The case folding here is ASCII-only, unlike glibc's locale-aware version. + * That is deliberate: these are used to match command and setting names, + * which are ASCII. + */ +#if defined(_WIN32) + +static int ascii_lower(int c) { + return (c >= 'A' && c <= 'Z') ? c + ('a' - 'A') : c; +} + +char *strcasestr(const char *haystack, const char *needle) { + if (!haystack || !needle) + return NULL; + if (!*needle) + return (char *)haystack; + for (; *haystack; haystack++) { + const char *h = haystack; + const char *n = needle; + while (*h && *n && ascii_lower((unsigned char)*h) == ascii_lower((unsigned char)*n)) { + h++; + n++; + } + if (!*n) + return (char *)haystack; + } + return NULL; +} + +int setenv(const char *name, const char *value, int overwrite) { + if (!name || !*name || strchr(name, '=')) + return -1; + if (!overwrite && getenv(name)) + return 0; + return _putenv_s(name, value ? value : ""); +} + +int unsetenv(const char *name) { + if (!name || !*name || strchr(name, '=')) + return -1; + return _putenv_s(name, ""); +} + +#endif /* _WIN32 */ diff --git a/src/textsel.c b/src/textsel.c index 0be8583..18bae3b 100644 --- a/src/textsel.c +++ b/src/textsel.c @@ -3,23 +3,29 @@ #endif #include "cst_textsel.h" +#include "os_compat.h" #include #include #include int cst_utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) { + unsigned long cp = 0; + int n; if (avail == 0) return -1; - mbstate_t st; - memset(&st, 0, sizeof(st)); - size_t n = mbrtowc(wc, s, avail, &st); - if (n == (size_t)-1 || n == (size_t)-2 || n == 0) { - *wc = (unsigned char)s[0]; + /* os_utf8_decode(), not mbrtowc(): the latter follows setlocale(), which on + * Windows means the ANSI code page, so UTF-8 text would be decoded as GBK + * and rendered as mojibake. */ + n = os_utf8_decode(s, avail, &cp); + if (n <= 0) { + /* Not a valid sequence: fall back to one raw byte, as before. */ + *wc = (wchar_t)(unsigned char)s[0]; *eaten = 1; return 0; } - *eaten = n; + *wc = (wchar_t)cp; + *eaten = (size_t)n; return 0; } diff --git a/src/tui.c b/src/tui.c index 2a1866a..c58c4a6 100644 --- a/src/tui.c +++ b/src/tui.c @@ -2966,12 +2966,10 @@ static void input_clear(tui_t *t) { input_set(t, ""); } static void input_put_wc(tui_t *t, wchar_t wc) { char mb[8]; - mbstate_t st; - memset(&st, 0, sizeof(st)); - size_t n = wcrtomb(mb, wc, &st); - if (n == (size_t)-1 || n == 0) + int n = os_utf8_encode((unsigned long)wc, mb); + if (n <= 0) return; - input_insert(t, mb, n); + input_insert(t, mb, (size_t)n); } static void input_put_byte(tui_t *t, char ch) { input_insert(t, &ch, 1); } @@ -3600,16 +3598,14 @@ static int picker_run(tui_t *t, const char *title, char items[][128], int n, continue; } if (iswprint(wch)) { - char mb[8]; - mbstate_t st; - memset(&st, 0, sizeof(st)); - size_t k = wcrtomb(mb, (wchar_t)wch, &st); - if (k != (size_t)-1 && qlen + k < sizeof(query)) { - memcpy(query + qlen, mb, k); - qlen += k; - query[qlen] = 0; - sel = top = 0; - } + char mb[8]; + int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb); + if (k > 0 && qlen + (size_t)k < sizeof(query)) { + memcpy(query + qlen, mb, (size_t)k); + qlen += (size_t)k; + query[qlen] = 0; + sel = top = 0; + } } } @@ -6143,15 +6139,13 @@ static void run_ask_modal(tui_t *t) { break; } if (iswprint(wch)) { - char mb[8]; - mbstate_t st; - memset(&st, 0, sizeof(st)); - size_t k = wcrtomb(mb, (wchar_t)wch, &st); - size_t n = strlen(t->ask_buf); - if (k != (size_t)-1 && n + k + 1 < sizeof(t->ask_buf)) { - memcpy(t->ask_buf + n, mb, k); - t->ask_buf[n + k] = 0; - } + char mb[8]; + int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb); + size_t n = strlen(t->ask_buf); + if (k > 0 && n + (size_t)k + 1 < sizeof(t->ask_buf)) { + memcpy(t->ask_buf + n, mb, (size_t)k); + t->ask_buf[n + k] = 0; + } } } curs_set(1);