Windows native 2 #2

Merged
WHC merged 3 commits from XYZ/CST-agent:windows-native-2 into main 2026-10-06 19:51:28 +08:00
7 changed files with 272 additions and 40 deletions
+17 -6
View File
@@ -18,12 +18,17 @@ endif()
# Separate archive for MinGW so Linux host builds keep libsashidown.a # Separate archive for MinGW so Linux host builds keep libsashidown.a
if(CMAKE_SYSTEM_NAME STREQUAL "Windows") if(CMAKE_SYSTEM_NAME STREQUAL "Windows")
set(SASHIDOWN_A "${SD_DIR}/libsashidown-mingw.a") 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( add_custom_command(
OUTPUT "${SASHIDOWN_A}" OUTPUT "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" libsashidown.a 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 COMMAND ${CMAKE_COMMAND} -E copy_if_different
"${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}" "${SD_DIR}/libsashidown.a" "${SASHIDOWN_A}"
COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean COMMAND ${CMAKE_MAKE_PROGRAM} -C "${SD_DIR}" clean
@@ -111,9 +116,11 @@ endif()
if(CST_BUILD_TUI) if(CST_BUILD_TUI)
if(WIN32) if(WIN32)
# Prefer PDCurses (pdcurses / curses). Users may set PDCurses_ROOT. # 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 find_path(PDCURSES_INCLUDE_DIR curses.h
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
PATH_SUFFIXES include) PATH_SUFFIXES include pdcurses include/pdcurses)
find_library(PDCURSES_LIBRARY find_library(PDCURSES_LIBRARY
NAMES pdcurses pdcurses.dll curses NAMES pdcurses pdcurses.dll curses
HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}" HINTS ENV PDCurses_ROOT "${PDCurses_ROOT}"
@@ -121,7 +128,11 @@ if(CST_BUILD_TUI)
if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY) if(PDCURSES_INCLUDE_DIR AND PDCURSES_LIBRARY)
target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR}) target_include_directories(cst-agent PRIVATE ${PDCURSES_INCLUDE_DIR})
target_link_libraries(cst-agent PRIVATE ${PDCURSES_LIBRARY}) 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})") message(STATUS "TUI: PDCurses (${PDCURSES_LIBRARY})")
else() else()
message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)") message(FATAL_ERROR "CST_BUILD_TUI=ON on Windows requires PDCurses (set PDCurses_ROOT)")
@@ -143,9 +154,9 @@ else()
endif() endif()
if(CMAKE_SYSTEM_NAME STREQUAL "Windows" OR WIN32) 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 target_link_libraries(cst-agent PRIVATE
ws2_32 crypt32 secur32 bcrypt advapi32) ws2_32 crypt32 secur32 bcrypt advapi32 winmm)
endif() endif()
# --- tests --- # --- tests ---
+29
View File
@@ -41,6 +41,35 @@ cmake --build build-win-tui
Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and Copy `build-win-tui/cst-agent.exe`, `third_party/busybox/busybox.exe`, and
`libwinpthread-1.dll` into one folder. `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 ## Run
```bat ```bat
+34
View File
@@ -111,6 +111,40 @@ void os_localtime(const time_t *t, struct tm *out);
int os_getpid(void); int os_getpid(void);
int os_chmod_private(const char *path); /* best-effort 0600; no-op/ok on Win */ 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 #ifdef __cplusplus
} }
#endif #endif
+41
View File
@@ -22,6 +22,45 @@
#define ERRBODY_MAX 4096 #define ERRBODY_MAX 4096
#define LLM_MAX_RETRIES 3 #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 ----------------------------------------------------------- */ /* --- wire body ----------------------------------------------------------- */
static size_t wire_tool_cap(const cst_llm_request_t *req) { 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; ret = -1;
break; break;
} }
llm_apply_ca_bundle(curl);
sse_reset(&s); sse_reset(&s);
s.curl = curl; s.curl = curl;
if (rec_dir && rec_dir[0]) { if (rec_dir && rec_dir[0]) {
@@ -768,6 +808,7 @@ int cst_llm_list_models(const char *api_base, const char *api_key,
snprintf(err, err_sz, "curl init failed"); snprintf(err, err_sz, "curl init failed");
return -1; return -1;
} }
llm_apply_ca_bundle(curl);
char url[1024]; char url[1024];
size_t bl = strlen(api_base); size_t bl = strlen(api_base);
while (bl && api_base[bl - 1] == '/') while (bl && api_base[bl - 1] == '/')
+117
View File
@@ -1168,3 +1168,120 @@ void os_proc_kill(os_proc_t *p) {
} }
} }
#endif #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 */
+12 -6
View File
@@ -3,23 +3,29 @@
#endif #endif
#include "cst_textsel.h" #include "cst_textsel.h"
#include "os_compat.h"
#include <stdlib.h> #include <stdlib.h>
#include <string.h> #include <string.h>
#include <wctype.h> #include <wctype.h>
int cst_utf8_decode(const char *s, size_t avail, wchar_t *wc, size_t *eaten) { 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) if (avail == 0)
return -1; return -1;
mbstate_t st; /* os_utf8_decode(), not mbrtowc(): the latter follows setlocale(), which on
memset(&st, 0, sizeof(st)); * Windows means the ANSI code page, so UTF-8 text would be decoded as GBK
size_t n = mbrtowc(wc, s, avail, &st); * and rendered as mojibake. */
if (n == (size_t)-1 || n == (size_t)-2 || n == 0) { n = os_utf8_decode(s, avail, &cp);
*wc = (unsigned char)s[0]; if (n <= 0) {
/* Not a valid sequence: fall back to one raw byte, as before. */
*wc = (wchar_t)(unsigned char)s[0];
*eaten = 1; *eaten = 1;
return 0; return 0;
} }
*eaten = n; *wc = (wchar_t)cp;
*eaten = (size_t)n;
return 0; return 0;
} }
+10 -16
View File
@@ -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) { static void input_put_wc(tui_t *t, wchar_t wc) {
char mb[8]; char mb[8];
mbstate_t st; int n = os_utf8_encode((unsigned long)wc, mb);
memset(&st, 0, sizeof(st)); if (n <= 0)
size_t n = wcrtomb(mb, wc, &st);
if (n == (size_t)-1 || n == 0)
return; 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); } static void input_put_byte(tui_t *t, char ch) { input_insert(t, &ch, 1); }
@@ -3601,12 +3599,10 @@ static int picker_run(tui_t *t, const char *title, char items[][128], int n,
} }
if (iswprint(wch)) { if (iswprint(wch)) {
char mb[8]; char mb[8];
mbstate_t st; int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb);
memset(&st, 0, sizeof(st)); if (k > 0 && qlen + (size_t)k < sizeof(query)) {
size_t k = wcrtomb(mb, (wchar_t)wch, &st); memcpy(query + qlen, mb, (size_t)k);
if (k != (size_t)-1 && qlen + k < sizeof(query)) { qlen += (size_t)k;
memcpy(query + qlen, mb, k);
qlen += k;
query[qlen] = 0; query[qlen] = 0;
sel = top = 0; sel = top = 0;
} }
@@ -6144,12 +6140,10 @@ static void run_ask_modal(tui_t *t) {
} }
if (iswprint(wch)) { if (iswprint(wch)) {
char mb[8]; char mb[8];
mbstate_t st; int k = os_utf8_encode((unsigned long)(wchar_t)wch, mb);
memset(&st, 0, sizeof(st));
size_t k = wcrtomb(mb, (wchar_t)wch, &st);
size_t n = strlen(t->ask_buf); size_t n = strlen(t->ask_buf);
if (k != (size_t)-1 && n + k + 1 < sizeof(t->ask_buf)) { if (k > 0 && n + (size_t)k + 1 < sizeof(t->ask_buf)) {
memcpy(t->ask_buf + n, mb, k); memcpy(t->ask_buf + n, mb, (size_t)k);
t->ask_buf[n + k] = 0; t->ask_buf[n + k] = 0;
} }
} }