From a9f3856b1546fe0e834b5150fe107f4f9b478f20 Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 08:14:02 +0200 Subject: [PATCH] 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 --- Cargo.lock | 391 +++++++++++++++++- Cargo.toml | 4 + crates/ghostty-apprt-gtk/Cargo.toml | 1 + crates/ghostty-apprt-gtk/build.rs | 11 + crates/ghostty-apprt-gtk/src/class/surface.rs | 47 ++- .../src/class/surface_child_exited.rs | 4 +- crates/ghostty-apprt-gtk/src/class/window.rs | 4 +- crates/ghostty-apprt/src/action.rs | 5 + crates/ghostty-apprt/src/runtime_api.rs | 9 + crates/ghostty-core/Cargo.toml | 1 + crates/ghostty-core/src/global.rs | 53 ++- crates/ghostty-core/src/surface/binding.rs | 23 +- crates/ghostty-core/src/surface/clipboard.rs | 14 +- crates/ghostty-core/src/surface/mod.rs | 30 +- crates/ghostty-core/src/surface/mouse.rs | 2 +- crates/ghostty-core/tests/e2e_term.rs | 18 + crates/ghostty-lib/Cargo.toml | 5 + crates/ghostty-lib/src/action_c.rs | 13 +- crates/ghostty-lib/src/config_capi.rs | 7 +- crates/ghostty-lib/src/embedded.rs | 96 ++++- crates/ghostty-lib/src/main_c.rs | 52 ++- crates/ghostty-lib/tests/c_host.rs | 10 +- crates/ghostty-lib/tests/init_argv.rs | 61 +++ crates/ghostty-termio/Cargo.toml | 1 + crates/ghostty-termio/src/command.rs | 42 +- crates/ghostty-termio/src/exec.rs | 11 +- crates/ghostty-termio/src/stream_handler.rs | 8 +- crates/ghostty-termio/src/termio.rs | 2 +- docs/rewrite/notes/final-Z2.md | 8 + src/main.rs | 10 +- 30 files changed, 863 insertions(+), 80 deletions(-) create mode 100644 crates/ghostty-lib/tests/init_argv.rs create mode 100644 docs/rewrite/notes/final-Z2.md diff --git a/Cargo.lock b/Cargo.lock index c822c08..3e80d09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -325,6 +325,9 @@ name = "bitflags" version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -395,6 +398,18 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + [[package]] name = "cairo-rs" version = "0.22.9" @@ -547,6 +562,12 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -556,6 +577,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crash-context" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3748e166981fa3ba9dc54ff950ad0ed5ca4aab4b73eae21d9f539659fe806e28" +dependencies = [ + "cfg-if", + "libc", + "mach2", +] + [[package]] name = "crc32fast" version = "1.5.2" @@ -718,6 +750,15 @@ dependencies = [ "mint", ] +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "uuid", +] + [[package]] name = "defmt" version = "1.1.1" @@ -835,6 +876,34 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +[[package]] +name = "embed-resource" +version = "3.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml", + "vswhom", + "winreg", +] + +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "endi" version = "1.1.1" @@ -901,6 +970,15 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "error-graph" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b920e777967421aa5f9bf34f842c0ab6ba19b3bdb4a082946093860f5858879" +dependencies = [ + "serde", +] + [[package]] name = "euclid" version = "0.22.14" @@ -930,6 +1008,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "failspot" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c942e64b20ecd39933d5ff938ca4fdb6ef0d298cc3855b231179a5ef0b24948d" + [[package]] name = "fancy-regex" version = "0.11.0" @@ -1291,6 +1375,7 @@ dependencies = [ "gdk4-wayland", "gdk4-x11", "ghostty-apprt", + "ghostty-build-info", "ghostty-config", "ghostty-datastruct", "ghostty-input", @@ -1336,12 +1421,17 @@ dependencies = [ "ghostty-termio", ] +[[package]] +name = "ghostty-build-info" +version = "0.1.0" + [[package]] name = "ghostty-cli" version = "0.1.0" dependencies = [ "flate2", "ghostty-apprt", + "ghostty-build-info", "ghostty-config", "ghostty-crash", "ghostty-font", @@ -1378,6 +1468,7 @@ dependencies = [ "base64 0.23.1", "crossbeam-channel", "ghostty-apprt", + "ghostty-build-info", "ghostty-config", "ghostty-crash", "ghostty-datastruct", @@ -1405,6 +1496,8 @@ dependencies = [ "ghostty-os", "libc", "log", + "minidump", + "minidump-writer", "serde_json", "tempfile", "thiserror 2.0.21", @@ -1481,15 +1574,18 @@ dependencies = [ "dispatch2", "ghostty-apprt", "ghostty-bench", + "ghostty-build-info", "ghostty-cli", "ghostty-config", "ghostty-core", + "ghostty-crash", "ghostty-input", "ghostty-inspector", "ghostty-os", "ghostty-renderer", "ghostty-terminal", "ghostty-vt", + "libc", "log", "objc2", "objc2-core-foundation", @@ -1566,6 +1662,7 @@ dependencies = [ name = "ghostty-rs" version = "0.1.0" dependencies = [ + "embed-resource", "ghostty-apprt", "ghostty-apprt-gtk", "ghostty-cli", @@ -1605,6 +1702,7 @@ version = "0.1.0" dependencies = [ "crossbeam-channel", "ghostty-apprt", + "ghostty-build-info", "ghostty-config", "ghostty-crash", "ghostty-datastruct", @@ -1787,6 +1885,17 @@ dependencies = [ "system-deps 9.0.0", ] +[[package]] +name = "goblin" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daa0a64d21a7eb230583b4c5f4e23b7e4e57974f96620f42a7e75e08ae66d745" +dependencies = [ + "log", + "plain", + "scroll 0.12.0", +] + [[package]] name = "graphene-rs" version = "0.22.8" @@ -2303,6 +2412,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + [[package]] name = "malloc_buf" version = "0.0.6" @@ -2318,6 +2433,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memmem" version = "0.1.1" @@ -2333,6 +2457,83 @@ dependencies = [ "autocfg", ] +[[package]] +name = "minidump" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a03516f742a87e757019a3019707b38900663397d63cdb0442721c5dbd29ed6b" +dependencies = [ + "debugid", + "encoding_rs", + "memmap2", + "minidump-common 0.27.0", + "num-traits", + "procfs-core", + "prost", + "range-map", + "scroll 0.13.0", + "thiserror 2.0.21", + "time", + "tracing", + "uuid", +] + +[[package]] +name = "minidump-common" +version = "0.26.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e16d10087ae9e375bad7a40e8ef5504bc08e808ccc6019067ff9de42a84570f" +dependencies = [ + "bitflags 2.13.2", + "debugid", + "num-derive 0.4.2", + "num-traits", + "range-map", + "scroll 0.12.0", + "smart-default", +] + +[[package]] +name = "minidump-common" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8e6411f30cf59f8a4815820a9b5d0c38962a719f5c43f80f5afbb71e791af16" +dependencies = [ + "bitflags 2.13.2", + "debugid", + "num-derive 0.5.1", + "num-traits", + "range-map", + "scroll 0.13.0", + "smart-default", +] + +[[package]] +name = "minidump-writer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f742d7445a9d4c63826d302dadbbcb607745560108e88b21c4014a9724d09dbe" +dependencies = [ + "bitflags 2.13.2", + "byteorder", + "cfg-if", + "crash-context", + "error-graph", + "failspot", + "goblin", + "libc", + "log", + "mach2", + "memmap2", + "minidump-common 0.26.1", + "process-backend", + "procfs-core", + "scroll 0.12.0", + "serde", + "serde_json", + "thiserror 2.0.21", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2377,6 +2578,12 @@ dependencies = [ "windows-sys", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "naga" version = "30.0.1" @@ -2467,6 +2674,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "num-derive" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e98dc3b890f6c23a0f9d3d491a2823d0dea0fa656302a13dd225fa924112a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -2966,6 +3184,12 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" @@ -3051,6 +3275,63 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-backend" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c02e259a3522eccc4957ee6e922db4945648fb7730a41192d919518d06a530" +dependencies = [ + "libc", + "log", + "process-reader", + "serde", + "thiserror 2.0.21", +] + +[[package]] +name = "process-reader" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3024e1803f4b8c40cca8c1955243410354cb4d67f053af9210147416a7265535" +dependencies = [ + "libc", + "serde", +] + +[[package]] +name = "procfs-core" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6401bf7b6af22f78b563665d15a22e9aef27775b79b149a66ca022468a4e405" +dependencies = [ + "bitflags 2.13.2", + "hex", + "serde", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "psm" version = "0.1.24" @@ -3105,6 +3386,15 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +[[package]] +name = "range-map" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12a5a2d6c7039059af621472a4389be1215a816df61aa4d531cfe85264aee95f" +dependencies = [ + "num-traits", +] + [[package]] name = "ratatui" version = "0.30.2" @@ -3302,6 +3592,46 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scroll" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ab8598aa408498679922eff7fa985c25d58a90771bd6be794434c5277eab1a6" +dependencies = [ + "scroll_derive 0.12.1", +] + +[[package]] +name = "scroll" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1257cd4248b4132760d6524d6dda4e053bc648c9070b960929bf50cfb1e7add" +dependencies = [ + "scroll_derive 0.13.2", +] + +[[package]] +name = "scroll_derive" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "scroll_derive" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1a36a382ed65dbcc0ab47fd5e9a94112417ccd34560a392ef3b7b0f0ec39148" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.8", +] + [[package]] name = "semver" version = "1.0.28" @@ -3443,6 +3773,12 @@ version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "1.0.4" @@ -3470,6 +3806,17 @@ version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" +[[package]] +name = "smart-default" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eb01866308440fc64d6c44d9e86c5cc17adfe33c4d6eed55da9145044d0ffc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "stacker" version = "0.1.25" @@ -3681,7 +4028,7 @@ dependencies = [ "log", "memmem", "nix 0.29.0", - "num-derive", + "num-derive 0.4.2", "num-traits", "ordered-float", "pest", @@ -3757,6 +4104,7 @@ dependencies = [ "powerfmt", "serde_core", "time-core", + "time-macros", ] [[package]] @@ -3765,6 +4113,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -3848,6 +4206,7 @@ version = "0.1.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ + "log", "pin-project-lite", "tracing-attributes", "tracing-core", @@ -3968,6 +4327,26 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "vtparse" version = "0.6.2" @@ -4343,6 +4722,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.56.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10" +dependencies = [ + "cfg-if", + "windows-sys", +] + [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index 0910efc..09b9a15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,6 +62,7 @@ ghostty-core = { path = "crates/ghostty-core" } ghostty-apprt-gtk = { path = "crates/ghostty-apprt-gtk" } ghostty-apprt = { path = "crates/ghostty-apprt" } ghostty-cli = { path = "crates/ghostty-cli" } +ghostty-build-info = { path = "crates/ghostty-build-info" } ghostty-crash = { path = "crates/ghostty-crash" } ghostty-bench = { path = "crates/ghostty-bench" } ghostty-inspector = { path = "crates/ghostty-inspector" } @@ -119,3 +120,6 @@ overflow-checks = false lto = true incremental = false codegen-units = 1 + +[build-dependencies] +embed-resource = "3.0.12" diff --git a/crates/ghostty-apprt-gtk/Cargo.toml b/crates/ghostty-apprt-gtk/Cargo.toml index b58eb7a..288fbd7 100644 --- a/crates/ghostty-apprt-gtk/Cargo.toml +++ b/crates/ghostty-apprt-gtk/Cargo.toml @@ -23,6 +23,7 @@ ghostty-config.workspace = true ghostty-input.workspace = true ghostty-datastruct.workspace = true ghostty-inspector.workspace = true +ghostty-build-info.workspace = true ghostty-os.workspace = true ghostty-vt.workspace = true log = "0.4" diff --git a/crates/ghostty-apprt-gtk/build.rs b/crates/ghostty-apprt-gtk/build.rs index 0dad45c..d8a41fe 100644 --- a/crates/ghostty-apprt-gtk/build.rs +++ b/crates/ghostty-apprt-gtk/build.rs @@ -69,6 +69,17 @@ fn main() { println!("cargo:rerun-if-changed=resources"); println!("cargo:rerun-if-changed=build.rs"); + // Source tarballs ship the prebuilt GResource bundle so packagers need + // neither blueprint-compiler nor glib-compile-resources (Zig: + // `GhosttyDist.Resource` for `ghostty_resources.{c,h}`). As in Zig, it is + // never used in a git checkout. + println!("cargo:rerun-if-changed=dist/ghostty.gresource"); + let prebuilt = manifest.join("dist/ghostty.gresource"); + if prebuilt.is_file() && !manifest.join("../../.git").exists() { + std::fs::copy(&prebuilt, out.join("ghostty.gresource")).unwrap(); + return; + } + let mut xml = String::new(); xml.push_str("\n\n"); diff --git a/crates/ghostty-apprt-gtk/src/class/surface.rs b/crates/ghostty-apprt-gtk/src/class/surface.rs index faa21d7..fad1cf6 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface.rs @@ -1512,6 +1512,12 @@ impl Surface { clipboard::paste(self, text); } + /// [`Surface::paste`] for raw bytes (e.g. dropped file names that are + /// not valid UTF-8 must reach the pty unchanged). + pub fn paste_bytes(&self, bytes: &[u8]) { + clipboard::paste_bytes(self, bytes); + } + /// Focus this surface. This properly focuses the input part of /// our surface. (Shadows `WidgetExt::grab_focus` on purpose, like Zig.) pub fn grab_focus(&self) { @@ -2622,6 +2628,26 @@ mod clipboard { } } + /// Paste explicit bytes. Valid UTF-8 takes the normal text path; other + /// bytes use the byte-preserving paste (the confirmation dialog, which + /// is text based, carries them with the reversible byte escape). + pub(super) fn paste_bytes(surface: &Surface, bytes: &[u8]) { + if let Ok(text) = std::str::from_utf8(bytes) { + paste(surface, text); + return; + } + + let Some(core) = surface.core() else { return }; + match core.complete_paste_bytes(bytes, false) { + Ok(()) => {} + Err(ClipboardError::UnsafePaste | ClipboardError::UnauthorizedPaste) => { + let text = ghostty_os::osbytes::bytes_to_config_string(bytes); + show_clipboard_confirmation(surface, ClipboardRequest::Paste, &text); + } + Err(err) => log::warn!("failed to complete clipboard request err={err}"), + } + } + /// Get the specific type of clipboard for a widget. fn get(widget: >k::Widget, clipboard: Clipboard) -> gdk::Clipboard { match clipboard { @@ -2661,7 +2687,13 @@ mod clipboard { // Get our text let text = buf.text(&buf.start_iter(), &buf.end_iter(), false); - if let Err(err) = core.complete_clipboard_request(req, &text, true) { + let result = if matches!(req, ClipboardRequest::Paste) { + // Undo the byte escape used for non-UTF-8 pastes. + core.complete_paste_bytes(&ghostty_os::osbytes::config_str_to_bytes(&text), true) + } else { + core.complete_clipboard_request(req, &text, true) + }; + if let Err(err) = result { log::warn!("failed to complete clipboard request: {err}"); } }); @@ -3375,17 +3407,14 @@ mod imp { let mut w = ghostty_os::ShellEscapeWriter::new(&mut escaped); for file in list.files() { let Some(path) = file.path() else { continue }; - let path = path.to_string_lossy().into_owned(); - if let Err(err) = w - .write_all(path.as_bytes()) - .and_then(|()| w.write_all(b"\n")) - { + // Raw path bytes: non-UTF-8 names must survive. + let path = ghostty_os::osbytes::os_bytes(path.as_os_str()); + if let Err(err) = w.write_all(&path).and_then(|()| w.write_all(b"\n")) { log::error!("unable to write path to buffer: {err}"); } } } - let string = String::from_utf8_lossy(&escaped).into_owned(); - this.paste(&string); + this.paste_bytes(&escaped); return true; } @@ -3397,7 +3426,7 @@ mod imp { { let mut w = ghostty_os::ShellEscapeWriter::new(&mut escaped); if let Err(err) = w - .write_all(path.to_string_lossy().as_bytes()) + .write_all(&ghostty_os::osbytes::os_bytes(path.as_os_str())) .and_then(|()| w.write_all(b"\n")) { log::error!("unable to write path to buffer: {err}"); diff --git a/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs b/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs index c7faba1..7f45923 100644 --- a/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs +++ b/crates/ghostty-apprt-gtk/src/class/surface_child_exited.rs @@ -113,11 +113,11 @@ mod imp { return; }; if data.exit_code == 0 { - banner.set_title("Command succeeded"); + banner.set_title(&ghostty_os::i18n::tr("Command succeeded")); this.add_css_class("normal"); this.remove_css_class("abnormal"); } else { - banner.set_title("Command failed"); + banner.set_title(&ghostty_os::i18n::tr("Command failed")); this.remove_css_class("normal"); this.add_css_class("abnormal"); } diff --git a/crates/ghostty-apprt-gtk/src/class/window.rs b/crates/ghostty-apprt-gtk/src/class/window.rs index 67c4007..c180ebc 100644 --- a/crates/ghostty-apprt-gtk/src/class/window.rs +++ b/crates/ghostty-apprt-gtk/src/class/window.rs @@ -1662,7 +1662,7 @@ impl Window { .application_name(name) .developer_name(tr("Ghostty Developers")) .application_icon(icon) - .version(env!("CARGO_PKG_VERSION")) + .version(ghostty_build_info::VERSION_STRING) .issue_url("https://github.com/ghostty-org/ghostty/issues") .website(website) .build(); @@ -1675,7 +1675,7 @@ impl Window { ("program-name", &name), ("logo-icon-name", &icon), ("title", &tr("About Ghostty")), - ("version", &env!("CARGO_PKG_VERSION")), + ("version", &ghostty_build_info::VERSION_STRING), ("website", &website), ], ); diff --git a/crates/ghostty-apprt/src/action.rs b/crates/ghostty-apprt/src/action.rs index ede94bd..6b90949 100644 --- a/crates/ghostty-apprt/src/action.rs +++ b/crates/ghostty-apprt/src/action.rs @@ -649,6 +649,11 @@ impl std::fmt::Display for SetTitle { #[derive(Debug, Clone, PartialEq, Eq)] pub struct Pwd { + /// The working directory as reported by OSC 7. Zig keeps raw bytes; here + /// non-UTF-8 bytes are carried with the reversible escape of + /// `ghostty_os::osbytes` (valid UTF-8 is unchanged). Decode with + /// `osbytes::config_str_to_bytes` / `config_str_to_os` before using it + /// as a path or handing it to C. pub pwd: String, } diff --git a/crates/ghostty-apprt/src/runtime_api.rs b/crates/ghostty-apprt/src/runtime_api.rs index 751455b..e8b9fa4 100644 --- a/crates/ghostty-apprt/src/runtime_api.rs +++ b/crates/ghostty-apprt/src/runtime_api.rs @@ -201,6 +201,15 @@ pub trait CoreSurface { data: &str, confirmed: bool, ) -> Result<(), ClipboardError>; + /// [`CoreSurface::complete_clipboard_request`] for a paste of raw bytes + /// (Zig data is `[]const u8`). The default converts lossily. + fn complete_paste_bytes(&self, data: &[u8], confirmed: bool) -> Result<(), ClipboardError> { + self.complete_clipboard_request( + ClipboardRequest::Paste, + &String::from_utf8_lossy(data), + confirmed, + ) + } /// After the user confirms (`surface.config.clipboard_read = .allow`). fn set_clipboard_read_allowed(&self, allow: bool); /// See [`CoreSurface::set_clipboard_read_allowed`] (`clipboard_write`). diff --git a/crates/ghostty-core/Cargo.toml b/crates/ghostty-core/Cargo.toml index 89a2cea..5c2cb93 100644 --- a/crates/ghostty-core/Cargo.toml +++ b/crates/ghostty-core/Cargo.toml @@ -14,6 +14,7 @@ ghostty-datastruct.workspace = true ghostty-font.workspace = true ghostty-inspector.workspace = true ghostty-input.workspace = true +ghostty-build-info.workspace = true ghostty-os.workspace = true ghostty-renderer.workspace = true ghostty-terminal.workspace = true diff --git a/crates/ghostty-core/src/global.rs b/crates/ghostty-core/src/global.rs index 4b55066..76ef226 100644 --- a/crates/ghostty-core/src/global.rs +++ b/crates/ghostty-core/src/global.rs @@ -116,7 +116,7 @@ pub struct GlobalState { static STATE: OnceLock = OnceLock::new(); /// Options for [`init`]. -#[derive(Clone, Copy, Default)] +#[derive(Clone, Default)] pub struct InitOptions { /// A CLI action is being executed: disable stderr logging by default /// since the action writes to stderr/stdout itself. @@ -126,6 +126,32 @@ pub struct InitOptions { /// `apprt.runtime.resourcesDir`, e.g. GTK's Flatpak host path lookup). /// `None` uses the generic `ghostty_os::resourcesdir::resources_dir`. pub resources_dir: Option std::io::Result>, + + /// Crash reporting metadata (version, build mode, runtime, font backend + /// and renderer tags; Zig: `build_config`). `None` uses + /// [`default_crash_options`]. + pub crash: Option, +} + +/// Crash metadata for this build when the caller supplies none: the +/// version from the shared build info and the compile-time platform +/// defaults (Metal + CoreText on Apple targets, fontconfig + OpenGL +/// elsewhere). +#[must_use] +pub fn default_crash_options() -> ghostty_crash::Options { + let mut o = ghostty_crash::Options { + release: ghostty_build_info::VERSION_STRING.to_owned(), + build_mode: ghostty_build_info::BUILD_MODE + .trim_start_matches('.') + .to_owned(), + ..ghostty_crash::Options::default() + }; + if cfg!(target_vendor = "apple") { + "none".clone_into(&mut o.app_runtime); + "coretext".clone_into(&mut o.font_backend); + "metal".clone_into(&mut o.renderer); + } + o } /// The global state. If [`init`] was never called, a default state (no @@ -182,13 +208,17 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat let rlimits = ResourceLimits::init(); // Initialize our crash reporting. - if let Err(err) = ghostty_crash::init(&ghostty_crash::Options { - release: ghostty_termio::stream_handler::VERSION_STRING.to_owned(), - ..ghostty_crash::Options::default() - }) { + let crash_opts = opts.crash.clone().unwrap_or_else(default_crash_options); + if let Err(err) = ghostty_crash::init(&crash_opts) { log::warn!("sentry init failed, no crash capture available err={err}"); } + // We need to make sure the process locale is set properly. Locale + // affects a lot of behaviors in a shell. + if let Some(locale) = ghostty_os::ensure_locale() { + log::debug!("locale set locale={locale}"); + } + // Find our resources directory once for the app so every launch // hereafter can use this cached value. let discover = opts @@ -352,6 +382,19 @@ mod tests { assert_eq!(log_scope("other::x"), "other"); } + #[test] + fn crash_defaults_use_shared_version() { + let o = default_crash_options(); + assert_eq!(o.release, ghostty_build_info::VERSION_STRING); + if cfg!(target_vendor = "apple") { + assert_eq!(o.renderer, "metal"); + assert_eq!(o.font_backend, "coretext"); + assert_eq!(o.app_runtime, "none"); + } else { + assert_eq!(o.renderer, "opengl"); + } + } + #[test] fn logging_parse() { assert!(Logging::parse("stderr").stderr); diff --git a/crates/ghostty-core/src/surface/binding.rs b/crates/ghostty-core/src/surface/binding.rs index 39c146b..ce865bc 100644 --- a/crates/ghostty-core/src/surface/binding.rs +++ b/crates/ghostty-core/src/surface/binding.rs @@ -424,6 +424,7 @@ pub fn write_screen_temp_file( loc: WriteScreenLoc, emit: WriteScreenFormat, ) -> Result { + #[cfg(unix)] use std::os::unix::fs::OpenOptionsExt as _; // Create a temporary directory to store our scrollback. @@ -432,12 +433,11 @@ pub fn write_screen_temp_file( let file_path = tmp_dir.path().join(&filename); let result = (|| -> Result { - let mut file = std::fs::OpenOptions::new() - .write(true) - .create(true) - .truncate(true) - .mode(0o600) - .open(&file_path)?; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + opts.mode(0o600); + let mut file = opts.open(&file_path)?; file.write_all(contents.as_bytes())?; file.flush()?; Ok(std::fs::canonicalize(&file_path)? @@ -1272,10 +1272,13 @@ mod tests { assert!(path.ends_with("screen.txt")); let read = std::fs::read_to_string(&path).unwrap(); assert_eq!(read, s); - let mode = std::os::unix::fs::PermissionsExt::mode( - &std::fs::metadata(&path).unwrap().permissions(), - ); - assert_eq!(mode & 0o777, 0o600); + #[cfg(unix)] + { + let mode = std::os::unix::fs::PermissionsExt::mode( + &std::fs::metadata(&path).unwrap().permissions(), + ); + assert_eq!(mode & 0o777, 0o600); + } let _ = std::fs::remove_dir_all(std::path::Path::new(&path).parent().unwrap()); } diff --git a/crates/ghostty-core/src/surface/clipboard.rs b/crates/ghostty-core/src/surface/clipboard.rs index c1c77d0..a91b278 100644 --- a/crates/ghostty-core/src/surface/clipboard.rs +++ b/crates/ghostty-core/src/surface/clipboard.rs @@ -215,6 +215,12 @@ impl Surface { } } + /// [`Surface::complete_clipboard_request`] for a paste of raw bytes + /// (Zig passes `[]const u8`; e.g. dropped non-UTF-8 file names). + pub fn complete_paste_bytes(&self, data: &[u8], confirmed: bool) -> Result<(), ClipboardError> { + self.complete_clipboard_paste(data, confirmed) + } + /// Paste text (also the target of `text_callback`). pub(crate) fn complete_clipboard_paste( &self, @@ -390,7 +396,13 @@ impl Surface { /// `textCallback`: text input that is pasted verbatim. pub fn text_callback(&self, text: &str) -> Result<(), ClipboardError> { + self.text_callback_bytes(text.as_bytes()) + } + + /// [`Surface::text_callback`] for raw bytes (Zig takes `[]const u8`; + /// the embedded `ghostty_surface_text` forwards the host bytes as is). + pub fn text_callback_bytes(&self, text: &[u8]) -> Result<(), ClipboardError> { let _crash = self.crash_guard(); - self.complete_clipboard_paste(text.as_bytes(), true) + self.complete_clipboard_paste(text, true) } } diff --git a/crates/ghostty-core/src/surface/mod.rs b/crates/ghostty-core/src/surface/mod.rs index 1d521ed..15ed2b5 100644 --- a/crates/ghostty-core/src/surface/mod.rs +++ b/crates/ghostty-core/src/surface/mod.rs @@ -471,10 +471,15 @@ impl Surface { .and_then(|wd| wd.value()) .map(str::to_owned); ecfg.resources_dir = global::state().resources_dir.host().map(str::to_owned); - ecfg.rt_pre_exec_info = ghostty_termio::command::RtPreExecInfo::init(&config); - ecfg.rt_post_fork_info = ghostty_termio::command::RtPostForkInfo::init(&config); - ecfg.create_scope = rt_surface.create_scope_fn(); - ecfg.restore_rlimit = global::state().rlimits.nofile; + // The Windows exec backend has no pre-exec/post-fork hooks, + // cgroup scopes or rlimits (Zig: Linux/POSIX only). + #[cfg(unix)] + { + ecfg.rt_pre_exec_info = ghostty_termio::command::RtPreExecInfo::init(&config); + ecfg.rt_post_fork_info = ghostty_termio::command::RtPostForkInfo::init(&config); + ecfg.create_scope = rt_surface.create_scope_fn(); + ecfg.restore_rlimit = global::state().rlimits.nofile; + } let exec = Exec::new(ecfg)?; let args = exec.args().to_vec(); @@ -935,7 +940,9 @@ impl Surface { } Message::PwdChange(w) => { - let pwd = String::from_utf8_lossy(w.slice()).into_owned(); + // Keep the OSC 7 bytes (see `Pwd`): non-UTF-8 bytes are + // carried with the reversible byte escape. + let pwd = ghostty_os::osbytes::bytes_to_config_string(w.slice()); self.perform(RtAction::Pwd(Pwd { pwd })); } @@ -1364,7 +1371,14 @@ impl Surface { pub fn pwd(&self) -> Option { let t = self.lock_terminal(); let pwd = t.get_pwd()?; - Some(String::from_utf8_lossy(pwd).into_owned()) + Some(ghostty_os::osbytes::bytes_to_config_string(pwd)) + } + + /// The raw bytes of [`Surface::pwd`] (Zig returns the OSC 7 bytes as is). + #[must_use] + pub fn pwd_bytes(&self) -> Option> { + let t = self.lock_terminal(); + t.get_pwd().map(<[u8]>::to_vec) } /// Returns the x/y coordinate of where the IME (Input Method Editor) @@ -2055,6 +2069,10 @@ impl CoreSurface for Surface { Surface::complete_clipboard_request(self, req, data, confirmed) } + fn complete_paste_bytes(&self, data: &[u8], confirmed: bool) -> Result<(), ClipboardError> { + Surface::complete_paste_bytes(self, data, confirmed) + } + fn set_clipboard_read_allowed(&self, allow: bool) { use ghostty_config::types_b::ClipboardAccess; self.set_config_clipboard( diff --git a/crates/ghostty-core/src/surface/mouse.rs b/crates/ghostty-core/src/surface/mouse.rs index 2d29a6a..dc580d9 100644 --- a/crates/ghostty-core/src/surface/mouse.rs +++ b/crates/ghostty-core/src/surface/mouse.rs @@ -1664,7 +1664,7 @@ impl Surface { sel: link.selection, trim: false, }); - let pwd = t.get_pwd().map(|p| String::from_utf8_lossy(p).into_owned()); + let pwd = t.get_pwd().map(ghostty_os::osbytes::bytes_to_config_string); resolve_path_for_opening(pwd.as_deref(), &str).unwrap_or(str) } diff --git a/crates/ghostty-core/tests/e2e_term.rs b/crates/ghostty-core/tests/e2e_term.rs index 0c8e83e..23d3010 100644 --- a/crates/ghostty-core/tests/e2e_term.rs +++ b/crates/ghostty-core/tests/e2e_term.rs @@ -114,6 +114,24 @@ fn pwd_osc7() { assert_eq!(h.surface.pwd().as_deref(), Some("/tmp/a b")); } +#[test] +fn pwd_osc7_keeps_non_utf8_bytes() { + let h = harness!(Harness::recorder( + r"\033]7;file://localhost/tmp/a%%FFb\033\\", + &[] + )); + let pwd = h.wait_action("pwd", |a| match a { + Action::Pwd(p) if !p.pwd.is_empty() => Some(p.pwd.clone()), + _ => None, + }); + assert_eq!( + ghostty_os::osbytes::config_str_to_bytes(&pwd), + b"/tmp/a\xffb" + ); + assert_eq!(h.surface.pwd_bytes().as_deref(), Some(&b"/tmp/a\xffb"[..])); + assert_eq!(h.surface.pwd().as_deref(), Some(pwd.as_str())); +} + #[test] fn bell_rings_once_per_burst() { let h = harness!(Harness::recorder(r"\007", &[])); diff --git a/crates/ghostty-lib/Cargo.toml b/crates/ghostty-lib/Cargo.toml index 5f86f1d..2f68de8 100644 --- a/crates/ghostty-lib/Cargo.toml +++ b/crates/ghostty-lib/Cargo.toml @@ -23,6 +23,7 @@ ghostty-bench.workspace = true ghostty-cli.workspace = true ghostty-config.workspace = true ghostty-core.workspace = true +ghostty-crash.workspace = true ghostty-input.workspace = true ghostty-inspector.workspace = true ghostty-os.workspace = true @@ -31,6 +32,9 @@ ghostty-terminal.workspace = true ghostty-vt.workspace = true log = "0.4.34" +[target.'cfg(unix)'.dependencies] +libc = "0.2.190" + [target.'cfg(target_vendor = "apple")'.dependencies] block2 = "0.6.2" dispatch2 = "0.3.1" @@ -40,6 +44,7 @@ objc2-foundation = "0.3.2" objc2-metal = { version = "0.3.2", features = ["block2"] } [dev-dependencies] +ghostty-build-info.workspace = true tempfile = "3" # The workspace forbids `unsafe`; this crate is the C ABI boundary, so it diff --git a/crates/ghostty-lib/src/action_c.rs b/crates/ghostty-lib/src/action_c.rs index 204d34a..eeeac05 100644 --- a/crates/ghostty-lib/src/action_c.rs +++ b/crates/ghostty-lib/src/action_c.rs @@ -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) => { diff --git a/crates/ghostty-lib/src/config_capi.rs b/crates/ghostty-lib/src/config_capi.rs index 5d37e9e..0120e2a 100644 --- a/crates/ghostty-lib/src/config_capi.rs +++ b/crates/ghostty-lib/src/config_capi.rs @@ -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}"); } diff --git a/crates/ghostty-lib/src/embedded.rs b/crates/ghostty-lib/src/embedded.rs index 8dc6632..82ec7be 100644 --- a/crates/ghostty-lib/src/embedded.rs +++ b/crates/ghostty-lib/src/embedded.rs @@ -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::(), 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(_)) + )); + } +} diff --git a/crates/ghostty-lib/src/main_c.rs b/crates/ghostty-lib/src/main_c.rs index 7b7c217..53146af 100644 --- a/crates/ghostty-lib/src/main_c.rs +++ b/crates/ghostty-lib/src/main_c.rs @@ -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 = 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 = 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 { diff --git a/crates/ghostty-lib/tests/c_host.rs b/crates/ghostty-lib/tests/c_host.rs index d824333..52b21fa 100644 --- a/crates/ghostty-lib/tests/c_host.rs +++ b/crates/ghostty-lib/tests/c_host.rs @@ -43,7 +43,10 @@ fn host_program_passes() { "host failed\nstdout:\n{stdout}\nstderr:\n{stderr}" ); assert!(stdout.contains("OK"), "stdout:\n{stdout}"); - assert!(stdout.contains("version=1.3.1"), "stdout:\n{stdout}"); + assert!( + stdout.contains(&format!("version={}", ghostty_build_info::VERSION_STRING)), + "stdout:\n{stdout}" + ); } #[test] @@ -55,7 +58,10 @@ fn cli_try_action_runs_version_and_exits() { "stderr: {}", String::from_utf8_lossy(&out.stderr) ); - assert!(stdout.starts_with("Ghostty 1.3.1"), "stdout:\n{stdout}"); + assert!( + stdout.starts_with(&format!("Ghostty {}", ghostty_build_info::VERSION_STRING)), + "stdout:\n{stdout}" + ); // The action ran instead of the rest of the program. assert!(!stdout.contains("OK")); } diff --git a/crates/ghostty-lib/tests/init_argv.rs b/crates/ghostty-lib/tests/init_argv.rs new file mode 100644 index 0000000..9231f8e --- /dev/null +++ b/crates/ghostty-lib/tests/init_argv.rs @@ -0,0 +1,61 @@ +//! `ghostty_init(argc, argv)` semantics from `main_c.zig`: the supplied +//! argument vector (not the process arguments) feeds the later config CLI +//! loading, and SIGPIPE is ignored for C / Swift hosts. +#![cfg(unix)] +#![allow( + unsafe_code, + clippy::unwrap_used, + clippy::expect_used, + clippy::undocumented_unsafe_blocks +)] + +use std::ffi::{CString, c_char}; + +use ghostty::{config_capi::*, main_c::ghostty_init, types::ghostty_config_t}; + +fn get_bool(cfg: ghostty_config_t, key: &str) -> bool { + let mut out = false; + let ok = + unsafe { ghostty_config_get(cfg, (&raw mut out).cast(), key.as_ptr().cast(), key.len()) }; + assert!(ok); + out +} + +#[test] +fn init_argv_feeds_config_cli_and_ignores_sigpipe() { + // Start from the default disposition so that the check is meaningful. + unsafe { libc::signal(libc::SIGPIPE, libc::SIG_DFL) }; + + // Not the process argv: the test harness argv has no `--maximize`. + let owned: Vec = [&b"ghostty"[..], b"--maximize=true"] + .iter() + .map(|a| CString::new(*a).unwrap()) + .collect(); + let mut argv: Vec<*mut c_char> = owned.iter().map(|c| c.as_ptr().cast_mut()).collect(); + assert_eq!(unsafe { ghostty_init(argv.len(), argv.as_mut_ptr()) }, 0); + + let mut sa: libc::sigaction = unsafe { std::mem::zeroed() }; + unsafe { libc::sigaction(libc::SIGPIPE, std::ptr::null(), &mut sa) }; + assert_eq!(sa.sa_sigaction, libc::SIG_IGN); + + let cfg = ghostty_config_new(); + unsafe { ghostty_config_load_cli_args(cfg) }; + assert!(get_bool(cfg, "maximize")); + unsafe { ghostty_config_free(cfg) }; +} + +#[test] +fn config_load_file_accepts_non_utf8_path() { + use std::os::unix::ffi::OsStrExt; + let dir = tempfile::tempdir().unwrap(); + let mut name = dir.path().as_os_str().as_bytes().to_vec(); + name.extend_from_slice(b"/cfg\xff"); + let path = std::ffi::OsStr::from_bytes(&name); + std::fs::write(path, "maximize = true\n").unwrap(); + + let cfg = ghostty_config_new(); + let c = CString::new(name).unwrap(); + unsafe { ghostty_config_load_file(cfg, c.as_ptr()) }; + assert!(get_bool(cfg, "maximize")); + unsafe { ghostty_config_free(cfg) }; +} diff --git a/crates/ghostty-termio/Cargo.toml b/crates/ghostty-termio/Cargo.toml index d2b9cce..2f2a00e 100644 --- a/crates/ghostty-termio/Cargo.toml +++ b/crates/ghostty-termio/Cargo.toml @@ -10,6 +10,7 @@ ghostty-apprt.workspace = true ghostty-config.workspace = true ghostty-crash.workspace = true ghostty-datastruct.workspace = true +ghostty-build-info.workspace = true ghostty-os.workspace = true ghostty-renderer.workspace = true ghostty-terminal.workspace = true diff --git a/crates/ghostty-termio/src/command.rs b/crates/ghostty-termio/src/command.rs index 1d1983c..9dfc920 100644 --- a/crates/ghostty-termio/src/command.rs +++ b/crates/ghostty-termio/src/command.rs @@ -18,7 +18,7 @@ use std::{ panic::{AssertUnwindSafe, catch_unwind}, }; -use ghostty_os::file::Rlimit; +use ghostty_os::{file::Rlimit, osbytes::config_str_to_bytes}; use rustix::{ io::Errno, process::{self, WaitOptions}, @@ -264,13 +264,13 @@ impl Command { } fn prepare(&self) -> Result { - let path = CString::new(self.path.as_str())?; + let path = CString::new(config_str_to_bytes(&self.path))?; let argv = if self.args.is_empty() { vec![path.clone()] } else { self.args .iter() - .map(|a| CString::new(a.as_str())) + .map(|a| CString::new(config_str_to_bytes(a))) .collect::>()? }; let envp = match &self.env { @@ -297,7 +297,7 @@ impl Command { #[cfg(target_vendor = "apple")] let candidates = exec_candidates(&self.path, std::env::var("PATH").ok().as_deref()) .into_iter() - .map(CString::new) + .map(|c| CString::new(config_str_to_bytes(&c))) .collect::, _>>()?; Ok(Prepared { path, @@ -315,7 +315,10 @@ impl Command { pub fn start(&mut self) -> Result<(), StartError> { // IMPORTANT: do all allocation prior to the fork(). let prep = self.prepare()?; - let cwd = self.cwd.as_deref().and_then(|c| CString::new(c).ok()); + let cwd = self + .cwd + .as_deref() + .and_then(|c| CString::new(config_str_to_bytes(c)).ok()); let mut argv_ptrs: Vec<*const c_char> = prep.argv.iter().map(|c| c.as_ptr()).collect(); argv_ptrs.push(std::ptr::null()); @@ -876,6 +879,35 @@ mod tests { assert_eq!("/tmp\n", read_all(stdout)); } + #[test] + fn non_utf8_cwd_and_args_are_preserved() { + use ghostty_os::osbytes::{bytes_to_config_string, os_from_bytes}; + let td = TempDir::init().unwrap(); + let stdout = test_file(&td, "stdout.txt"); + + // A directory whose name is not valid UTF-8, handed over with the + // reversible byte escape used by the String based layers. + let mut raw = ghostty_os::osbytes::os_bytes(td.path().as_os_str()); + raw.extend_from_slice(b"/d\xff"); + std::fs::create_dir(os_from_bytes(raw.clone())).unwrap(); + + let mut cmd = Command::new("/bin/sh", &["/bin/sh", "-c", "pwd -P; printf %s \"$0\""]); + cmd.args.push(bytes_to_config_string(b"a\xffb")); + cmd.stdout = Some(stdout.try_clone().unwrap().into()); + cmd.cwd = Some(bytes_to_config_string(&raw)); + cmd.start().unwrap(); + assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0)); + + let mut bytes = Vec::new(); + std::io::Read::read_to_end( + &mut std::fs::File::open(td.path().join("stdout.txt")).unwrap(), + &mut bytes, + ) + .unwrap(); + assert!(bytes.windows(2).any(|w| w == b"d\xff"), "{bytes:?}"); + assert!(bytes.ends_with(b"a\xffb"), "{bytes:?}"); + } + #[test] fn invalid_cwd_is_ignored() { let td = TempDir::init().unwrap(); diff --git a/crates/ghostty-termio/src/exec.rs b/crates/ghostty-termio/src/exec.rs index 14a3f3f..fa5e9e2 100644 --- a/crates/ghostty-termio/src/exec.rs +++ b/crates/ghostty-termio/src/exec.rs @@ -181,7 +181,7 @@ impl Exec { // set that on the terminal now. This allows rapidly initializing // new surfaces to use the proper pwd. if let Some(cwd) = &self.subprocess.cwd - && let Err(err) = term.set_pwd(cwd.as_bytes()) + && let Err(err) = term.set_pwd(&ghostty_os::osbytes::config_str_to_bytes(cwd)) { log::warn!("error setting initial pwd err={err:?}"); } @@ -907,7 +907,10 @@ impl Subprocess { // Add the environment variables that override any others. for (k, v) in cfg.env_override.iter() { - env.insert(k, v); + env.insert( + ghostty_os::osbytes::config_str_to_os(k), + ghostty_os::osbytes::config_str_to_os(v), + ); } // Build our args list @@ -925,7 +928,7 @@ impl Subprocess { // https://github.com/kovidgoyal/kitty/issues/1595 // https://github.com/ghostty-org/ghostty/discussions/7769 if let Some(pwd) = &cwd { - env.insert("PWD", pwd.clone()); + env.insert("PWD", ghostty_os::osbytes::config_str_to_os(pwd)); } Ok(Subprocess { @@ -1037,7 +1040,7 @@ impl Subprocess { } } Some(proposed) => { - if std::path::Path::new(proposed).exists() { + if std::path::Path::new(&ghostty_os::osbytes::config_str_to_os(proposed)).exists() { Some(proposed.clone()) } else { log::warn!("cannot access cwd, ignoring: {proposed}"); diff --git a/crates/ghostty-termio/src/stream_handler.rs b/crates/ghostty-termio/src/stream_handler.rs index 28b030e..3b1c47e 100644 --- a/crates/ghostty-termio/src/stream_handler.rs +++ b/crates/ghostty-termio/src/stream_handler.rs @@ -86,7 +86,7 @@ use crate::{ }; /// `build_config.version_string`. -pub const VERSION_STRING: &str = "1.3.1"; +pub const VERSION_STRING: &str = ghostty_build_info::VERSION_STRING; /// The long-lived state of the handler (everything but the terminal). pub struct HandlerState { @@ -1693,7 +1693,11 @@ mod tests { fn xtversion() { let mut h = Harness::new(); h.feed(b"\x1b[>q"); - assert_eq!(b"\x1bP>|ghostty 1.3.1\x1b\\".as_slice(), h.written()); + let want = format!( + "\x1bP>|ghostty {}\x1b\\", + ghostty_build_info::VERSION_STRING + ); + assert_eq!(want.as_bytes(), h.written()); } #[test] diff --git a/crates/ghostty-termio/src/termio.rs b/crates/ghostty-termio/src/termio.rs index b296128..1d94e1f 100644 --- a/crates/ghostty-termio/src/termio.rs +++ b/crates/ghostty-termio/src/termio.rs @@ -173,7 +173,7 @@ impl ThreadEnterState { let mut out = Vec::with_capacity(self.input.len()); for item in &self.input { match item { - ReadableIO::Raw(v) => out.push(v.clone().into_bytes()), + ReadableIO::Raw(v) => out.push(ghostty_os::osbytes::config_str_to_bytes(v)), ReadableIO::Path(path) => { let f = std::fs::File::open(path).map_err(|err| { log::warn!("failed to open input file={path} err={err}"); diff --git a/docs/rewrite/notes/final-Z2.md b/docs/rewrite/notes/final-Z2.md new file mode 100644 index 0000000..8ee5b98 --- /dev/null +++ b/docs/rewrite/notes/final-Z2.md @@ -0,0 +1,8 @@ +# Z2 final notes (core / lib / termio / apprt-gtk) + +- Windows: `cargo check --target x86_64-pc-windows-gnu -p ghostty-core --tests` passes. Surface init gates `rt_pre_exec_info`, `rt_post_fork_info`, `create_scope` and `restore_rlimit` on `cfg(unix)`. Screen-file writing only sets mode 0600 on unix. The test that read the file mode is unix-gated. +- Embedded init: `global::init` calls `ensure_locale`. `InitOptions.crash` carries crash metadata, with `default_crash_options()` (shared version; Metal/CoreText/`none` tags on Apple). `ghostty_init` ignores SIGPIPE and passes crash options for the lib build. It also stores the raw argv with `ghostty_os::args::set_argv`, so `ghostty_config_load_cli_args` uses it (I added `set_argv` and a vec-returning `iterator()` in ghostty-os/args.rs). `ghostty_config_load_file` keeps non-UTF-8 path bytes (`file_load::open` decodes them). +- Bytes: `Pwd.pwd` and `Surface::pwd()` carry OSC 7 bytes with the reversible `osbytes` escape. `pwd_bytes()` returns the raw bytes. Sinks decode: C `Action::Pwd`, inherited working dir, termio cwd/args/env/PWD/`Raw` input. Embedded surface working-directory/command/env/initial_input and `ghostty_surface_text` (`text_callback_bytes`) pass raw bytes. GTK file drop uses `paste_bytes` and a new `CoreSurface::complete_paste_bytes`. +- Version: XTVERSION, `TERM_PROGRAM_VERSION`, both GTK About branches and crash metadata use `ghostty_build_info::VERSION_STRING`. +- Tests added: e2e OSC 7 non-UTF-8, `command.rs` non-UTF-8 cwd/args, `embedded` surface-options bytes, `tests/init_argv.rs` (argv, SIGPIPE, non-UTF-8 load_file), crash defaults. +- Gate: workspace build, tests and clippy (`--no-deps -D warnings`) for my crates pass, and `cargo +nightly fmt` is applied. Not done: macOS cross clippy (the wrapper setup was declined at the tool prompt). No GTK drop UI test (needs a display). The paste confirmation dialog is text-based, so it carries non-UTF-8 bytes via the escape. diff --git a/src/main.rs b/src/main.rs index 60510df..4cf1cfd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -20,16 +20,18 @@ fn main() -> ExitCode { } fn real_main() -> ExitCode { - // Byte-safe argv (`std::env::args()` panics on non-UTF-8 arguments). - let args: Vec = ghostty_os::args::collect_lossy(); + // Raw argv bytes (Zig `[]const u8`); `std::env::args()` would panic on + // non-UTF-8 arguments and a lossy conversion would change them. + let args: Vec = ghostty_os::args::collect_os(); // We first start by initializing our global state. This sets up // process-level state we need to run the terminal. We detect whether a // CLI action was requested so logging can be silenced for it. - let action_requested = !matches!(ghostty_cli::ghostty::detect(&args), Ok(None)); + let action_requested = !matches!(ghostty_cli::ghostty::detect_os(&args), Ok(None)); global::init(InitOptions { action: action_requested, resources_dir: Some(ghostty_apprt_gtk::flatpak::resources_dir), + ..InitOptions::default() }); // Zig `global.init` runs `ensureLocale` before anything locale dependent. ghostty_apprt_gtk::class::application::init_locale(); @@ -38,7 +40,7 @@ fn real_main() -> ExitCode { // Execute our action if we have one. if action_requested { ghostty_cli::new_window::set_ipc_handler(new_window_ipc); - if let Some(code) = ghostty_cli::run_from_args(&args) { + if let Some(code) = ghostty_cli::ghostty::run_from_os_args(&args) { return ExitCode::from(code); } }