fix: Windows core build, embedded init, byte-exact paths, version info

Closes the remaining core/lib/termio/GTK rows (final-Z2.md):
- ghostty-core compiles for Windows (Unix-only exec fields and file
  modes gated); checked with cargo check --target x86_64-pc-windows-gnu.
- Embedded init: ensure_locale, SIGPIPE ignored in ghostty_init for
  C/Swift hosts, Apple crash tags; the argv passed to ghostty_init feeds
  ghostty_config_load_cli_args; ghostty_config_load_file accepts
  non-UTF-8 paths.
- OSC 7 pwd, embedded surface cwd/command/env/initial input,
  ghostty_surface_text and GTK file drops keep raw bytes.
- XTVERSION, TERM_PROGRAM_VERSION, the About dialog and crash metadata
  use ghostty_build_info instead of hardcoded versions.

Test Plan:
- cargo test --workspace (3327 passed)
- cargo clippy --workspace --all-targets -- -D warnings
This commit is contained in:
ddidderr committed 2026-10-12 08:14:02 +02:00
1 parent 395cff8981
commit a9f3856b15
30 files changed
+863 -80

No files matched your search

+11 -2
View File
@@ -36,7 +36,14 @@ pub struct Keep {
impl Keep {
/// A NUL terminated copy of `s` (interior NULs are dropped).
fn cstr(&mut self, s: &str) -> *const c_char {
let c = CString::new(s.replace('\0', "")).unwrap_or_default();
self.cstr_bytes(s.as_bytes())
}
/// [`Keep::cstr`] for raw bytes.
fn cstr_bytes(&mut self, s: &[u8]) -> *const c_char {
let mut s = s.to_vec();
s.retain(|&b| b != 0);
let c = CString::new(s).unwrap_or_default();
let ptr = c.as_ptr();
// The heap buffer of a CString doesn't move when the Vec grows.
self.strings.push(c);
@@ -123,7 +130,9 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
Action::SetTitle(t) => v.set_title.title = keep.cstr(&t.title),
Action::SetTabTitle(t) => v.set_tab_title.title = keep.cstr(&t.title),
Action::PromptTitle(p) => v.prompt_title = *p as c_int,
Action::Pwd(p) => v.pwd.pwd = keep.cstr(&p.pwd),
Action::Pwd(p) => {
v.pwd.pwd = keep.cstr_bytes(&ghostty_os::osbytes::config_str_to_bytes(&p.pwd));
}
Action::MouseShape(s) => v.mouse_shape = *s as c_int,
Action::MouseVisibility(m) => v.mouse_visibility = *m as c_int,
Action::MouseOverLink(l) => {
+4 -3
View File
@@ -249,9 +249,10 @@ pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: *
return;
}
// SAFETY: `path` is NUL terminated.
let path = unsafe { CStr::from_ptr(path) }
.to_string_lossy()
.into_owned();
// Non-UTF-8 file names are valid on Unix; keep the bytes (reversible
// escape understood by the config file loader).
let path =
ghostty_os::osbytes::bytes_to_config_string(unsafe { CStr::from_ptr(path) }.to_bytes());
if let Err(err) = c.config.load_file(&path) {
log::error!("error loading config from file path={path} err={err}");
}
+81 -15
View File
@@ -42,6 +42,7 @@ use ghostty_input::{
binding::Action as BindingAction,
keycodes,
};
use ghostty_os::osbytes;
use ghostty_terminal::{
point::{Coordinate, Point, Tag as PointTag},
selection::Selection,
@@ -884,17 +885,22 @@ fn string_escape(input: &str) -> String {
unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_config_s) {
// If we have a working directory from the options then we set it.
// SAFETY: per the function contract.
if let Some(wd) =
unsafe { cstr(opts.working_directory) }.map(|c| c.to_string_lossy().into_owned())
if let Some(wd) = unsafe { cstr(opts.working_directory) }.map(CStr::to_bytes)
&& !wd.is_empty()
{
match std::fs::metadata(&wd) {
Err(err) => log::warn!("error opening requested working directory dir={wd} err={err}"),
// Raw bytes are kept (reversible escape in the String based config).
let wd_str = osbytes::bytes_to_config_string(wd);
let wd_os = osbytes::os_from_bytes(wd.to_vec());
let wd_disp = String::from_utf8_lossy(wd);
match std::fs::metadata(&wd_os) {
Err(err) => {
log::warn!("error opening requested working directory dir={wd_disp} err={err}");
}
Ok(m) if !m.is_dir() => {
log::warn!("requested working directory is not a directory dir={wd}");
log::warn!("requested working directory is not a directory dir={wd_disp}");
}
Ok(_) => {
let mut wd_val = WorkingDirectory::Path(wd);
let mut wd_val = WorkingDirectory::Path(wd_str);
wd_val.finalize();
config.working_directory = Some(wd_val);
}
@@ -903,10 +909,10 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
// If we have a command from the options then we set it.
// SAFETY: per the function contract.
if let Some(cmd) = unsafe { cstr(opts.command) }.map(|c| c.to_string_lossy().into_owned())
if let Some(cmd) = unsafe { cstr(opts.command) }.map(CStr::to_bytes)
&& !cmd.is_empty()
{
config.command = Some(Command::Shell(cmd));
config.command = Some(Command::Shell(osbytes::bytes_to_config_string(cmd)));
config.wait_after_command = true;
}
@@ -919,7 +925,10 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
let (Some(k), Some(v)) = (unsafe { cstr(var.key) }, unsafe { cstr(var.value) }) else {
continue;
};
config.env.put(&k.to_string_lossy(), &v.to_string_lossy());
config.env.put(
&osbytes::bytes_to_config_string(k.to_bytes()),
&osbytes::bytes_to_config_string(v.to_bytes()),
);
}
}
@@ -929,10 +938,9 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
// We need to escape the string because the "raw" field expects a
// Zig string.
config.input.list.clear();
config
.input
.list
.push(ReadableIO::Raw(string_escape(&input.to_string_lossy())));
config.input.list.push(ReadableIO::Raw(string_escape(
&osbytes::bytes_to_config_string(input.to_bytes()),
)));
}
// Wait after command
@@ -1107,7 +1115,7 @@ pub unsafe extern "C" fn ghostty_surface_inherited_config(
}
if should_inherit_working_directory(context, &config)
&& let Some(cwd) = core.pwd()
&& let Some(cwd) = core.pwd_bytes()
&& let Ok(c) = CString::new(cwd)
{
let mut keep = s.inherited_wd.borrow_mut();
@@ -1593,7 +1601,7 @@ pub unsafe extern "C" fn ghostty_surface_text(
// SAFETY: `text` points to `len` readable bytes.
unsafe { std::slice::from_raw_parts(text.cast::<u8>(), len) }
};
if let Err(err) = core.text_callback(&String::from_utf8_lossy(bytes)) {
if let Err(err) = core.text_callback_bytes(bytes) {
log::error!("error in key callback err={err}");
}
}
@@ -2155,3 +2163,61 @@ mod darwin {
fill_text(core, t, sel, result)
}
}
#[cfg(test)]
mod tests {
use std::os::unix::ffi::OsStrExt;
use super::*;
use crate::types::ghostty_env_var_s;
/// Surface options are raw C bytes in Zig; non-UTF-8 values must reach
/// the config unchanged (reversible byte escape).
#[test]
fn surface_options_keep_non_utf8_bytes() {
let td = tempfile::tempdir().unwrap();
let mut dir = td.path().as_os_str().as_bytes().to_vec();
dir.extend_from_slice(b"/d\xff");
std::fs::create_dir(std::ffi::OsStr::from_bytes(&dir)).unwrap();
let wd = CString::new(dir.clone()).unwrap();
let cmd = CString::new(&b"echo \xfe"[..]).unwrap();
let key = CString::new(&b"K\xfd"[..]).unwrap();
let val = CString::new(&b"v\xfc"[..]).unwrap();
let input = CString::new(&b"in\xff\n"[..]).unwrap();
let mut env = [ghostty_env_var_s {
key: key.as_ptr(),
value: val.as_ptr(),
}];
// SAFETY: all-zero is a valid value (null pointers, 0 enums).
let mut opts: ghostty_surface_config_s = unsafe { std::mem::zeroed() };
opts.working_directory = wd.as_ptr();
opts.command = cmd.as_ptr();
opts.env_vars = env.as_mut_ptr();
opts.env_var_count = 1;
opts.initial_input = input.as_ptr();
let mut config = Config::default();
// SAFETY: the pointers above are valid NUL terminated strings.
unsafe { apply_surface_options(&mut config, &opts) };
let got_wd = config
.working_directory
.as_ref()
.and_then(|w| w.value())
.unwrap();
assert_eq!(osbytes::config_str_to_bytes(got_wd), dir);
let Some(Command::Shell(c)) = &config.command else {
panic!("no command");
};
assert_eq!(osbytes::config_str_to_bytes(c), b"echo \xfe");
let (k, v) = config.env.iter().next().unwrap();
assert_eq!(osbytes::config_str_to_bytes(k), b"K\xfd");
assert_eq!(osbytes::config_str_to_bytes(v), b"v\xfc");
assert!(matches!(
config.input.list.first(),
Some(ReadableIO::Raw(_))
));
}
}
+47 -5
View File
@@ -4,12 +4,13 @@
use std::{
collections::HashMap,
ffi::{CStr, CString, c_char, c_int},
ffi::{CStr, CString, OsString, c_char, c_int},
sync::{Mutex, OnceLock},
};
use ghostty_cli::build_info::{self, BuildInfo};
use ghostty_core::global::{self, InitOptions};
use ghostty_os::osbytes;
use crate::types::{build_mode, ghostty_info_s, ghostty_string_s};
@@ -77,7 +78,8 @@ pub(crate) fn alloc_c_text(bytes: &[u8]) -> *const c_char {
/// `argv` must be null or point to `argc` valid NUL terminated strings.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ghostty_init(argc: usize, argv: *mut *mut c_char) -> c_int {
let mut args = Vec::with_capacity(argc);
// Raw argument bytes (Zig: `std.os.argv = argv[0..argc]`).
let mut raw: Vec<OsString> = Vec::with_capacity(argc);
if !argv.is_null() {
for i in 0..argc {
// SAFETY: the caller guarantees `argv[0..argc]` are valid.
@@ -87,10 +89,18 @@ pub unsafe extern "C" fn ghostty_init(argc: usize, argv: *mut *mut c_char) -> c_
}
// SAFETY: each argv entry is a NUL terminated string.
let c = unsafe { CStr::from_ptr(p) };
args.push(c.to_string_lossy().into_owned());
raw.push(osbytes::os_from_bytes(c.to_bytes().to_vec()));
}
}
// Later config CLI loading (`ghostty_config_load_cli_args`) must see
// these arguments rather than the process arguments.
ghostty_os::args::set_argv(raw.clone());
let args: Vec<String> = raw
.iter()
.map(|a| osbytes::os_to_config_string(a))
.collect();
// We first try to parse any action that we may be executing.
let action = match ghostty_cli::ghostty::detect(&args) {
Ok(a) => a,
@@ -101,16 +111,48 @@ pub unsafe extern "C" fn ghostty_init(argc: usize, argv: *mut *mut c_char) -> c_
};
let _ = ARGS.set(args);
// `+version` and friends report the library build.
build_info::set(lib_build_info());
// Setup our signal handlers before logging (Zig: `initSignals`). A C or
// Swift host does not get the Rust runtime's SIGPIPE ignore.
init_signals();
global::init_lib(InitOptions {
action: action.is_some(),
resources_dir: None,
crash: Some(lib_crash_options()),
});
// `+version` and friends report the library build.
build_info::set(lib_build_info());
0
}
/// Ignore SIGPIPE: it is common with how termio shuts down read threads
/// (Zig: `GlobalState.initSignals`; not on Windows).
fn init_signals() {
#[cfg(unix)]
// SAFETY: installing SIG_IGN for SIGPIPE has no memory safety impact.
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_IGN);
}
}
/// Crash metadata for the library artifact: no app runtime, CoreText +
/// Metal on Apple targets (so the GTK / fontconfig / OpenGL defaults are
/// not reported for the macOS app).
fn lib_crash_options() -> ghostty_crash::Options {
let info = build_info::get();
let tag = |s: &str| s.trim_start_matches('.').to_owned();
let mut o = global::default_crash_options();
o.release.clone_from(&info.version_string);
o.app_runtime = tag(&lib_build_info().app_runtime);
if cfg!(target_vendor = "apple") {
"coretext".clone_into(&mut o.font_backend);
"metal".clone_into(&mut o.renderer);
}
o
}
/// The build info of the library (Zig: `build_config` for the lib artifact:
/// no app runtime, CoreText + Metal on Apple targets).
fn lib_build_info() -> BuildInfo {