refactor: type escaped OS strings and fix the final audit findings
Escaped config strings (ghostty_os::osbytes) kept reaching OS boundaries undecoded, and decoding was occasionally applied to plain text. The root cause was that both were plain `String`s. Now: - `EncodedOs` is a newtype (no Display/AsRef<Path>) that decodes only via to_os/to_path/to_bytes/to_wide; plain text enters via from_plain_text. OS helpers (env, home, xdg, passwd, resources dir, path expansion), config::Path, WorkingDirectory, theme/file_load/edit paths, terminal pwd, OpenUrl/Pwd actions and termio cwd use it, so the compiler forces decoding at every boundary. os::open takes &OsStr. Config output is unchanged. Fixes all rows of docs/rewrite/notes/review-rv5-codex.md (final-Z7.md): screen-file copy; regex/OSC 8 URLs no longer decoded (regression); lossless Windows WTF-8/WTF-16 for args, env and CreateProcessW; Windows resources dir/TERMINFO; color OSC request lists and X11 color lookup no longer allocate globally (C zero-allocation test extended); macOS Mach handler resumes all threads before KERN_FAILURE (hang regression) and SIGABRT minidumps go through the exception thread; plus open-config, background images, crash dir, ssh cache, exe dir/PATH, fontconfig filenames, file links, kitty TMPDIR, .hushlogin and benchmark paths. The typing also surfaced and fixed gtk-custom-css, bell audio, bash HISTFILE, OSC 52 writes and the C config getters. Test Plan: - cargo test --workspace (3376 passed) - just check-macos (both), just check-windows, just check-wasm - diff <(ghostty-rs +show-config --default --docs) \ <(ghostty +show-config --default --docs)
This commit is contained in:
1 parent
0f898d3e41
commit
8f3e206d9f
92 files changed
+2951
-976
No files matched your search
Generated
+6
@@ -1359,6 +1359,7 @@ dependencies = [
|
||||
"ghostty-datastruct",
|
||||
"ghostty-input",
|
||||
"ghostty-inspector",
|
||||
"ghostty-os",
|
||||
"ghostty-renderer",
|
||||
"ghostty-terminal",
|
||||
"ghostty-vt",
|
||||
@@ -1404,6 +1405,7 @@ dependencies = [
|
||||
name = "ghostty-bench"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ghostty-os",
|
||||
"ghostty-terminal",
|
||||
"ghostty-unicode",
|
||||
"ghostty-vt",
|
||||
@@ -1441,6 +1443,7 @@ dependencies = [
|
||||
"ghostty-vt",
|
||||
"log",
|
||||
"ratatui",
|
||||
"temp-env",
|
||||
"tempfile",
|
||||
"termina",
|
||||
"thiserror 2.0.21",
|
||||
@@ -1458,6 +1461,7 @@ dependencies = [
|
||||
"ghostty-vt",
|
||||
"log",
|
||||
"onig",
|
||||
"temp-env",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -1486,6 +1490,7 @@ dependencies = [
|
||||
"onig",
|
||||
"oslog",
|
||||
"png",
|
||||
"temp-env",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
@@ -1500,6 +1505,7 @@ dependencies = [
|
||||
"minidump",
|
||||
"minidump-writer",
|
||||
"serde_json",
|
||||
"temp-env",
|
||||
"tempfile",
|
||||
"thiserror 2.0.21",
|
||||
"windows-sys",
|
||||
|
||||
@@ -78,7 +78,7 @@ pub enum QuitTimer {
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct WindowOverrides {
|
||||
pub command: Option<Command>,
|
||||
pub working_directory: Option<String>,
|
||||
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1141,7 +1141,7 @@ impl Application {
|
||||
fn action_new_window(&self, parameter: Option<&glib::Variant>) {
|
||||
log::debug!("received new window action");
|
||||
|
||||
let mut working_directory: Option<String> = None;
|
||||
let mut working_directory: Option<ghostty_os::osbytes::EncodedOs> = None;
|
||||
let mut title: Option<String> = None;
|
||||
let mut command: Option<Command> = None;
|
||||
let mut args: Vec<String> = Vec::new();
|
||||
@@ -1180,7 +1180,9 @@ impl Application {
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = s.strip_prefix("--working-directory=") {
|
||||
working_directory = Some(v.trim().to_owned());
|
||||
// IPC carries the encoded form of the OS string.
|
||||
working_directory =
|
||||
Some(ghostty_os::osbytes::EncodedOs::from_encoded(v.trim()));
|
||||
continue;
|
||||
}
|
||||
if let Some(v) = s.strip_prefix("--title=") {
|
||||
@@ -1791,7 +1793,7 @@ impl Application {
|
||||
action::OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
|
||||
action::OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
|
||||
};
|
||||
if let Err(err) = ghostty_os::open::open(kind, &value.url) {
|
||||
if let Err(err) = ghostty_os::open::open(kind, &value.url.to_os()) {
|
||||
log::warn!("unable to open url: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +138,10 @@ mod command_imp {
|
||||
let title = surface
|
||||
.get_effective_title()
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
if let Some(pwd) = surface.get_pwd()
|
||||
// Display text only.
|
||||
if let Some(pwd) = surface
|
||||
.get_pwd()
|
||||
.map(|p| String::from_utf8_lossy(&p.to_bytes()).into_owned())
|
||||
&& !title.contains(&pwd)
|
||||
{
|
||||
*description = Some(pwd);
|
||||
|
||||
@@ -132,7 +132,7 @@ pub enum DebugImKey {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SurfaceOverrides {
|
||||
pub command: Option<Command>,
|
||||
pub working_directory: Option<String>,
|
||||
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
@@ -268,7 +268,7 @@ impl Surface {
|
||||
if ghostty_apprt::surface::should_inherit_working_directory(context, &config_obj.get())
|
||||
&& let Some(pwd) = parent.get_pwd()
|
||||
{
|
||||
imp.pwd.replace(Some(pwd));
|
||||
imp.pwd.replace(Some(pwd.into_encoded()));
|
||||
self.notify("pwd");
|
||||
}
|
||||
}
|
||||
@@ -1500,7 +1500,7 @@ impl Surface {
|
||||
pub fn set_clipboard(
|
||||
&self,
|
||||
clipboard_type: Clipboard,
|
||||
contents: &[(String, String)],
|
||||
contents: &[(String, Vec<u8>)],
|
||||
confirm: bool,
|
||||
) {
|
||||
clipboard::set(self, clipboard_type, contents, confirm);
|
||||
@@ -1582,7 +1582,7 @@ impl Surface {
|
||||
}
|
||||
self.set_clipboard(
|
||||
Clipboard::Standard,
|
||||
&[("text/plain".to_owned(), title)],
|
||||
&[("text/plain".to_owned(), title.into_bytes())],
|
||||
false,
|
||||
);
|
||||
true
|
||||
@@ -1608,13 +1608,22 @@ impl Surface {
|
||||
}
|
||||
|
||||
/// Returns the pwd property.
|
||||
pub fn get_pwd(&self) -> Option<String> {
|
||||
self.imp().pwd.borrow().clone()
|
||||
pub fn get_pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
|
||||
// The GObject `pwd` property is a (UTF-8) string, so it stores the
|
||||
// encoded form of the OS string; this accessor is the only place it
|
||||
// is converted back to the typed value.
|
||||
self.imp()
|
||||
.pwd
|
||||
.borrow()
|
||||
.clone()
|
||||
.map(ghostty_os::osbytes::EncodedOs::from_encoded)
|
||||
}
|
||||
|
||||
/// Set the pwd for this surface, copies the value.
|
||||
pub fn set_pwd(&self, pwd: Option<&str>) {
|
||||
self.imp().pwd.replace(pwd.map(str::to_owned));
|
||||
pub fn set_pwd(&self, pwd: Option<&ghostty_os::osbytes::EncodedOs>) {
|
||||
self.imp()
|
||||
.pwd
|
||||
.replace(pwd.map(|p| p.as_encoded().to_owned()));
|
||||
self.notify("pwd");
|
||||
}
|
||||
|
||||
@@ -1806,8 +1815,10 @@ impl Surface {
|
||||
|
||||
let volume = config.bell_audio_volume.clamp(0.0, 1.0);
|
||||
|
||||
debug_assert!(std::path::Path::new(path).is_absolute());
|
||||
let media_file = gtk::MediaFile::for_filename(path);
|
||||
// The config path is an encoded OS string: decode it first.
|
||||
let path = path.to_path();
|
||||
debug_assert!(path.is_absolute());
|
||||
let media_file = gtk::MediaFile::for_filename(&path);
|
||||
|
||||
// If the audio file is marked as required, we'll emit an error if
|
||||
// there was a problem playing it. Otherwise there will be silence.
|
||||
@@ -2125,7 +2136,7 @@ impl Surface {
|
||||
|
||||
/// The first `focused` surface other than ourselves, used to inherit the
|
||||
/// working directory like `App.focusedSurface` does in the core.
|
||||
fn focused_surface_pwd(&self) -> Option<String> {
|
||||
fn focused_surface_pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
|
||||
Surface::all()
|
||||
.into_iter()
|
||||
.find(|s| s != self && s.core().is_some() && s.get_focused())
|
||||
@@ -2178,7 +2189,7 @@ impl Surface {
|
||||
if let Some(size) = *imp.font_size_request.borrow() {
|
||||
config.font_size = size.0.points;
|
||||
}
|
||||
if let Some(pwd) = imp.pwd.borrow().clone() {
|
||||
if let Some(pwd) = self.get_pwd() {
|
||||
let mut wd_val = WorkingDirectory::Path(pwd);
|
||||
wd_val.finalize();
|
||||
config.working_directory = Some(wd_val);
|
||||
@@ -2421,7 +2432,7 @@ impl RtSurface for RtSurfaceHandle {
|
||||
.is_some_and(|s| s.clipboard_request(clipboard, state))
|
||||
}
|
||||
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) {
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
|
||||
if let Some(s) = self.gobj() {
|
||||
s.set_clipboard(clipboard, contents, confirm);
|
||||
}
|
||||
@@ -2485,15 +2496,16 @@ mod clipboard {
|
||||
pub(super) fn set(
|
||||
surface: &Surface,
|
||||
clipboard_type: Clipboard,
|
||||
contents: &[(String, String)],
|
||||
contents: &[(String, Vec<u8>)],
|
||||
confirm: bool,
|
||||
) {
|
||||
// Grab our plaintext content for use in confirmation dialogs
|
||||
// and signals. We always expect one to exist.
|
||||
// and signals (display text only; the clipboard gets the raw bytes).
|
||||
// We always expect one to exist.
|
||||
let Some(text) = contents
|
||||
.iter()
|
||||
.find(|(mime, _)| mime == "text/plain")
|
||||
.map(|(_, data)| data.clone())
|
||||
.map(|(_, data)| String::from_utf8_lossy(data).into_owned())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
@@ -2505,7 +2517,7 @@ mod clipboard {
|
||||
let providers: Vec<gdk::ContentProvider> = contents
|
||||
.iter()
|
||||
.map(|(mime, data)| {
|
||||
let bytes = glib::Bytes::from(data.as_bytes());
|
||||
let bytes = glib::Bytes::from(data.as_slice());
|
||||
if mime == "text/plain" {
|
||||
// Add an explicit UTF-8 encoding parameter to the
|
||||
// text/plain type. The default charset when there is
|
||||
|
||||
@@ -73,7 +73,7 @@ pub enum SelectTab {
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct TabOverrides {
|
||||
pub command: Option<ConfigCommand>,
|
||||
pub working_directory: Option<String>,
|
||||
pub working_directory: Option<ghostty_os::osbytes::EncodedOs>,
|
||||
pub title: Option<String>,
|
||||
}
|
||||
|
||||
|
||||
@@ -110,29 +110,30 @@ impl CssProviders {
|
||||
|
||||
for p in &config.gtk_custom_css.value {
|
||||
let (path, optional) = match p {
|
||||
Path::Optional(path) => (path, true),
|
||||
Path::Required(path) => (path, false),
|
||||
Path::Optional(path) => (path.to_path(), true),
|
||||
Path::Required(path) => (path.to_path(), false),
|
||||
};
|
||||
let meta = match std::fs::metadata(path) {
|
||||
let shown = path.display();
|
||||
let meta = match std::fs::metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
if e.kind() != std::io::ErrorKind::NotFound || !optional {
|
||||
log::warn!("error opening gtk-custom-css file {path}: {e}");
|
||||
log::warn!("error opening gtk-custom-css file {shown}: {e}");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
log::info!("loading gtk-custom-css path={path}");
|
||||
log::info!("loading gtk-custom-css path={shown}");
|
||||
if meta.len() > CSS_FILE_SIZE_LIMIT {
|
||||
log::warn!(
|
||||
"gtk-custom-css file {path} was larger than {CSS_FILE_SIZE_LIMIT} bytes"
|
||||
"gtk-custom-css file {shown} was larger than {CSS_FILE_SIZE_LIMIT} bytes"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let contents = match std::fs::read(path) {
|
||||
let contents = match std::fs::read(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("error reading gtk-custom-css file {path}: {e}");
|
||||
log::warn!("error reading gtk-custom-css file {shown}: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Flatpak helpers (port of `apprt/gtk/flatpak.zig`).
|
||||
|
||||
use ghostty_os::ResourcesDir;
|
||||
use ghostty_os::{ResourcesDir, osbytes::EncodedOs};
|
||||
use gtk::glib;
|
||||
|
||||
/// Returns the resources directory, consulting the Flatpak runtime data
|
||||
@@ -9,7 +9,7 @@ pub fn resources_dir() -> std::io::Result<ResourcesDir> {
|
||||
// Only consult Flatpak runtime data for the host case.
|
||||
if ghostty_os::is_flatpak() {
|
||||
let mut result = ResourcesDir {
|
||||
app_path: Some("/app/share/ghostty".to_owned()),
|
||||
app_path: Some(EncodedOs::from_plain_text("/app/share/ghostty")),
|
||||
host_path: None,
|
||||
};
|
||||
|
||||
@@ -24,13 +24,11 @@ pub fn resources_dir() -> std::io::Result<ResourcesDir> {
|
||||
return Ok(result);
|
||||
};
|
||||
|
||||
result.host_path = Some(
|
||||
std::path::Path::new(app_dir.as_str())
|
||||
result.host_path = Some(EncodedOs::from_path(
|
||||
&std::path::Path::new(app_dir.as_str())
|
||||
.join("share")
|
||||
.join("ghostty")
|
||||
.to_string_lossy()
|
||||
.into_owned(),
|
||||
);
|
||||
.join("ghostty"),
|
||||
));
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ ghostty-config.workspace = true
|
||||
ghostty-datastruct.workspace = true
|
||||
ghostty-inspector.workspace = true
|
||||
ghostty-terminal.workspace = true
|
||||
ghostty-os.workspace = true
|
||||
ghostty-renderer.workspace = true
|
||||
thiserror = "2.0.21"
|
||||
|
||||
|
||||
@@ -650,16 +650,14 @@ 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,
|
||||
/// they are carried as an [`EncodedOs`](ghostty_os::osbytes::EncodedOs)
|
||||
/// that must be decoded (`to_path`/`to_bytes`) at the OS/C boundary.
|
||||
pub pwd: ghostty_os::osbytes::EncodedOs,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for Pwd {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Pwd{{ {} }}", self.pwd)
|
||||
write!(f, "Pwd{{ {} }}", self.pwd.as_encoded())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -752,8 +750,10 @@ pub struct OpenUrl {
|
||||
/// The type of data that the URL refers to.
|
||||
pub kind: OpenUrlKind,
|
||||
|
||||
/// The URL.
|
||||
pub url: String,
|
||||
/// The URL or path to open, as raw bytes (Zig `[]const u8`). Plain text
|
||||
/// URLs are wrapped with `EncodedOs::from_plain_text`, file names with
|
||||
/// `EncodedOs::from_path`/`from_bytes`; the opener decodes it.
|
||||
pub url: ghostty_os::osbytes::EncodedOs,
|
||||
}
|
||||
|
||||
/// The type of the data at the URL to open. This is used as a hint to
|
||||
@@ -870,7 +870,13 @@ mod tests {
|
||||
#[test]
|
||||
fn display_formats() {
|
||||
assert_eq!(SetTitle { title: "x".into() }.to_string(), "SetTitle{ x }");
|
||||
assert_eq!(Pwd { pwd: "/a".into() }.to_string(), "Pwd{ /a }");
|
||||
assert_eq!(
|
||||
Pwd {
|
||||
pwd: ghostty_os::osbytes::EncodedOs::from_encoded("/a")
|
||||
}
|
||||
.to_string(),
|
||||
"Pwd{ /a }"
|
||||
);
|
||||
assert_eq!(
|
||||
DesktopNotification {
|
||||
title: "t".into(),
|
||||
|
||||
@@ -307,7 +307,9 @@ pub trait RtSurface {
|
||||
/// `CoreSurface::complete_clipboard_request`. Returns true if started.
|
||||
fn clipboard_request(&self, clipboard: Clipboard, state: ClipboardRequest) -> bool;
|
||||
/// `setClipboard(type, contents, confirm)`; contents are (mime, data).
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool);
|
||||
/// The data is raw bytes like Zig's `[:0]const u8` (a copied file name
|
||||
/// need not be valid UTF-8).
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool);
|
||||
/// `defaultTermioEnv`: env vars to set for the child (GDK/GTK related
|
||||
/// cleanups).
|
||||
fn default_termio_env(&self) -> Vec<(std::ffi::OsString, std::ffi::OsString)>;
|
||||
|
||||
@@ -222,7 +222,11 @@ pub fn should_inherit_working_directory(context: NewSurfaceContext, config: &Con
|
||||
/// `app.focusedSurface()?.pwd()`), which the caller (core) must look up.
|
||||
// TODO(integration): core could wrap this to resolve the focused surface.
|
||||
#[must_use]
|
||||
pub fn new_config(config: &Config, context: NewSurfaceContext, prev_pwd: Option<String>) -> Config {
|
||||
pub fn new_config(
|
||||
config: &Config,
|
||||
context: NewSurfaceContext,
|
||||
prev_pwd: Option<ghostty_os::osbytes::EncodedOs>,
|
||||
) -> Config {
|
||||
let mut copy = config.clone();
|
||||
if let Some(pwd) = prev_pwd
|
||||
&& should_inherit_working_directory(context, config)
|
||||
@@ -238,6 +242,10 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
fn tmp() -> ghostty_os::osbytes::EncodedOs {
|
||||
ghostty_os::osbytes::EncodedOs::from_encoded("/tmp")
|
||||
}
|
||||
|
||||
struct Rec(RefCell<Vec<(u32, bool)>>);
|
||||
impl AppMailbox<u32> for Rec {
|
||||
fn push_surface_message(&self, s: u32, m: Message, _t: Timeout) -> usize {
|
||||
@@ -277,12 +285,9 @@ mod tests {
|
||||
NewSurfaceContext::Tab,
|
||||
&c
|
||||
));
|
||||
let n = new_config(&c, NewSurfaceContext::Window, Some("/tmp".into()));
|
||||
assert_eq!(
|
||||
n.working_directory,
|
||||
Some(WorkingDirectory::Path("/tmp".into()))
|
||||
);
|
||||
let n = new_config(&c, NewSurfaceContext::Tab, Some("/tmp".into()));
|
||||
let n = new_config(&c, NewSurfaceContext::Window, Some(tmp()));
|
||||
assert_eq!(n.working_directory, Some(WorkingDirectory::Path(tmp())));
|
||||
let n = new_config(&c, NewSurfaceContext::Tab, Some(tmp()));
|
||||
assert_eq!(n.working_directory, None);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ name = "ghostty-synthetic"
|
||||
path = "src/bin/ghostty-synthetic.rs"
|
||||
|
||||
[dependencies]
|
||||
ghostty-os.workspace = true
|
||||
ghostty-terminal.workspace = true
|
||||
ghostty-unicode.workspace = true
|
||||
ghostty-vt.workspace = true
|
||||
|
||||
@@ -2,48 +2,61 @@
|
||||
//! the benchmark and synthetic CLIs).
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
ffi::{OsStr, OsString},
|
||||
fs::File,
|
||||
io::{self, Read},
|
||||
path::{Path, PathBuf},
|
||||
str::FromStr,
|
||||
};
|
||||
|
||||
use ghostty_os::osbytes::{os_bytes, os_from_bytes};
|
||||
|
||||
/// Parsed `--key[=value]` arguments.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Args {
|
||||
map: HashMap<String, Option<String>>,
|
||||
// Values are OS strings: file names (`--data=...`) need not be UTF-8.
|
||||
map: HashMap<String, Option<OsString>>,
|
||||
}
|
||||
|
||||
/// The action name of a `+action` argument, if `arg` is one.
|
||||
#[must_use]
|
||||
pub fn action_name(arg: &OsStr) -> Option<String> {
|
||||
let bytes = os_bytes(arg);
|
||||
let name = bytes.strip_prefix(b"+")?;
|
||||
Some(String::from_utf8_lossy(name).into_owned())
|
||||
}
|
||||
|
||||
impl Args {
|
||||
/// Parse arguments (skipping `+action` words and the program name).
|
||||
pub fn parse<I: IntoIterator<Item = String>>(iter: I) -> Result<Args, String> {
|
||||
pub fn parse<I, S>(iter: I) -> Result<Args, String>
|
||||
where
|
||||
I: IntoIterator<Item = S>,
|
||||
S: Into<OsString>,
|
||||
{
|
||||
let mut map = HashMap::new();
|
||||
for arg in iter {
|
||||
if arg.starts_with('+') {
|
||||
let bytes = os_bytes(&arg.into());
|
||||
if bytes.starts_with(b"+") {
|
||||
continue;
|
||||
}
|
||||
let Some(rest) = arg.strip_prefix("--") else {
|
||||
return Err(format!("unexpected argument: {arg}"));
|
||||
let Some(rest) = bytes.strip_prefix(b"--") else {
|
||||
return Err(format!(
|
||||
"unexpected argument: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
));
|
||||
};
|
||||
match rest.split_once('=') {
|
||||
Some((k, v)) => map.insert(k.to_owned(), Some(v.to_owned())),
|
||||
None => map.insert(rest.to_owned(), None),
|
||||
let (key, value) = match rest.iter().position(|b| *b == b'=') {
|
||||
Some(i) => (&rest[..i], Some(os_from_bytes(rest[i + 1..].to_vec()))),
|
||||
None => (rest, None),
|
||||
};
|
||||
let key = String::from_utf8(key.to_vec())
|
||||
.map_err(|_| "option names must be valid UTF-8".to_owned())?;
|
||||
map.insert(key, value);
|
||||
}
|
||||
Ok(Args { map })
|
||||
}
|
||||
|
||||
/// Take a typed value, falling back to `default`.
|
||||
pub fn take<T: FromStr>(&mut self, key: &str, default: T) -> Result<T, String> {
|
||||
match self.map.remove(key) {
|
||||
None => Ok(default),
|
||||
Some(None) => Err(format!("option --{key} requires a value")),
|
||||
Some(Some(v)) => v
|
||||
.parse()
|
||||
.map_err(|_| format!("invalid value for --{key}: {v}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_opt_string(&mut self, key: &str) -> Result<Option<String>, String> {
|
||||
fn take_os(&mut self, key: &str) -> Result<Option<OsString>, String> {
|
||||
match self.map.remove(key) {
|
||||
None => Ok(None),
|
||||
Some(None) => Err(format!("option --{key} requires a value")),
|
||||
@@ -51,15 +64,44 @@ impl Args {
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a typed value, falling back to `default`.
|
||||
pub fn take<T: FromStr>(&mut self, key: &str, default: T) -> Result<T, String> {
|
||||
match self.take_os(key)? {
|
||||
None => Ok(default),
|
||||
Some(v) => v
|
||||
.to_str()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.ok_or_else(|| format!("invalid value for --{key}: {}", v.to_string_lossy())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn take_opt_string(&mut self, key: &str) -> Result<Option<String>, String> {
|
||||
match self.take_os(key)? {
|
||||
None => Ok(None),
|
||||
Some(v) => v
|
||||
.into_string()
|
||||
.map(Some)
|
||||
.map_err(|v| format!("invalid value for --{key}: {}", v.to_string_lossy())),
|
||||
}
|
||||
}
|
||||
|
||||
/// Take a file path value (any bytes are allowed).
|
||||
pub fn take_opt_path(&mut self, key: &str) -> Result<Option<PathBuf>, String> {
|
||||
Ok(self.take_os(key)?.map(PathBuf::from))
|
||||
}
|
||||
|
||||
/// Take a boolean flag (`--key`, `--key=true`, `--key=false`).
|
||||
pub fn take_flag(&mut self, key: &str) -> Result<bool, String> {
|
||||
match self.map.remove(key) {
|
||||
None => Ok(false),
|
||||
Some(None) => Ok(true),
|
||||
Some(Some(v)) => match v.as_str() {
|
||||
Some(Some(v)) => match v.to_str().unwrap_or_default() {
|
||||
"true" | "t" | "1" | "yes" => Ok(true),
|
||||
"false" | "f" | "0" | "no" => Ok(false),
|
||||
_ => Err(format!("invalid value for --{key}: {v}")),
|
||||
_ => Err(format!(
|
||||
"invalid value for --{key}: {}",
|
||||
v.to_string_lossy()
|
||||
)),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -75,10 +117,10 @@ impl Args {
|
||||
|
||||
/// Returns the data source for the path: `None` if unset, stdin for `-`,
|
||||
/// otherwise the opened file (mirrors `benchmark/options.zig`).
|
||||
pub fn data_file(path: Option<&str>) -> io::Result<Option<Box<dyn Read>>> {
|
||||
pub fn data_file(path: Option<&Path>) -> io::Result<Option<Box<dyn Read>>> {
|
||||
match path {
|
||||
None => Ok(None),
|
||||
Some("-") => Ok(Some(Box::new(io::stdin()))),
|
||||
Some(p) if p == Path::new("-") => Ok(Some(Box::new(io::stdin()))),
|
||||
Some(p) => Ok(Some(Box::new(File::open(p)?))),
|
||||
}
|
||||
}
|
||||
@@ -96,3 +138,25 @@ pub fn read_short(r: &mut dyn Read, buf: &mut [u8]) -> io::Result<usize> {
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn data_path_keeps_non_utf8_bytes() {
|
||||
let arg = OsString::from_vec(b"--data=/tmp/a\xffb".to_vec());
|
||||
let mut args = Args::parse([arg]).unwrap();
|
||||
let p = args.take_opt_path("data").unwrap().unwrap();
|
||||
assert_eq!(p.as_os_str().as_bytes(), b"/tmp/a\xffb");
|
||||
args.finish().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn action_name_extracts_plus_words() {
|
||||
assert_eq!(action_name(OsStr::new("+ascii")).as_deref(), Some("ascii"));
|
||||
assert_eq!(action_name(OsStr::new("--x")), None);
|
||||
}
|
||||
}
|
||||
@@ -40,12 +40,12 @@ fn warn(what: &str, e: &dyn std::fmt::Display) -> BenchmarkFailed {
|
||||
|
||||
/// Shared data-file handling: opened in setup, closed in teardown.
|
||||
struct Data {
|
||||
path: Option<String>,
|
||||
path: Option<std::path::PathBuf>,
|
||||
f: Option<Box<dyn Read>>,
|
||||
}
|
||||
|
||||
impl Data {
|
||||
fn new(path: Option<String>) -> Data {
|
||||
fn new(path: Option<std::path::PathBuf>) -> Data {
|
||||
Data { path, f: None }
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ impl TerminalStream {
|
||||
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
|
||||
let rows = args.take("terminal-rows", 80u16)?;
|
||||
let cols = args.take("terminal-cols", 120u16)?;
|
||||
let data = args.take_opt_string("data")?;
|
||||
let data = args.take_opt_path("data")?;
|
||||
let full = match args.take_opt_string("handler")?.as_deref() {
|
||||
None | Some("print") => false,
|
||||
Some("full") => true,
|
||||
@@ -160,7 +160,7 @@ pub struct TerminalParser {
|
||||
impl TerminalParser {
|
||||
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
|
||||
Ok(TerminalParser {
|
||||
data: Data::new(args.take_opt_string("data")?),
|
||||
data: Data::new(args.take_opt_path("data")?),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -196,7 +196,7 @@ pub struct OscParser {
|
||||
impl OscParser {
|
||||
pub fn create(args: &mut Args) -> std::result::Result<Self, String> {
|
||||
Ok(OscParser {
|
||||
data: Data::new(args.take_opt_string("data")?),
|
||||
data: Data::new(args.take_opt_path("data")?),
|
||||
parser: osc::Parser::init(true),
|
||||
})
|
||||
}
|
||||
@@ -267,7 +267,7 @@ impl CodepointWidth {
|
||||
Some(m) => return Err(format!("invalid value for --mode: {m}")),
|
||||
};
|
||||
Ok(CodepointWidth {
|
||||
data: Data::new(args.take_opt_string("data")?),
|
||||
data: Data::new(args.take_opt_path("data")?),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
@@ -390,7 +390,7 @@ impl GraphemeBreak {
|
||||
Some(m) => return Err(format!("invalid value for --mode: {m}")),
|
||||
};
|
||||
Ok(GraphemeBreak {
|
||||
data: Data::new(args.take_opt_string("data")?),
|
||||
data: Data::new(args.take_opt_path("data")?),
|
||||
noop,
|
||||
})
|
||||
}
|
||||
@@ -479,7 +479,7 @@ impl IsSymbol {
|
||||
Some(m) => return Err(format!("invalid value for --mode: {m}")),
|
||||
};
|
||||
Ok(IsSymbol {
|
||||
data: Data::new(args.take_opt_string("data")?),
|
||||
data: Data::new(args.take_opt_path("data")?),
|
||||
mode,
|
||||
})
|
||||
}
|
||||
@@ -544,7 +544,7 @@ impl ScreenClone {
|
||||
};
|
||||
let rows = args.take("terminal-rows", 80u16)?;
|
||||
let cols = args.take("terminal-cols", 120u16)?;
|
||||
let data = args.take_opt_string("data")?;
|
||||
let data = args.take_opt_path("data")?;
|
||||
let terminal = Terminal::new(TermOptions::new(cols, rows)).map_err(|e| format!("{e:?}"))?;
|
||||
Ok(ScreenClone {
|
||||
data: Data::new(data),
|
||||
@@ -674,9 +674,13 @@ mod tests {
|
||||
|
||||
fn run(action: &str, mode: &str, data: &std::path::Path) -> Result {
|
||||
let mut args = Args::parse([
|
||||
format!("+{action}"),
|
||||
format!("--mode={mode}"),
|
||||
format!("--data={}", data.display()),
|
||||
std::ffi::OsString::from(format!("+{action}")),
|
||||
std::ffi::OsString::from(format!("--mode={mode}")),
|
||||
{
|
||||
let mut a = std::ffi::OsString::from("--data=");
|
||||
a.push(data);
|
||||
a
|
||||
},
|
||||
])
|
||||
.unwrap();
|
||||
match action {
|
||||
|
||||
@@ -11,14 +11,14 @@ use ghostty_bench::{
|
||||
};
|
||||
|
||||
fn run() -> Result<(), String> {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let argv: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
let mut action = None;
|
||||
for a in &argv {
|
||||
if let Some(name) = a.strip_prefix('+') {
|
||||
if let Some(name) = ghostty_bench::args::action_name(a) {
|
||||
if action.is_some() {
|
||||
return Err("multiple actions specified".into());
|
||||
}
|
||||
action = Some(name.to_owned());
|
||||
action = Some(name);
|
||||
}
|
||||
}
|
||||
let action = action.ok_or("no action specified; available: ascii, osc, sgr, utf8")?;
|
||||
|
||||
@@ -4,15 +4,15 @@ use std::process::ExitCode;
|
||||
use ghostty_bench::{args::Args, benches};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let argv: Vec<String> = std::env::args().skip(1).collect();
|
||||
let argv: Vec<std::ffi::OsString> = std::env::args_os().skip(1).collect();
|
||||
let mut action = None;
|
||||
for a in &argv {
|
||||
if let Some(name) = a.strip_prefix('+') {
|
||||
if let Some(name) = ghostty_bench::args::action_name(a) {
|
||||
if action.is_some() {
|
||||
eprintln!("error: multiple actions specified");
|
||||
return ExitCode::FAILURE;
|
||||
}
|
||||
action = Some(name.to_owned());
|
||||
action = Some(name);
|
||||
}
|
||||
}
|
||||
let Some(action) = action else {
|
||||
|
||||
@@ -28,3 +28,4 @@ workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.27.0"
|
||||
temp-env = "0.3.6"
|
||||
@@ -85,7 +85,8 @@ fn run_inner(
|
||||
};
|
||||
format!("{} ago", d.round(1_000_000_000))
|
||||
};
|
||||
writeln!(stdout, "{} ({since})", report.name)?;
|
||||
// Display only: the file name is shown as text.
|
||||
writeln!(stdout, "{} ({since})", report.name.to_string_lossy())?;
|
||||
}
|
||||
|
||||
Ok(0)
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::io::Write;
|
||||
use std::os::unix::process::CommandExt;
|
||||
|
||||
use ghostty_config::config::Config;
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::error::Error;
|
||||
pub use crate::options::NoOptions as Options;
|
||||
@@ -43,7 +44,7 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
|
||||
/// Pick the editor from `$VISUAL` then `$EDITOR` (empty values are skipped).
|
||||
#[must_use]
|
||||
pub fn find_editor() -> Option<String> {
|
||||
pub fn find_editor() -> Option<EncodedOs> {
|
||||
// VISUAL vs. EDITOR: https://unix.stackexchange.com/questions/4859/visual-vs-editor-what-s-the-difference
|
||||
for var in ["VISUAL", "EDITOR"] {
|
||||
if let Some(v) = ghostty_os::getenv(var) {
|
||||
@@ -76,19 +77,20 @@ fn run_inner(stderr: &mut dyn Write) -> Result<u8, Error> {
|
||||
|
||||
/// Windows branch of `run`: print the path and fail.
|
||||
#[cfg(windows)]
|
||||
fn write_windows_unsupported(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
||||
fn write_windows_unsupported(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
|
||||
write!(
|
||||
stderr,
|
||||
"The `ghostty +edit-config` command is not supported on Windows.\n\
|
||||
Please edit the configuration file manually at the following path:\n\
|
||||
\n\
|
||||
{path}\n"
|
||||
{}\n",
|
||||
path.as_encoded()
|
||||
)?;
|
||||
Ok(1)
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
||||
fn exec_editor(stderr: &mut dyn Write, path: &EncodedOs) -> Result<u8, Error> {
|
||||
// Get our editor
|
||||
let Some(editor) = find_editor() else {
|
||||
// If we don't have `$EDITOR` set then we can't do anything
|
||||
@@ -107,15 +109,16 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
||||
);
|
||||
// Output the path using the OSC8 sequence so that it is linked.
|
||||
// (Paths are raw bytes: decode the reversible escape when writing.)
|
||||
let msg = format!("{msg}\x1b]8;;file://{path}\x1b\\{path}\x1b]8;;\x1b\\\n");
|
||||
let p = path.as_encoded();
|
||||
let msg = format!("{msg}\x1b]8;;file://{p}\x1b\\{p}\x1b]8;;\x1b\\\n");
|
||||
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
|
||||
return Ok(1);
|
||||
};
|
||||
|
||||
// Flush before exec so no output is lost.
|
||||
let _ = stderr.flush();
|
||||
let err = std::process::Command::new(ghostty_os::osbytes::config_str_to_os(&editor))
|
||||
.arg(ghostty_os::osbytes::config_str_to_os(path))
|
||||
let err = std::process::Command::new(editor.to_os())
|
||||
.arg(path.to_os())
|
||||
.exec();
|
||||
|
||||
// If we reached this point then exec failed.
|
||||
@@ -131,8 +134,10 @@ fn exec_editor(stderr: &mut dyn Write, path: &str) -> Result<u8, Error> {
|
||||
permissions, or the shell environment not being set up\n\
|
||||
correctly.\n\
|
||||
\n\
|
||||
Editor: {editor}\n\
|
||||
Path: {path}\n"
|
||||
Editor: {}\n\
|
||||
Path: {}\n",
|
||||
editor.as_encoded(),
|
||||
path.as_encoded()
|
||||
);
|
||||
stderr.write_all(&ghostty_os::osbytes::config_str_to_bytes(&msg))?;
|
||||
Ok(1)
|
||||
|
||||
@@ -15,12 +15,7 @@ use ghostty_config::{
|
||||
config_struct,
|
||||
theme::{Location, LocationIterator},
|
||||
};
|
||||
use ghostty_os::osbytes::{
|
||||
config_str_to_bytes,
|
||||
config_str_to_path,
|
||||
os_to_config_string,
|
||||
path_to_config_string,
|
||||
};
|
||||
use ghostty_os::osbytes::{EncodedOs, config_str_to_bytes, os_to_config_string};
|
||||
|
||||
use crate::{error::Error, tui};
|
||||
|
||||
@@ -58,7 +53,7 @@ cli_options!(Options, help);
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct ThemeListElement {
|
||||
pub location: Location,
|
||||
pub path: String,
|
||||
pub path: EncodedOs,
|
||||
pub theme: String,
|
||||
pub rank: Option<f64>,
|
||||
}
|
||||
@@ -76,7 +71,7 @@ impl ThemeListElement {
|
||||
#[must_use]
|
||||
pub fn to_uri(&self) -> String {
|
||||
let mut out = String::from("file://");
|
||||
for b in ghostty_os::osbytes::config_str_to_bytes(&self.path) {
|
||||
for b in self.path.to_bytes() {
|
||||
// std.Uri path component escaping: unreserved + sub-delims + ":@/".
|
||||
if b.is_ascii_alphanumeric()
|
||||
|| matches!(
|
||||
@@ -123,15 +118,15 @@ pub fn location_name(loc: Location) -> &'static str {
|
||||
/// Returns the unsorted list (in directory order). Errors opening a
|
||||
/// directory other than "not found" are printed to stderr and skipped.
|
||||
#[must_use]
|
||||
pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
|
||||
pub fn collect_themes(resources_dir: Option<&EncodedOs>) -> Vec<ThemeListElement> {
|
||||
let mut themes = vec![];
|
||||
let mut it = LocationIterator::new(resources_dir);
|
||||
while let Some((location, dir)) = it.next_location() {
|
||||
let rd = match std::fs::read_dir(config_str_to_path(&dir)) {
|
||||
let rd = match std::fs::read_dir(dir.to_path()) {
|
||||
Ok(rd) => rd,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(e) => {
|
||||
eprintln!("error trying to open {dir}: {e}");
|
||||
eprintln!("error trying to open {}: {e}", dir.as_encoded());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
@@ -150,7 +145,7 @@ pub fn collect_themes(resources_dir: Option<&str>) -> Vec<ThemeListElement> {
|
||||
continue;
|
||||
}
|
||||
themes.push(ThemeListElement {
|
||||
path: path_to_config_string(&config_str_to_path(&dir).join(&raw_name)),
|
||||
path: EncodedOs::from_path(&dir.to_path().join(&raw_name)),
|
||||
location,
|
||||
theme: name,
|
||||
rank: None,
|
||||
@@ -174,15 +169,14 @@ pub fn should_include_theme(theme_filter: ColorScheme, theme_config: &Config) ->
|
||||
}
|
||||
|
||||
/// Make `path` absolute (`Config::load_file` requires absolute paths).
|
||||
pub(crate) fn absolute_path(path: &str) -> String {
|
||||
std::path::absolute(config_str_to_path(path))
|
||||
.map_or_else(|_| path.to_string(), |p| path_to_config_string(&p))
|
||||
pub(crate) fn absolute_path(path: &EncodedOs) -> EncodedOs {
|
||||
std::path::absolute(path.to_path()).map_or_else(|_| path.clone(), |p| EncodedOs::from_path(&p))
|
||||
}
|
||||
|
||||
fn resources_dir() -> Option<String> {
|
||||
fn resources_dir() -> Option<EncodedOs> {
|
||||
ghostty_os::resources_dir()
|
||||
.ok()
|
||||
.and_then(|r| r.app().map(str::to_string))
|
||||
.and_then(|r| r.app().cloned())
|
||||
}
|
||||
|
||||
/// The `list-themes` command is used to preview or list all the available
|
||||
@@ -238,7 +232,7 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
|
||||
)?;
|
||||
}
|
||||
|
||||
let mut themes = collect_themes(resources_dir.as_deref());
|
||||
let mut themes = collect_themes(resources_dir.as_ref());
|
||||
|
||||
if themes.is_empty() {
|
||||
write!(
|
||||
@@ -281,7 +275,7 @@ pub fn write_plain(
|
||||
// Names/paths are raw bytes in Zig: decode the reversible escape so
|
||||
// non-UTF-8 bytes are written back unchanged.
|
||||
let line = if opts.path {
|
||||
format!("{} ({loc}) {}\n", theme.theme, theme.path)
|
||||
format!("{} ({loc}) {}\n", theme.theme, theme.path.as_encoded())
|
||||
} else {
|
||||
format!("{} ({loc})\n", theme.theme)
|
||||
};
|
||||
@@ -314,7 +308,7 @@ mod tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let raw = std::ffi::OsStr::from_bytes(b"th\xffeme");
|
||||
std::fs::write(dir.join(raw), "background = #000000\n").unwrap();
|
||||
let res = tmp.path().to_string_lossy().into_owned();
|
||||
let res = EncodedOs::from_path(tmp.path());
|
||||
let themes: Vec<_> = collect_themes(Some(&res))
|
||||
.into_iter()
|
||||
.filter(|t| t.location == Location::Resources)
|
||||
@@ -322,7 +316,7 @@ mod tests {
|
||||
assert_eq!(1, themes.len());
|
||||
assert_eq!(config_str_to_bytes(&themes[0].theme), b"th\xffeme".to_vec());
|
||||
// The path points at the real file and the listing loads it.
|
||||
assert_eq!(config_str_to_path(&themes[0].path), dir.join(raw));
|
||||
assert_eq!(themes[0].path.to_path(), dir.join(raw));
|
||||
let mut out = Vec::new();
|
||||
write_plain(&mut out, &themes, &Options::default()).unwrap();
|
||||
assert_eq!(out, b"th\xffeme (resources)\n");
|
||||
@@ -336,7 +330,7 @@ mod tests {
|
||||
("Beta", "background = 202020\n"),
|
||||
(".DS_Store", "junk"),
|
||||
]);
|
||||
let res = tmp.path().to_string_lossy().into_owned();
|
||||
let res = EncodedOs::from_path(tmp.path());
|
||||
let mut themes: Vec<_> = collect_themes(Some(&res))
|
||||
.into_iter()
|
||||
.filter(|t| t.location == Location::Resources)
|
||||
@@ -365,7 +359,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!("Zeta (resources)\n", list(ColorScheme::Light, false));
|
||||
assert_eq!(
|
||||
format!("Zeta (resources) {res}/themes/Zeta\n"),
|
||||
format!("Zeta (resources) {}/themes/Zeta\n", res.as_encoded()),
|
||||
list(ColorScheme::Light, true)
|
||||
);
|
||||
}
|
||||
@@ -392,7 +386,7 @@ mod tests {
|
||||
fn uri_escaping() {
|
||||
let t = ThemeListElement {
|
||||
location: Location::User,
|
||||
path: "/a b/c%d".into(),
|
||||
path: EncodedOs::from_encoded("/a b/c%d"),
|
||||
theme: "x".into(),
|
||||
rank: None,
|
||||
};
|
||||
@@ -403,7 +397,7 @@ mod tests {
|
||||
fn uri_escaping_percent_encodes_original_bytes() {
|
||||
let t = ThemeListElement {
|
||||
location: Location::User,
|
||||
path: ghostty_os::osbytes::bytes_to_config_string(b"/a/\xff\xc3x"),
|
||||
path: EncodedOs::from_bytes(b"/a/\xff\xc3x"),
|
||||
theme: "x".into(),
|
||||
rank: None,
|
||||
};
|
||||
|
||||
@@ -353,8 +353,8 @@ enum Loaded {
|
||||
/// A pending side effect requested by the state (needs the terminal).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Effect {
|
||||
/// Copy the text to the clipboard (OSC 52).
|
||||
Copy(String),
|
||||
/// Copy the (decoded) bytes to the clipboard (OSC 52).
|
||||
Copy(ghostty_os::osbytes::EncodedOs),
|
||||
}
|
||||
|
||||
/// The preview state (Zig: `Preview`).
|
||||
@@ -373,9 +373,9 @@ pub struct Preview {
|
||||
mouse: Option<MouseEvent>,
|
||||
/// The list height as of the last draw (for clamping the window).
|
||||
list_height: u16,
|
||||
loaded: Option<(String, Loaded)>,
|
||||
loaded: Option<(ghostty_os::osbytes::EncodedOs, Loaded)>,
|
||||
/// Where `w` writes the auto theme file (overridable for tests).
|
||||
pub auto_theme_path: Option<String>,
|
||||
pub auto_theme_path: Option<std::path::PathBuf>,
|
||||
effects: Vec<Effect>,
|
||||
overlays: Vec<Overlay>,
|
||||
}
|
||||
@@ -581,7 +581,8 @@ impl Preview {
|
||||
}
|
||||
if is_char('c') {
|
||||
if let Some(t) = self.selected() {
|
||||
let name = t.theme.clone();
|
||||
// Theme names are in the reversible OS-string encoding.
|
||||
let name = ghostty_os::osbytes::EncodedOs::from_encoded(t.theme.clone());
|
||||
self.effects.push(Effect::Copy(name));
|
||||
}
|
||||
} else if code == KeyCode::Char('C')
|
||||
@@ -965,10 +966,10 @@ impl Preview {
|
||||
(child.area.width / 2).saturating_sub(gw(&text) / 2),
|
||||
);
|
||||
child.print_ex(
|
||||
&theme.path,
|
||||
theme.path.as_encoded(),
|
||||
ui_err,
|
||||
middle,
|
||||
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
|
||||
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
|
||||
Some(&theme.to_uri()),
|
||||
None,
|
||||
);
|
||||
@@ -1024,10 +1025,10 @@ impl Preview {
|
||||
None,
|
||||
);
|
||||
child.print_ex(
|
||||
&theme.path,
|
||||
theme.path.as_encoded(),
|
||||
standard,
|
||||
2,
|
||||
(child.area.width / 2).saturating_sub(gw(&theme.path) / 2),
|
||||
(child.area.width / 2).saturating_sub(gw(theme.path.as_encoded()) / 2),
|
||||
Some(&uri),
|
||||
None,
|
||||
);
|
||||
@@ -1405,25 +1406,25 @@ fn error_name(e: &ghostty_config::config::Error) -> String {
|
||||
}
|
||||
|
||||
/// `resolveAutoThemePath`: `<config dir>/auto/theme.ghostty`.
|
||||
pub fn resolve_auto_theme_path() -> Result<String, Error> {
|
||||
pub fn resolve_auto_theme_path() -> Result<std::path::PathBuf, Error> {
|
||||
let main_cfg_path = ghostty_config::file_load::preferred_default_file_path()
|
||||
.map_err(|e| Error::Other(e.to_string()))?;
|
||||
let main_cfg_path = ghostty_os::osbytes::config_str_to_path(&main_cfg_path);
|
||||
.map_err(|e| Error::Other(e.to_string()))?
|
||||
.to_path();
|
||||
let base_dir = main_cfg_path
|
||||
.parent()
|
||||
.ok_or_else(|| Error::Other("BadPathName".to_string()))?;
|
||||
Ok(ghostty_os::osbytes::path_to_config_string(
|
||||
&base_dir.join("auto").join("theme.ghostty"),
|
||||
))
|
||||
Ok(base_dir.join("auto").join("theme.ghostty"))
|
||||
}
|
||||
|
||||
/// `writeAutoThemeFile`.
|
||||
pub fn write_auto_theme_file(path: Option<&str>, theme_name: &str) -> Result<(), Error> {
|
||||
pub fn write_auto_theme_file(
|
||||
path: Option<&std::path::Path>,
|
||||
theme_name: &str,
|
||||
) -> Result<(), Error> {
|
||||
let auto_path = match path {
|
||||
Some(p) => p.to_string(),
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => resolve_auto_theme_path()?,
|
||||
};
|
||||
let auto_path = ghostty_os::osbytes::config_str_to_path(&auto_path);
|
||||
if let Some(dir) = auto_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
@@ -1737,11 +1738,7 @@ fn run_loop(app: &mut Preview) -> Result<(), Error> {
|
||||
match effect {
|
||||
Effect::Copy(text) => {
|
||||
let out = terminal.backend_mut();
|
||||
write!(
|
||||
out,
|
||||
"\x1b]52;c;{}\x1b\\",
|
||||
base64(&ghostty_os::osbytes::config_str_to_bytes(&text))
|
||||
)?;
|
||||
write!(out, "\x1b]52;c;{}\x1b\\", base64(&text.to_bytes()))?;
|
||||
out.flush()?;
|
||||
}
|
||||
}
|
||||
@@ -1788,7 +1785,7 @@ mod tests {
|
||||
.unwrap();
|
||||
ThemeListElement {
|
||||
location: Location::Resources,
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
path: ghostty_os::osbytes::EncodedOs::from_path(&path),
|
||||
theme: (*name).to_string(),
|
||||
rank: None,
|
||||
}
|
||||
@@ -1939,14 +1936,19 @@ mod tests {
|
||||
KeyModifiers::SHIFT,
|
||||
)));
|
||||
let effects = p.take_effects();
|
||||
assert_eq!(Effect::Copy("alpha".into()), effects[0]);
|
||||
assert_eq!(
|
||||
Effect::Copy(tmp.path().join("alpha").to_string_lossy().into_owned()),
|
||||
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_encoded("alpha")),
|
||||
effects[0]
|
||||
);
|
||||
assert_eq!(
|
||||
Effect::Copy(ghostty_os::osbytes::EncodedOs::from_path(
|
||||
&tmp.path().join("alpha")
|
||||
)),
|
||||
effects[1]
|
||||
);
|
||||
|
||||
let auto = tmp.path().join("cfg/auto/theme.ghostty");
|
||||
p.auto_theme_path = Some(auto.to_string_lossy().into_owned());
|
||||
p.auto_theme_path = Some(auto.clone());
|
||||
p.update(&key(KeyCode::Enter));
|
||||
assert_eq!(Mode::Save, p.mode);
|
||||
p.update(&ch('w'));
|
||||
|
||||
@@ -8,6 +8,7 @@ use std::sync::OnceLock;
|
||||
|
||||
use ghostty_apprt::ipc;
|
||||
use ghostty_config::cli_args::{ArgError, ArgIter, CliTarget};
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
@@ -56,15 +57,15 @@ impl Options {
|
||||
if stripped == "home" || stripped == "inherit" {
|
||||
return Ok(Some(arg.to_string()));
|
||||
}
|
||||
let expanded =
|
||||
ghostty_os::expand_home(stripped).map_err(|e| ArgError::Other(e.to_string()))?;
|
||||
let realpath =
|
||||
std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(&expanded))
|
||||
// The CLI argument is an encoded OS string.
|
||||
let expanded = ghostty_os::expand_home(&EncodedOs::from_encoded(stripped))
|
||||
.map_err(|e| ArgError::Other(e.to_string()))?;
|
||||
let realpath = std::fs::canonicalize(expanded.to_path())
|
||||
.map_err(|e| ArgError::Other(e.to_string()))?;
|
||||
self.working_directory_seen = true;
|
||||
return Ok(Some(format!(
|
||||
"--working-directory={}",
|
||||
ghostty_os::osbytes::path_to_config_string(&realpath)
|
||||
EncodedOs::from_path(&realpath).as_encoded()
|
||||
)));
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ impl DiskCache {
|
||||
home: None,
|
||||
})
|
||||
.map_err(|_| Error::XdgLookupFailed)?;
|
||||
Ok(PathBuf::from(state_dir).join("ssh_cache"))
|
||||
Ok(state_dir.to_path().join("ssh_cache"))
|
||||
}
|
||||
|
||||
/// Clear all cache data stored in the disk cache.
|
||||
@@ -346,6 +346,20 @@ fn is_valid_user(user: &str) -> bool {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn default_path_decodes_non_utf8_xdg_state() {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let v = std::ffi::OsString::from_vec(b"/tmp/st\xffate".to_vec());
|
||||
let p = temp_env::with_var("XDG_STATE_HOME", Some(&v), || {
|
||||
super::DiskCache::default_path("ghostty").unwrap()
|
||||
});
|
||||
assert_eq!(
|
||||
p.as_os_str().as_bytes(),
|
||||
b"/tmp/st\xffate/ghostty/ssh_cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[test]
|
||||
fn lock_is_exclusive_until_dropped() {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use std::io::Write;
|
||||
|
||||
use ghostty_config::{config::Config, config_struct};
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::error::Error;
|
||||
|
||||
@@ -41,8 +42,8 @@ fn run_inner(opts: &Options, stdout: &mut dyn Write) -> Result<u8, Error> {
|
||||
if let Some(config_path) = &opts.config_file {
|
||||
// Byte-preserving: the option string carries non-UTF-8 bytes via the
|
||||
// reversible `osbytes` escape and the canonical path may too.
|
||||
let abs_path = std::fs::canonicalize(ghostty_os::osbytes::config_str_to_path(config_path))?;
|
||||
cfg.load_file(&ghostty_os::osbytes::path_to_config_string(&abs_path))?;
|
||||
let abs_path = std::fs::canonicalize(EncodedOs::from_encoded(config_path).to_path())?;
|
||||
cfg.load_file(&EncodedOs::from_path(&abs_path))?;
|
||||
cfg.load_recursive_files()?;
|
||||
} else {
|
||||
cfg = Config::load()?;
|
||||
|
||||
@@ -18,3 +18,4 @@ workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
onig = "6.5.3"
|
||||
temp-env = "0.3.6"
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::{
|
||||
cli_args::{self, ArgError, ArgIter, CliTarget},
|
||||
command::Command,
|
||||
@@ -3765,7 +3767,7 @@ pub enum ReplayStep {
|
||||
Arg(String),
|
||||
|
||||
/// A base path to expand relative paths against.
|
||||
Expand(String),
|
||||
Expand(EncodedOs),
|
||||
|
||||
/// A conditional argument. This arg is parsed only if all
|
||||
/// conditions match (an "AND"). An "OR" can be achieved by
|
||||
@@ -3876,12 +3878,15 @@ impl Config {
|
||||
/// Load configuration from the target config file at `path`.
|
||||
///
|
||||
/// `path` must be resolved and absolute.
|
||||
pub fn load_file(&mut self, path: &str) -> Result<(), Error> {
|
||||
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
|
||||
pub fn load_file(&mut self, path: &EncodedOs) -> Result<(), Error> {
|
||||
assert!(path.to_path().is_absolute());
|
||||
let file = match crate::file_load::open(path) {
|
||||
Ok(f) => f,
|
||||
Err(crate::file_load::OpenFileError::NotAFile) => {
|
||||
log::warn!("config-file {path}: not reading because it is not a file");
|
||||
log::warn!(
|
||||
"config-file {}: not reading because it is not a file",
|
||||
path.as_encoded()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
Err(crate::file_load::OpenFileError::FileNotFound) => {
|
||||
@@ -3894,8 +3899,8 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Load config from the given File.
|
||||
fn load_fs_file(&mut self, file: std::fs::File, path: &str) -> Result<(), Error> {
|
||||
log::info!("reading configuration file path={path}");
|
||||
fn load_fs_file(&mut self, file: std::fs::File, path: &EncodedOs) -> Result<(), Error> {
|
||||
log::info!("reading configuration file path={}", path.as_encoded());
|
||||
let mut reader = std::io::BufReader::with_capacity(2048, file);
|
||||
self.load_reader(&mut reader, path)
|
||||
}
|
||||
@@ -3904,7 +3909,7 @@ impl Config {
|
||||
fn load_reader<R: std::io::BufRead>(
|
||||
&mut self,
|
||||
reader: &mut R,
|
||||
path: &str,
|
||||
path: &EncodedOs,
|
||||
) -> Result<(), Error> {
|
||||
// If the file starts with a UTF-8 byte order mark, skip it.
|
||||
// https://en.wikipedia.org/wiki/Byte_order_mark#UTF-8
|
||||
@@ -3914,11 +3919,12 @@ impl Config {
|
||||
reader.consume(3);
|
||||
}
|
||||
}
|
||||
let mut iter = cli_args::LineIterator::with_path(reader, path);
|
||||
let mut iter = cli_args::LineIterator::with_path(reader, path.as_encoded());
|
||||
self.load_iter(&mut iter)?;
|
||||
let dir = ghostty_os::osbytes::config_str_to_path(path)
|
||||
let dir = path
|
||||
.to_path()
|
||||
.parent()
|
||||
.map(ghostty_os::osbytes::path_to_config_string)
|
||||
.map(EncodedOs::from_path)
|
||||
.unwrap_or_default();
|
||||
self.expand_paths(&dir)?;
|
||||
Ok(())
|
||||
@@ -3927,23 +3933,26 @@ impl Config {
|
||||
/// Load optional configuration file from `path`. All errors are ignored.
|
||||
///
|
||||
/// Returns the action that was taken.
|
||||
pub fn load_optional_file(&mut self, path: &str) -> OptionalFileAction {
|
||||
pub fn load_optional_file(&mut self, path: &EncodedOs) -> OptionalFileAction {
|
||||
match self.load_file(path) {
|
||||
Ok(()) => OptionalFileAction::Loaded,
|
||||
Err(Error::Io(e)) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
OptionalFileAction::NotFound
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("error reading optional config file, not loading err={err} path={path}");
|
||||
log::warn!(
|
||||
"error reading optional config file, not loading err={err} path={}",
|
||||
path.as_encoded()
|
||||
);
|
||||
OptionalFileAction::Error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The default template with the (decoded, byte-exact) path substituted.
|
||||
fn render_config_template(path: &str) -> Vec<u8> {
|
||||
fn render_config_template(path: &EncodedOs) -> Vec<u8> {
|
||||
let tmpl = include_str!("config-template");
|
||||
let path_bytes = ghostty_os::osbytes::config_str_to_bytes(path);
|
||||
let path_bytes = path.to_bytes();
|
||||
let mut out = Vec::with_capacity(tmpl.len() + path_bytes.len());
|
||||
for (i, part) in tmpl.split("{[path]s}").enumerate() {
|
||||
if i > 0 {
|
||||
@@ -3954,10 +3963,9 @@ impl Config {
|
||||
out
|
||||
}
|
||||
|
||||
fn write_config_template(path: &str) -> std::io::Result<()> {
|
||||
log::info!("creating template config file: path={path}");
|
||||
// `path` carries the reversible byte encoding; decode it for the OS.
|
||||
let os_path = ghostty_os::osbytes::config_str_to_path(path);
|
||||
fn write_config_template(path: &EncodedOs) -> std::io::Result<()> {
|
||||
log::info!("creating template config file: path={}", path.as_encoded());
|
||||
let os_path = path.to_path();
|
||||
if let Some(dir) = os_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
@@ -3982,7 +3990,11 @@ impl Config {
|
||||
if xdg_action != OptionalFileAction::NotFound
|
||||
&& legacy_xdg_action != OptionalFileAction::NotFound
|
||||
{
|
||||
log::warn!("both config files `{legacy_xdg_path}` and `{xdg_path}` exist.");
|
||||
log::warn!(
|
||||
"both config files `{}` and `{}` exist.",
|
||||
legacy_xdg_path.as_encoded(),
|
||||
xdg_path.as_encoded()
|
||||
);
|
||||
log::warn!("loading them both in that order");
|
||||
true
|
||||
} else {
|
||||
@@ -4014,7 +4026,9 @@ impl Config {
|
||||
&& legacy_app_support_action != OptionalFileAction::NotFound
|
||||
{
|
||||
log::warn!(
|
||||
"both config files `{legacy_app_support_path}` and `{app_support_path}` exist."
|
||||
"both config files `{}` and `{}` exist.",
|
||||
legacy_app_support_path.as_encoded(),
|
||||
app_support_path.as_encoded()
|
||||
);
|
||||
log::warn!("loading them both in that order");
|
||||
true
|
||||
@@ -4142,7 +4156,7 @@ impl Config {
|
||||
// Any paths referenced from the CLI are relative to the current working
|
||||
// directory.
|
||||
let cwd = std::fs::canonicalize(".")?;
|
||||
self.expand_paths(&ghostty_os::osbytes::path_to_config_string(&cwd))?;
|
||||
self.expand_paths(&EncodedOs::from_path(&cwd))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4153,7 +4167,7 @@ impl Config {
|
||||
}
|
||||
|
||||
// Keeps track of loaded files to prevent cycles.
|
||||
let mut loaded: HashSet<String> = HashSet::new();
|
||||
let mut loaded: HashSet<EncodedOs> = HashSet::new();
|
||||
|
||||
// We need to insert all of our loaded config-file values
|
||||
// PRIOR to the "-e" in our replay steps, since everything
|
||||
@@ -4186,12 +4200,12 @@ impl Config {
|
||||
|
||||
// All paths should already be absolute at this point because
|
||||
// they're fixed up after each load.
|
||||
assert!(ghostty_os::osbytes::config_str_to_path(&path).is_absolute());
|
||||
assert!(path.to_path().is_absolute());
|
||||
|
||||
// We must only load a unique file once
|
||||
if !loaded.insert(path.clone()) {
|
||||
let diag = Diagnostic {
|
||||
message: format!("config-file {path}: cycle detected"),
|
||||
message: format!("config-file {}: cycle detected", path.as_encoded()),
|
||||
..Diagnostic::default()
|
||||
};
|
||||
self.diagnostics.append(diag.clone());
|
||||
@@ -4199,13 +4213,14 @@ impl Config {
|
||||
continue;
|
||||
}
|
||||
|
||||
let file = match std::fs::File::open(ghostty_os::osbytes::config_str_to_path(&path)) {
|
||||
let file = match std::fs::File::open(path.to_path()) {
|
||||
Ok(f) => f,
|
||||
Err(err) => {
|
||||
if err.kind() != std::io::ErrorKind::NotFound || !optional {
|
||||
let diag = Diagnostic {
|
||||
message: format!(
|
||||
"error opening config-file {path}: {}",
|
||||
"error opening config-file {}: {}",
|
||||
path.as_encoded(),
|
||||
zig_io_error_name(&err)
|
||||
),
|
||||
..Diagnostic::default()
|
||||
@@ -4221,7 +4236,8 @@ impl Config {
|
||||
if !meta.is_file() {
|
||||
let diag = Diagnostic {
|
||||
message: format!(
|
||||
"config-file {path}: not reading because file type is {}",
|
||||
"config-file {}: not reading because file type is {}",
|
||||
path.as_encoded(),
|
||||
file_kind_name(&meta)
|
||||
),
|
||||
..Diagnostic::default()
|
||||
@@ -4347,9 +4363,9 @@ impl Config {
|
||||
|
||||
/// Expand the relative paths in config-files to be absolute paths
|
||||
/// relative to the base directory.
|
||||
fn expand_paths(&mut self, base: &str) -> Result<(), Error> {
|
||||
fn expand_paths(&mut self, base: &EncodedOs) -> Result<(), Error> {
|
||||
// Keep track of this step for replays
|
||||
self.replay_steps.push(ReplayStep::Expand(base.to_string()));
|
||||
self.replay_steps.push(ReplayStep::Expand(base.clone()));
|
||||
|
||||
// Expand all of our paths
|
||||
if let Some(path) = &mut self.background_image {
|
||||
@@ -4376,7 +4392,7 @@ impl Config {
|
||||
// Find our theme file and open it. See the open function for details.
|
||||
let resources_dir = ghostty_os::resources_dir().ok();
|
||||
let Some(themefile) = crate::theme::open(
|
||||
name,
|
||||
&EncodedOs::from_encoded(name),
|
||||
resources_dir.as_ref().and_then(|r| r.app()),
|
||||
&mut self.diagnostics,
|
||||
) else {
|
||||
@@ -4400,7 +4416,7 @@ impl Config {
|
||||
|
||||
// Load our theme
|
||||
let mut reader = std::io::BufReader::with_capacity(2048, file);
|
||||
let mut iter = cli_args::LineIterator::with_path(&mut reader, path);
|
||||
let mut iter = cli_args::LineIterator::with_path(&mut reader, path.as_encoded());
|
||||
new_config.load_iter(&mut iter)?;
|
||||
|
||||
// Setup our replay to be conditional.
|
||||
@@ -4641,14 +4657,17 @@ impl Config {
|
||||
let pw = ghostty_os::passwd::get()?;
|
||||
if self.command.is_none() {
|
||||
if let Some(sh) = pw.shell {
|
||||
log::info!("default shell src=passwd value={sh}");
|
||||
self.command = Some(Command::Shell(sh));
|
||||
log::info!("default shell src=passwd value={}", sh.as_encoded());
|
||||
self.command = Some(Command::Shell(sh.into_encoded()));
|
||||
}
|
||||
}
|
||||
|
||||
if *wd == WorkingDirectory::Home {
|
||||
if let Some(home) = pw.home {
|
||||
log::info!("default working directory src=passwd value={home}");
|
||||
log::info!(
|
||||
"default working directory src=passwd value={}",
|
||||
home.as_encoded()
|
||||
);
|
||||
*wd = WorkingDirectory::Path(home);
|
||||
} else {
|
||||
*wd = WorkingDirectory::Inherit;
|
||||
@@ -4951,7 +4970,7 @@ mod tests {
|
||||
let td = ghostty_os::TempDir::init().unwrap();
|
||||
let dir = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
|
||||
let file = dir.join("config.ghostty");
|
||||
let enc = ghostty_os::osbytes::path_to_config_string(&file);
|
||||
let enc = EncodedOs::from_path(&file);
|
||||
Config::write_config_template(&enc).unwrap();
|
||||
let data = std::fs::read(&file).unwrap();
|
||||
let raw = file.as_os_str().as_bytes();
|
||||
@@ -4985,7 +5004,10 @@ mod tests {
|
||||
let data = "\u{feff}abnormal-command-exit-runtime = 2500\n";
|
||||
let mut reader = Cursor::new(data.as_bytes().to_vec());
|
||||
let mut cfg = Config::default();
|
||||
cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty")
|
||||
cfg.load_reader(
|
||||
&mut reader,
|
||||
&EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"),
|
||||
)
|
||||
.unwrap();
|
||||
cfg.finalize().unwrap();
|
||||
|
||||
@@ -4997,7 +5019,10 @@ mod tests {
|
||||
let data = "abnormal-command-exit-runtime = 2500\n";
|
||||
let mut reader = Cursor::new(data.as_bytes().to_vec());
|
||||
let mut cfg = Config::default();
|
||||
cfg.load_reader(&mut reader, "/home/ghostty/.config/ghostty/config.ghostty")
|
||||
cfg.load_reader(
|
||||
&mut reader,
|
||||
&EncodedOs::from_encoded("/home/ghostty/.config/ghostty/config.ghostty"),
|
||||
)
|
||||
.unwrap();
|
||||
cfg.finalize().unwrap();
|
||||
|
||||
@@ -5141,11 +5166,11 @@ mod tests {
|
||||
cfg.load_iter(&mut i).unwrap();
|
||||
cfg.finalize().unwrap();
|
||||
|
||||
let expected = ghostty_os::expand_home("~/projects/ghostty")
|
||||
.unwrap_or_else(|_| "~/projects/ghostty".to_string());
|
||||
let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty"))
|
||||
.unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty"));
|
||||
assert_eq!(
|
||||
cfg.working_directory.as_ref().unwrap().value().unwrap(),
|
||||
expected
|
||||
&expected
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5420,7 +5445,7 @@ mod tests {
|
||||
let mut cfg = Config::default();
|
||||
cfg.load_iter(&mut it(&[&format!("--config-file={}", a.display())]))
|
||||
.unwrap();
|
||||
cfg.expand_paths("/").unwrap();
|
||||
cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap();
|
||||
cfg.load_recursive_files().unwrap();
|
||||
assert!(
|
||||
cfg.diagnostics
|
||||
@@ -5435,7 +5460,7 @@ mod tests {
|
||||
let mut cfg = Config::default();
|
||||
cfg.load_iter(&mut it(&["--config-file=?/nonexistent/ghostty/file"]))
|
||||
.unwrap();
|
||||
cfg.expand_paths("/").unwrap();
|
||||
cfg.expand_paths(&EncodedOs::from_encoded("/")).unwrap();
|
||||
cfg.load_recursive_files().unwrap();
|
||||
assert!(cfg.diagnostics.empty());
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Opening the config for editing (port of `config/edit.zig`).
|
||||
|
||||
use std::{fs::OpenOptions, io, path::Path};
|
||||
use std::{fs::OpenOptions, io};
|
||||
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::file_load;
|
||||
|
||||
@@ -13,12 +15,16 @@ use crate::file_load;
|
||||
/// come first.
|
||||
///
|
||||
/// The config file (and its directory) is created if it doesn't exist.
|
||||
pub fn open_path() -> io::Result<String> {
|
||||
pub fn open_path() -> io::Result<EncodedOs> {
|
||||
// Get the path we should open
|
||||
let config_path = config_path()?;
|
||||
|
||||
// The config path is an encoded OS string: decode before touching the
|
||||
// filesystem.
|
||||
let real_path = config_path.to_path();
|
||||
|
||||
// Create config directory recursively.
|
||||
if let Some(config_dir) = Path::new(&config_path).parent() {
|
||||
if let Some(config_dir) = real_path.parent() {
|
||||
std::fs::create_dir_all(config_dir)?;
|
||||
}
|
||||
|
||||
@@ -26,7 +32,7 @@ pub fn open_path() -> io::Result<String> {
|
||||
match OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(&config_path)
|
||||
.open(&real_path)
|
||||
{
|
||||
Ok(_) => {}
|
||||
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {}
|
||||
@@ -41,15 +47,15 @@ fn xdg_err(e: ghostty_os::xdg::Error) -> io::Error {
|
||||
}
|
||||
|
||||
/// Returns the config path to use for open for the current OS.
|
||||
fn config_path() -> io::Result<String> {
|
||||
fn config_path() -> io::Result<EncodedOs> {
|
||||
let paths = config_path_candidates()?;
|
||||
|
||||
// Find the first path that exists and is non-empty. If no paths are
|
||||
// non-empty but at least one exists, we will return the first path that
|
||||
// exists.
|
||||
let mut exists: Option<&String> = None;
|
||||
let mut exists: Option<&EncodedOs> = None;
|
||||
for path in &paths {
|
||||
let meta = match std::fs::File::open(path).and_then(|f| f.metadata()) {
|
||||
let meta = match std::fs::File::open(path.to_path()).and_then(|f| f.metadata()) {
|
||||
Ok(m) => m,
|
||||
// File doesn't exist, continue.
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => continue,
|
||||
@@ -79,7 +85,7 @@ fn config_path() -> io::Result<String> {
|
||||
|
||||
/// Returns a list of possible paths the main config file could be
|
||||
/// in for the current OS.
|
||||
fn config_path_candidates() -> io::Result<Vec<String>> {
|
||||
fn config_path_candidates() -> io::Result<Vec<EncodedOs>> {
|
||||
let mut paths = Vec::with_capacity(4);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -92,3 +98,38 @@ fn config_path_candidates() -> io::Result<Vec<String>> {
|
||||
paths.push(file_load::legacy_default_xdg_path().map_err(xdg_err)?);
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix, not(target_os = "macos")))]
|
||||
mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
|
||||
use super::*;
|
||||
|
||||
/// "Open config" must create/return the file under the *decoded* XDG
|
||||
/// directory, not a second file at the escaped pathname.
|
||||
#[test]
|
||||
fn open_path_uses_decoded_non_utf8_xdg_dir() {
|
||||
let td = ghostty_os::TempDir::init().unwrap();
|
||||
let xdg = td.path().join(std::ffi::OsStr::from_bytes(b"cfg\xff"));
|
||||
let value = std::ffi::OsString::from_vec(xdg.as_os_str().as_bytes().to_vec());
|
||||
temp_env::with_var("XDG_CONFIG_HOME", Some(&value), || {
|
||||
let p = open_path().unwrap();
|
||||
let real = p.to_path();
|
||||
assert_eq!(real, xdg.join("ghostty/config.ghostty"));
|
||||
assert!(real.is_file());
|
||||
// Nothing was created at the escaped (encoded) pathname.
|
||||
assert_ne!(p.as_encoded().as_bytes(), real.as_os_str().as_bytes());
|
||||
assert!(!std::path::Path::new(p.as_encoded()).exists());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidates_are_xdg_paths_in_priority_order() {
|
||||
temp_env::with_var("XDG_CONFIG_HOME", Some("/x/cfg"), || {
|
||||
let c = config_path_candidates().unwrap();
|
||||
assert_eq!(c[0], "/x/cfg/ghostty/config.ghostty");
|
||||
assert_eq!(c[1], "/x/cfg/ghostty/config");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -7,10 +7,10 @@
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
use ghostty_os::xdg;
|
||||
use ghostty_os::{osbytes::EncodedOs, xdg};
|
||||
|
||||
/// Default path for the XDG home configuration file.
|
||||
pub fn default_xdg_path() -> Result<String, xdg::Error> {
|
||||
pub fn default_xdg_path() -> Result<EncodedOs, xdg::Error> {
|
||||
xdg::config(&xdg::Options {
|
||||
subdir: Some("ghostty/config.ghostty"),
|
||||
home: None,
|
||||
@@ -18,7 +18,7 @@ pub fn default_xdg_path() -> Result<String, xdg::Error> {
|
||||
}
|
||||
|
||||
/// Ghostty <1.3.0 default path for the XDG home configuration file.
|
||||
pub fn legacy_default_xdg_path() -> Result<String, xdg::Error> {
|
||||
pub fn legacy_default_xdg_path() -> Result<EncodedOs, xdg::Error> {
|
||||
xdg::config(&xdg::Options {
|
||||
subdir: Some("ghostty/config"),
|
||||
home: None,
|
||||
@@ -26,7 +26,7 @@ pub fn legacy_default_xdg_path() -> Result<String, xdg::Error> {
|
||||
}
|
||||
|
||||
/// Preferred default path for the XDG home configuration file.
|
||||
pub fn preferred_xdg_path() -> Result<String, xdg::Error> {
|
||||
pub fn preferred_xdg_path() -> Result<EncodedOs, xdg::Error> {
|
||||
// If the XDG path exists, use that.
|
||||
let xdg_path = default_xdg_path()?;
|
||||
if open(&xdg_path).is_ok() {
|
||||
@@ -45,28 +45,28 @@ pub fn preferred_xdg_path() -> Result<String, xdg::Error> {
|
||||
|
||||
/// Default path for the macOS Application Support configuration file.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn default_app_support_path() -> Result<String, xdg::Error> {
|
||||
pub fn default_app_support_path() -> Result<EncodedOs, xdg::Error> {
|
||||
app_support_dir("config.ghostty")
|
||||
}
|
||||
|
||||
/// Ghostty <1.3.0 default path for the macOS Application Support
|
||||
/// configuration file.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn legacy_default_app_support_path() -> Result<String, xdg::Error> {
|
||||
pub fn legacy_default_app_support_path() -> Result<EncodedOs, xdg::Error> {
|
||||
app_support_dir("config")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn app_support_dir(sub_path: &str) -> Result<String, xdg::Error> {
|
||||
fn app_support_dir(sub_path: &str) -> Result<EncodedOs, xdg::Error> {
|
||||
ghostty_os::macos::app_support_dir(sub_path)
|
||||
.map(|p| ghostty_os::osbytes::path_to_config_string(&p))
|
||||
.map(|p| EncodedOs::from_path(&p))
|
||||
.map_err(|_| xdg::Error::AppleApiFailed)
|
||||
}
|
||||
|
||||
/// Preferred default path for the macOS Application Support configuration
|
||||
/// file.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn preferred_app_support_path() -> Result<String, xdg::Error> {
|
||||
pub fn preferred_app_support_path() -> Result<EncodedOs, xdg::Error> {
|
||||
Ok(preferred_of(
|
||||
default_app_support_path()?,
|
||||
legacy_default_app_support_path()?,
|
||||
@@ -76,7 +76,7 @@ pub fn preferred_app_support_path() -> Result<String, xdg::Error> {
|
||||
/// Pick between a new path and its legacy path: the new one if it exists,
|
||||
/// else the legacy one if it exists, else the new one.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn preferred_of(new: String, legacy: String) -> String {
|
||||
fn preferred_of(new: EncodedOs, legacy: EncodedOs) -> EncodedOs {
|
||||
// If the new path exists, use that.
|
||||
if open(&new).is_ok() {
|
||||
return new;
|
||||
@@ -95,9 +95,9 @@ fn preferred_of(new: String, legacy: String) -> String {
|
||||
/// one if it exists, else Application Support.
|
||||
#[cfg(any(target_os = "macos", test))]
|
||||
fn preferred_macos(
|
||||
app_support_path: String,
|
||||
xdg_path: &dyn Fn() -> Result<String, xdg::Error>,
|
||||
) -> Result<String, xdg::Error> {
|
||||
app_support_path: EncodedOs,
|
||||
xdg_path: &dyn Fn() -> Result<EncodedOs, xdg::Error>,
|
||||
) -> Result<EncodedOs, xdg::Error> {
|
||||
// macOS prefers the Application Support directory if it exists.
|
||||
if open(&app_support_path).is_ok() {
|
||||
return Ok(app_support_path);
|
||||
@@ -119,7 +119,7 @@ fn preferred_macos(
|
||||
/// This doesn't create or populate the file with any default
|
||||
/// contents; downstream callers must handle this.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
|
||||
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
|
||||
preferred_macos(preferred_app_support_path()?, &preferred_xdg_path)
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
|
||||
/// This doesn't create or populate the file with any default
|
||||
/// contents; downstream callers must handle this.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn preferred_default_file_path() -> Result<String, xdg::Error> {
|
||||
pub fn preferred_default_file_path() -> Result<EncodedOs, xdg::Error> {
|
||||
// All other platforms use XDG only
|
||||
preferred_xdg_path()
|
||||
}
|
||||
@@ -158,21 +158,24 @@ impl std::error::Error for OpenFileError {}
|
||||
/// Opens the file at the given path and returns the file handle
|
||||
/// if it exists and is non-empty. This also constrains the possible
|
||||
/// errors to a smaller set that we can explicitly handle.
|
||||
pub fn open(path: &str) -> Result<File, OpenFileError> {
|
||||
assert!(ghostty_os::osbytes::config_str_to_path(path).is_absolute());
|
||||
pub fn open(path: &EncodedOs) -> Result<File, OpenFileError> {
|
||||
let real = path.to_path();
|
||||
assert!(real.is_absolute());
|
||||
|
||||
// `path` may carry the reversible byte escape of non-UTF-8 names.
|
||||
let file = File::open(ghostty_os::osbytes::config_str_to_os(path)).map_err(|err| {
|
||||
let file = File::open(&real).map_err(|err| {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
OpenFileError::FileNotFound
|
||||
} else {
|
||||
log::warn!("unexpected file open error path={path} err={err}");
|
||||
log::warn!(
|
||||
"unexpected file open error path={} err={err}",
|
||||
real.display()
|
||||
);
|
||||
OpenFileError::FileOpenFailed
|
||||
}
|
||||
})?;
|
||||
|
||||
let meta = file.metadata().map_err(|err| {
|
||||
log::warn!("error getting file stat path={path} err={err}");
|
||||
log::warn!("error getting file stat path={} err={err}", real.display());
|
||||
OpenFileError::FileOpenFailed
|
||||
})?;
|
||||
if !meta.is_file() {
|
||||
@@ -190,21 +193,25 @@ pub fn open(path: &str) -> Result<File, OpenFileError> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn e(s: &str) -> EncodedOs {
|
||||
EncodedOs::from_encoded(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_missing_dir_and_empty() {
|
||||
assert_eq!(
|
||||
open("/nonexistent/ghostty/file").unwrap_err(),
|
||||
open(&e("/nonexistent/ghostty/file")).unwrap_err(),
|
||||
OpenFileError::FileNotFound
|
||||
);
|
||||
assert_eq!(open("/tmp").unwrap_err(), OpenFileError::NotAFile);
|
||||
assert_eq!(open(&e("/tmp")).unwrap_err(), OpenFileError::NotAFile);
|
||||
let p = std::env::temp_dir().join(format!("ghostty-file-load-{}", std::process::id()));
|
||||
std::fs::write(&p, "").unwrap();
|
||||
assert_eq!(
|
||||
open(p.to_str().unwrap()).unwrap_err(),
|
||||
open(&EncodedOs::from_path(&p)).unwrap_err(),
|
||||
OpenFileError::FileIsEmpty
|
||||
);
|
||||
std::fs::write(&p, "a=b").unwrap();
|
||||
assert!(open(p.to_str().unwrap()).is_ok());
|
||||
assert!(open(&EncodedOs::from_path(&p)).is_ok());
|
||||
std::fs::remove_file(&p).unwrap();
|
||||
}
|
||||
|
||||
@@ -212,18 +219,18 @@ mod tests {
|
||||
fn preferred_of_prefers_existing_new_then_legacy() {
|
||||
let dir = std::env::temp_dir().join(format!("ghostty-pref-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let new = dir.join("config.ghostty").to_str().unwrap().to_owned();
|
||||
let legacy = dir.join("config").to_str().unwrap().to_owned();
|
||||
let _ = std::fs::remove_file(&new);
|
||||
let _ = std::fs::remove_file(&legacy);
|
||||
let new = EncodedOs::from_path(&dir.join("config.ghostty"));
|
||||
let legacy = EncodedOs::from_path(&dir.join("config"));
|
||||
let _ = std::fs::remove_file(new.to_path());
|
||||
let _ = std::fs::remove_file(legacy.to_path());
|
||||
|
||||
// Neither exists: the new one.
|
||||
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
|
||||
// Only legacy exists: the legacy one.
|
||||
std::fs::write(&legacy, "a=b").unwrap();
|
||||
std::fs::write(legacy.to_path(), "a=b").unwrap();
|
||||
assert_eq!(preferred_of(new.clone(), legacy.clone()), legacy);
|
||||
// Both exist: the new one.
|
||||
std::fs::write(&new, "a=b").unwrap();
|
||||
std::fs::write(new.to_path(), "a=b").unwrap();
|
||||
assert_eq!(preferred_of(new.clone(), legacy.clone()), new);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -232,19 +239,19 @@ mod tests {
|
||||
fn preferred_macos_order() {
|
||||
let dir = std::env::temp_dir().join(format!("ghostty-pref-mac-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let app = dir.join("app").to_str().unwrap().to_owned();
|
||||
let xdg_p = dir.join("xdg").to_str().unwrap().to_owned();
|
||||
let _ = std::fs::remove_file(&app);
|
||||
let _ = std::fs::remove_file(&xdg_p);
|
||||
let app = EncodedOs::from_path(&dir.join("app"));
|
||||
let xdg_p = EncodedOs::from_path(&dir.join("xdg"));
|
||||
let _ = std::fs::remove_file(app.to_path());
|
||||
let _ = std::fs::remove_file(xdg_p.to_path());
|
||||
let xdg_fn = || Ok(xdg_p.clone());
|
||||
|
||||
// Neither exists: app support.
|
||||
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
|
||||
// XDG exists only: XDG.
|
||||
std::fs::write(&xdg_p, "a=b").unwrap();
|
||||
std::fs::write(xdg_p.to_path(), "a=b").unwrap();
|
||||
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), xdg_p);
|
||||
// App support exists: app support wins.
|
||||
std::fs::write(&app, "a=b").unwrap();
|
||||
std::fs::write(app.to_path(), "a=b").unwrap();
|
||||
assert_eq!(preferred_macos(app.clone(), &xdg_fn).unwrap(), app);
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -252,8 +259,8 @@ mod tests {
|
||||
#[test]
|
||||
fn default_paths_end_with_expected_names() {
|
||||
if let (Ok(a), Ok(b)) = (default_xdg_path(), legacy_default_xdg_path()) {
|
||||
assert!(a.ends_with("ghostty/config.ghostty"));
|
||||
assert!(b.ends_with("ghostty/config"));
|
||||
assert!(a.ends_with_plain("ghostty/config.ghostty"));
|
||||
assert!(b.ends_with_plain("ghostty/config"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ use std::{
|
||||
path::{Component, PathBuf},
|
||||
};
|
||||
|
||||
use ghostty_os::osbytes::{config_str_to_path, path_to_config_string};
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::{
|
||||
c_value::CValue,
|
||||
@@ -20,21 +20,21 @@ use crate::{
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Path {
|
||||
/// No error if the file does not exist.
|
||||
Optional(String),
|
||||
Optional(EncodedOs),
|
||||
|
||||
/// The file is required to exist.
|
||||
Required(String),
|
||||
Required(EncodedOs),
|
||||
}
|
||||
|
||||
impl Default for Path {
|
||||
fn default() -> Self {
|
||||
Path::Required(String::new())
|
||||
Path::Required(EncodedOs::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Lexically resolve `base/path` like `std.fs.path.resolve`.
|
||||
fn resolve_lexical(base: &str, path: &str) -> String {
|
||||
let joined = config_str_to_path(base).join(config_str_to_path(path));
|
||||
fn resolve_lexical(base: &EncodedOs, path: &EncodedOs) -> EncodedOs {
|
||||
let joined = base.to_path().join(path.to_path());
|
||||
let mut out = PathBuf::new();
|
||||
for c in joined.components() {
|
||||
match c {
|
||||
@@ -45,29 +45,24 @@ fn resolve_lexical(base: &str, path: &str) -> String {
|
||||
other => out.push(other.as_os_str()),
|
||||
}
|
||||
}
|
||||
path_to_config_string(&out)
|
||||
EncodedOs::from_path(&out)
|
||||
}
|
||||
|
||||
impl Path {
|
||||
/// The inner path string.
|
||||
#[must_use]
|
||||
pub fn path(&self) -> &str {
|
||||
pub fn path(&self) -> &EncodedOs {
|
||||
match self {
|
||||
Path::Optional(p) | Path::Required(p) => p,
|
||||
}
|
||||
}
|
||||
|
||||
fn path_mut(&mut self) -> &mut String {
|
||||
fn path_mut(&mut self) -> &mut EncodedOs {
|
||||
match self {
|
||||
Path::Optional(p) | Path::Required(p) => p,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.path().len()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.path().is_empty()
|
||||
@@ -98,9 +93,9 @@ impl Path {
|
||||
}
|
||||
|
||||
Ok(Some(if optional {
|
||||
Path::Optional(value.to_string())
|
||||
Path::Optional(EncodedOs::from_encoded(value))
|
||||
} else {
|
||||
Path::Required(value.to_string())
|
||||
Path::Required(EncodedOs::from_encoded(value))
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -110,16 +105,15 @@ impl Path {
|
||||
/// `base` is the base directory to expand relative paths; it must be an
|
||||
/// absolute path. Errors are added to `diags` if they occur; an `Err` is
|
||||
/// only returned if the base directory cannot be opened.
|
||||
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
||||
// Config strings carry non-UTF-8 bytes via a reversible escape
|
||||
// encoding; filesystem calls use the decoded OS paths.
|
||||
let base_path = config_str_to_path(base);
|
||||
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
||||
// Filesystem calls use the decoded OS paths.
|
||||
let base_path = base.to_path();
|
||||
assert!(base_path.is_absolute());
|
||||
|
||||
let path = self.path().to_string();
|
||||
let path = self.path().clone();
|
||||
|
||||
// If it is already absolute we can ignore it.
|
||||
if path.is_empty() || config_str_to_path(&path).is_absolute() {
|
||||
if path.is_empty() || path.to_path().is_absolute() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -127,21 +121,27 @@ impl Path {
|
||||
// home directory. We explicitly look for "~/" because we don't
|
||||
// support alternate users such as "~alice/"
|
||||
// (Windows isn't supported yet: Zig skips this there.)
|
||||
if !cfg!(windows) && path.starts_with("~/") {
|
||||
if !cfg!(windows) && path.starts_with_plain("~/") {
|
||||
match ghostty_os::expand_home(&path) {
|
||||
Ok(expanded) => {
|
||||
log::debug!("expanding file path from home directory: path={expanded}");
|
||||
log::debug!(
|
||||
"expanding file path from home directory: path={}",
|
||||
expanded.as_encoded()
|
||||
);
|
||||
*self.path_mut() = expanded;
|
||||
}
|
||||
Err(err) => {
|
||||
diags.append(Diagnostic {
|
||||
message: format!("error expanding home directory for path {path}: {err}"),
|
||||
message: format!(
|
||||
"error expanding home directory for path {}: {err}",
|
||||
path.as_encoded()
|
||||
),
|
||||
..Diagnostic::default()
|
||||
});
|
||||
|
||||
// Blank this path so that we don't attempt to resolve it
|
||||
// again
|
||||
*self = Path::Required(String::new());
|
||||
*self = Path::Required(EncodedOs::new());
|
||||
}
|
||||
}
|
||||
return Ok(());
|
||||
@@ -150,9 +150,9 @@ impl Path {
|
||||
// Make sure the base directory can be opened.
|
||||
std::fs::read_dir(&base_path)?;
|
||||
|
||||
let full = base_path.join(config_str_to_path(&path));
|
||||
let full = base_path.join(path.to_path());
|
||||
let abs = match std::fs::canonicalize(&full) {
|
||||
Ok(p) => path_to_config_string(&p),
|
||||
Ok(p) => EncodedOs::from_path(&p),
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
||||
// The file doesn't exist. Try to resolve the relative path
|
||||
// another way.
|
||||
@@ -160,17 +160,21 @@ impl Path {
|
||||
}
|
||||
Err(err) => {
|
||||
diags.append(Diagnostic {
|
||||
message: format!("error resolving file path {path}: {err}"),
|
||||
message: format!("error resolving file path {}: {err}", path.as_encoded()),
|
||||
..Diagnostic::default()
|
||||
});
|
||||
|
||||
// Blank this path so that we don't attempt to resolve it again
|
||||
*self = Path::Required(String::new());
|
||||
*self = Path::Required(EncodedOs::new());
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
|
||||
log::debug!("expanding file path relative={path} abs={abs}");
|
||||
log::debug!(
|
||||
"expanding file path relative={} abs={}",
|
||||
path.as_encoded(),
|
||||
abs.as_encoded()
|
||||
);
|
||||
*self.path_mut() = abs;
|
||||
Ok(())
|
||||
}
|
||||
@@ -180,11 +184,11 @@ impl ConfigField for Path {
|
||||
fn c_value(&self) -> CValue {
|
||||
match self {
|
||||
Path::Optional(p) => CValue::Path {
|
||||
path: p.clone(),
|
||||
path: p.as_encoded().to_owned(),
|
||||
optional: true,
|
||||
},
|
||||
Path::Required(p) => CValue::Path {
|
||||
path: p.clone(),
|
||||
path: p.as_encoded().to_owned(),
|
||||
optional: false,
|
||||
},
|
||||
}
|
||||
@@ -205,8 +209,8 @@ impl ConfigField for Path {
|
||||
/// Used by formatter.
|
||||
fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Path::Optional(p) => writeln!(f.writer, "{} = ?{p}", f.name),
|
||||
Path::Required(p) => writeln!(f.writer, "{} = {p}", f.name),
|
||||
Path::Optional(p) => writeln!(f.writer, "{} = ?{}", f.name, p.as_encoded()),
|
||||
Path::Required(p) => writeln!(f.writer, "{} = {}", f.name, p.as_encoded()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -222,7 +226,7 @@ pub struct RepeatablePath {
|
||||
|
||||
impl RepeatablePath {
|
||||
/// Expand all the paths relative to the base directory.
|
||||
pub fn expand(&mut self, base: &str, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
||||
pub fn expand(&mut self, base: &EncodedOs, diags: &mut DiagnosticList) -> std::io::Result<()> {
|
||||
for path in &mut self.value {
|
||||
path.expand(base, diags)?;
|
||||
}
|
||||
@@ -267,6 +271,10 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::formatter::format_entry;
|
||||
|
||||
fn e(s: &str) -> EncodedOs {
|
||||
EncodedOs::from_encoded(s)
|
||||
}
|
||||
|
||||
fn fmt<T: ConfigField>(v: &T) -> String {
|
||||
let mut s = String::new();
|
||||
format_entry("a", v, &mut s).unwrap();
|
||||
@@ -276,13 +284,13 @@ mod tests {
|
||||
#[test]
|
||||
fn path_parse() {
|
||||
let p = |s| Path::parse(Some(s)).unwrap().unwrap();
|
||||
assert_eq!(p("config.1"), Path::Required("config.1".into()));
|
||||
assert_eq!(p("?config.2"), Path::Optional("config.2".into()));
|
||||
assert_eq!(p("\"?config.3\""), Path::Required("?config.3".into()));
|
||||
assert_eq!(p("?\"config.4\""), Path::Optional("config.4".into()));
|
||||
assert_eq!(p("?"), Path::Optional(String::new()));
|
||||
assert_eq!(p("\"\""), Path::Required(String::new()));
|
||||
assert_eq!(p("?\"\""), Path::Optional(String::new()));
|
||||
assert_eq!(p("config.1"), Path::Required(e("config.1")));
|
||||
assert_eq!(p("?config.2"), Path::Optional(e("config.2")));
|
||||
assert_eq!(p("\"?config.3\""), Path::Required(e("?config.3")));
|
||||
assert_eq!(p("?\"config.4\""), Path::Optional(e("config.4")));
|
||||
assert_eq!(p("?"), Path::Optional(EncodedOs::new()));
|
||||
assert_eq!(p("\"\""), Path::Required(EncodedOs::new()));
|
||||
assert_eq!(p("?\"\""), Path::Optional(EncodedOs::new()));
|
||||
assert_eq!(Path::parse(None), Err(ArgError::ValueRequired));
|
||||
assert_eq!(Path::parse(Some("")), Ok(None));
|
||||
}
|
||||
@@ -292,21 +300,21 @@ mod tests {
|
||||
let mut item = Path::default();
|
||||
|
||||
item.parse_cli(Some("config.1")).unwrap();
|
||||
assert_eq!(item, Path::Required("config.1".into()));
|
||||
assert_eq!(item, Path::Required(e("config.1")));
|
||||
|
||||
item.parse_cli(Some("?config.2")).unwrap();
|
||||
assert_eq!(item, Path::Optional("config.2".into()));
|
||||
assert_eq!(item, Path::Optional(e("config.2")));
|
||||
|
||||
item.parse_cli(Some("\"?config.3\"")).unwrap();
|
||||
assert_eq!(item, Path::Required("?config.3".into()));
|
||||
assert_eq!(item, Path::Required(e("?config.3")));
|
||||
|
||||
// Zero-length values, ignored
|
||||
item.parse_cli(Some("?")).unwrap();
|
||||
assert_eq!(item, Path::Required("?config.3".into()));
|
||||
assert_eq!(item, Path::Required(e("?config.3")));
|
||||
item.parse_cli(Some("\"\"")).unwrap();
|
||||
assert_eq!(item, Path::Required("?config.3".into()));
|
||||
assert_eq!(item, Path::Required(e("?config.3")));
|
||||
item.parse_cli(Some("?\"\"")).unwrap();
|
||||
assert_eq!(item, Path::Required("?config.3".into()));
|
||||
assert_eq!(item, Path::Required(e("?config.3")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -339,9 +347,9 @@ mod tests {
|
||||
|
||||
assert_eq!(list.value.len(), 3);
|
||||
|
||||
assert_eq!(list.value[0], Path::Required("config.1".into()));
|
||||
assert_eq!(list.value[1], Path::Optional("config.2".into()));
|
||||
assert_eq!(list.value[2], Path::Required("?config.3".into()));
|
||||
assert_eq!(list.value[0], Path::Required(e("config.1")));
|
||||
assert_eq!(list.value[1], Path::Optional(e("config.2")));
|
||||
assert_eq!(list.value[2], Path::Required(e("?config.3")));
|
||||
|
||||
list.parse_cli(Some("")).unwrap();
|
||||
assert_eq!(list.value.len(), 0);
|
||||
@@ -370,13 +378,13 @@ mod tests {
|
||||
#[test]
|
||||
fn path_expand_relative_nonexistent_and_absolute() {
|
||||
let mut diags = DiagnosticList::default();
|
||||
let mut p = Path::Required("../nope/x.conf".into());
|
||||
p.expand("/tmp", &mut diags).unwrap();
|
||||
assert_eq!(p, Path::Required("/nope/x.conf".into()));
|
||||
let mut p = Path::Required(e("../nope/x.conf"));
|
||||
p.expand(&e("/tmp"), &mut diags).unwrap();
|
||||
assert_eq!(p, Path::Required(e("/nope/x.conf")));
|
||||
|
||||
let mut p = Path::Optional("/abs/path".into());
|
||||
p.expand("/tmp", &mut diags).unwrap();
|
||||
assert_eq!(p, Path::Optional("/abs/path".into()));
|
||||
let mut p = Path::Optional(e("/abs/path"));
|
||||
p.expand(&e("/tmp"), &mut diags).unwrap();
|
||||
assert_eq!(p, Path::Optional(e("/abs/path")));
|
||||
assert!(diags.is_empty());
|
||||
}
|
||||
|
||||
@@ -390,12 +398,12 @@ mod tests {
|
||||
std::fs::write(&target, b"x").unwrap();
|
||||
std::os::unix::fs::symlink(&target, base.join("link")).unwrap();
|
||||
|
||||
let mut p = Path::Required("link".into());
|
||||
let mut p = Path::Required(e("link"));
|
||||
let mut diags = DiagnosticList::default();
|
||||
p.expand(&path_to_config_string(&base), &mut diags).unwrap();
|
||||
p.expand(&EncodedOs::from_path(&base), &mut diags).unwrap();
|
||||
assert_eq!(diags.items().len(), 0);
|
||||
// The canonical (non-UTF-8) target is preserved byte for byte.
|
||||
assert_eq!(config_str_to_path(p.path()), target);
|
||||
assert!(std::fs::metadata(config_str_to_path(p.path())).is_ok());
|
||||
assert_eq!(p.path().to_path(), target);
|
||||
assert!(std::fs::metadata(p.path().to_path()).is_ok());
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,7 @@
|
||||
|
||||
use std::fs::File;
|
||||
|
||||
use ghostty_os::osbytes::{
|
||||
config_str_to_os,
|
||||
config_str_to_path,
|
||||
os_to_config_string,
|
||||
path_to_config_string,
|
||||
};
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use crate::diagnostics::{Diagnostic, DiagnosticList};
|
||||
|
||||
@@ -33,7 +28,7 @@ impl Location {
|
||||
/// invalid for any reason. For example, it is perfectly valid to
|
||||
/// install and run Ghostty without the resources directory.
|
||||
#[must_use]
|
||||
pub fn dir(self, resources_dir: Option<&str>) -> Option<String> {
|
||||
pub fn dir(self, resources_dir: Option<&EncodedOs>) -> Option<EncodedOs> {
|
||||
match self {
|
||||
Location::User => ghostty_os::xdg::config(&ghostty_os::xdg::Options {
|
||||
subdir: Some("ghostty/themes"),
|
||||
@@ -42,9 +37,7 @@ impl Location {
|
||||
.ok(),
|
||||
Location::Resources => {
|
||||
let base = resources_dir?;
|
||||
Some(path_to_config_string(
|
||||
&config_str_to_path(base).join("themes"),
|
||||
))
|
||||
Some(EncodedOs::from_path(&base.to_path().join("themes")))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,24 +47,24 @@ impl Location {
|
||||
/// order of priority.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LocationIterator {
|
||||
pub resources_dir: Option<String>,
|
||||
pub resources_dir: Option<EncodedOs>,
|
||||
pub i: usize,
|
||||
}
|
||||
|
||||
impl LocationIterator {
|
||||
#[must_use]
|
||||
pub fn new(resources_dir: Option<&str>) -> Self {
|
||||
pub fn new(resources_dir: Option<&EncodedOs>) -> Self {
|
||||
Self {
|
||||
resources_dir: resources_dir.map(str::to_string),
|
||||
resources_dir: resources_dir.cloned(),
|
||||
i: 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next_location(&mut self) -> Option<(Location, String)> {
|
||||
pub fn next_location(&mut self) -> Option<(Location, EncodedOs)> {
|
||||
while self.i < Location::ALL.len() {
|
||||
let location = Location::ALL[self.i];
|
||||
self.i += 1;
|
||||
if let Some(dir) = location.dir(self.resources_dir.as_deref()) {
|
||||
if let Some(dir) = location.dir(self.resources_dir.as_ref()) {
|
||||
return Some((location, dir));
|
||||
}
|
||||
}
|
||||
@@ -86,7 +79,7 @@ impl LocationIterator {
|
||||
/// An opened theme.
|
||||
#[derive(Debug)]
|
||||
pub struct OpenedTheme {
|
||||
pub path: String,
|
||||
pub path: EncodedOs,
|
||||
pub file: File,
|
||||
}
|
||||
|
||||
@@ -160,31 +153,34 @@ fn check_regular(file: &File, theme: &str, diags: &mut DiagnosticList) -> bool {
|
||||
/// the theme resolves to something other than a regular file a diagnostic entry
|
||||
/// will be added to the list and None will be returned.
|
||||
pub fn open(
|
||||
theme: &str,
|
||||
resources_dir: Option<&str>,
|
||||
theme: &EncodedOs,
|
||||
resources_dir: Option<&EncodedOs>,
|
||||
diags: &mut DiagnosticList,
|
||||
) -> Option<OpenedTheme> {
|
||||
let theme_path = theme.to_path();
|
||||
let theme_text = theme.as_encoded();
|
||||
|
||||
// Absolute themes are loaded a different path.
|
||||
if config_str_to_path(theme).is_absolute() {
|
||||
if theme_path.is_absolute() {
|
||||
let file = open_absolute(theme, diags)?;
|
||||
if !check_regular(&file, theme, diags) {
|
||||
if !check_regular(&file, theme_text, diags) {
|
||||
return None;
|
||||
}
|
||||
return Some(OpenedTheme {
|
||||
path: theme.to_string(),
|
||||
path: theme.clone(),
|
||||
file,
|
||||
});
|
||||
}
|
||||
|
||||
let basename = config_str_to_path(theme)
|
||||
let basename = theme_path
|
||||
.file_name()
|
||||
.map(os_to_config_string)
|
||||
.map(EncodedOs::from_os)
|
||||
.unwrap_or_default();
|
||||
if theme != basename {
|
||||
if *theme != basename {
|
||||
diag(
|
||||
diags,
|
||||
format!(
|
||||
"theme \"{theme}\" cannot include path separators unless it is an absolute path"
|
||||
"theme \"{theme_text}\" cannot include path separators unless it is an absolute path"
|
||||
),
|
||||
);
|
||||
return None;
|
||||
@@ -194,10 +190,10 @@ pub fn open(
|
||||
// one that exists.
|
||||
let mut it = LocationIterator::new(resources_dir);
|
||||
while let Some((_, dir)) = it.next_location() {
|
||||
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
|
||||
match File::open(config_str_to_path(&path)) {
|
||||
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
|
||||
match File::open(path.to_path()) {
|
||||
Ok(file) => {
|
||||
if !check_regular(&file, theme, diags) {
|
||||
if !check_regular(&file, theme_text, diags) {
|
||||
return None;
|
||||
}
|
||||
return Some(OpenedTheme { path, file });
|
||||
@@ -208,7 +204,10 @@ pub fn open(
|
||||
Err(err) => {
|
||||
diag(
|
||||
diags,
|
||||
format!("failed to load theme \"{theme}\" from the file \"{path}\": {err}"),
|
||||
format!(
|
||||
"failed to load theme \"{theme_text}\" from the file \"{}\": {err}",
|
||||
path.as_encoded()
|
||||
),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
@@ -219,10 +218,13 @@ pub fn open(
|
||||
// our iterator, reiterate over in order to build a better error message.
|
||||
it.reset();
|
||||
while let Some((_, dir)) = it.next_location() {
|
||||
let path = path_to_config_string(&config_str_to_path(&dir).join(config_str_to_os(theme)));
|
||||
let path = EncodedOs::from_path(&dir.to_path().join(theme.to_os()));
|
||||
diag(
|
||||
diags,
|
||||
format!("theme \"{theme}\" not found, tried path \"{path}\""),
|
||||
format!(
|
||||
"theme \"{theme_text}\" not found, tried path \"{}\"",
|
||||
path.as_encoded()
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -232,8 +234,9 @@ pub fn open(
|
||||
/// Open the given theme from an absolute path. If there are any errors
|
||||
/// then messages will be appended to the given error list and None is
|
||||
/// returned.
|
||||
pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
|
||||
match File::open(config_str_to_path(theme)) {
|
||||
pub fn open_absolute(theme: &EncodedOs, diags: &mut DiagnosticList) -> Option<File> {
|
||||
let theme = theme.as_encoded();
|
||||
match File::open(EncodedOs::from_encoded(theme).to_path()) {
|
||||
Ok(f) => Some(f),
|
||||
Err(err) => {
|
||||
if err.kind() == std::io::ErrorKind::NotFound {
|
||||
@@ -257,10 +260,14 @@ pub fn open_absolute(theme: &str, diags: &mut DiagnosticList) -> Option<File> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn e(s: &str) -> EncodedOs {
|
||||
EncodedOs::from_encoded(s)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn theme_path_separators_rejected() {
|
||||
let mut d = DiagnosticList::default();
|
||||
assert!(open("a/b", None, &mut d).is_none());
|
||||
assert!(open(&e("a/b"), None, &mut d).is_none());
|
||||
assert!(
|
||||
d.items()[0]
|
||||
.message
|
||||
@@ -271,13 +278,13 @@ mod tests {
|
||||
#[test]
|
||||
fn theme_absolute_missing_and_dir() {
|
||||
let mut d = DiagnosticList::default();
|
||||
assert!(open("/nonexistent/ghostty/theme", None, &mut d).is_none());
|
||||
assert!(open(&e("/nonexistent/ghostty/theme"), None, &mut d).is_none());
|
||||
assert_eq!(
|
||||
d.items()[0].message,
|
||||
"failed to load theme from the path \"/nonexistent/ghostty/theme\""
|
||||
);
|
||||
let mut d = DiagnosticList::default();
|
||||
assert!(open("/tmp", None, &mut d).is_none());
|
||||
assert!(open(&e("/tmp"), None, &mut d).is_none());
|
||||
assert_eq!(
|
||||
d.items()[0].message,
|
||||
"not reading theme from \"/tmp\": it is a directory"
|
||||
@@ -290,8 +297,8 @@ mod tests {
|
||||
std::fs::create_dir_all(dir.join("themes")).unwrap();
|
||||
std::fs::write(dir.join("themes/mytheme-zzz"), "background = #000000\n").unwrap();
|
||||
let mut d = DiagnosticList::default();
|
||||
let t = open("mytheme-zzz", Some(dir.to_str().unwrap()), &mut d).unwrap();
|
||||
assert!(t.path.ends_with("themes/mytheme-zzz"));
|
||||
let t = open(&e("mytheme-zzz"), Some(&EncodedOs::from_path(&dir)), &mut d).unwrap();
|
||||
assert!(t.path.ends_with_plain("themes/mytheme-zzz"));
|
||||
assert!(d.is_empty());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
@@ -299,7 +306,7 @@ mod tests {
|
||||
#[test]
|
||||
fn theme_not_found_lists_tried_paths() {
|
||||
let mut d = DiagnosticList::default();
|
||||
assert!(open("nope-zzz", Some("/nonexistent-res"), &mut d).is_none());
|
||||
assert!(open(&e("nope-zzz"), Some(&e("/nonexistent-res")), &mut d).is_none());
|
||||
assert!(d.items().iter().any(|x| x.message
|
||||
== "theme \"nope-zzz\" not found, tried path \"/nonexistent-res/themes/nope-zzz\""));
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ pub enum WorkingDirectory {
|
||||
|
||||
/// Use an explicit working directory path. This may be not be
|
||||
/// expanded until finalize is called.
|
||||
Path(String),
|
||||
Path(ghostty_os::osbytes::EncodedOs),
|
||||
}
|
||||
|
||||
impl ConfigField for WorkingDirectory {
|
||||
@@ -169,7 +169,7 @@ impl ConfigField for WorkingDirectory {
|
||||
*self = match input {
|
||||
"home" => WorkingDirectory::Home,
|
||||
"inherit" => WorkingDirectory::Inherit,
|
||||
_ => WorkingDirectory::Path(input.to_string()),
|
||||
_ => WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(input)),
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
@@ -178,7 +178,7 @@ impl ConfigField for WorkingDirectory {
|
||||
match self {
|
||||
WorkingDirectory::Home => write_str(f, "home"),
|
||||
WorkingDirectory::Inherit => write_str(f, "inherit"),
|
||||
WorkingDirectory::Path(p) => write_str(f, p),
|
||||
WorkingDirectory::Path(p) => write_str(f, p.as_encoded()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -190,7 +190,7 @@ impl WorkingDirectory {
|
||||
return;
|
||||
};
|
||||
|
||||
if !path.starts_with("~/") {
|
||||
if !path.starts_with_plain("~/") {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -198,7 +198,8 @@ impl WorkingDirectory {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
log::warn!(
|
||||
"error expanding home directory for working-directory path={path}: {err}"
|
||||
"error expanding home directory for working-directory path={}: {err}",
|
||||
path.as_encoded()
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -211,7 +212,7 @@ impl WorkingDirectory {
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn value(&self) -> Option<&str> {
|
||||
pub fn value(&self) -> Option<&ghostty_os::osbytes::EncodedOs> {
|
||||
match self {
|
||||
WorkingDirectory::Path(p) => Some(p),
|
||||
WorkingDirectory::Home | WorkingDirectory::Inherit => None,
|
||||
@@ -1044,6 +1045,8 @@ impl ConfigField for RepeatableLink {
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
|
||||
use super::*;
|
||||
use crate::cli_args::ConfigField;
|
||||
|
||||
@@ -1069,18 +1072,28 @@ mod tests {
|
||||
parse(&mut wd, "home").unwrap();
|
||||
assert_eq!(wd, WorkingDirectory::Home);
|
||||
parse(&mut wd, "~/projects/ghostty").unwrap();
|
||||
assert_eq!(wd, WorkingDirectory::Path("~/projects/ghostty".into()));
|
||||
assert_eq!(
|
||||
wd,
|
||||
WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(
|
||||
"~/projects/ghostty"
|
||||
))
|
||||
);
|
||||
parse(&mut wd, "\"/tmp path\"").unwrap();
|
||||
assert_eq!(wd, WorkingDirectory::Path("/tmp path".into()));
|
||||
assert_eq!(
|
||||
wd,
|
||||
WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp path"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn working_directory_finalize() {
|
||||
let mut wd = WorkingDirectory::Path("~/projects/ghostty".into());
|
||||
let mut wd = WorkingDirectory::Path(ghostty_os::osbytes::EncodedOs::from_encoded(
|
||||
"~/projects/ghostty",
|
||||
));
|
||||
wd.finalize();
|
||||
let expected = ghostty_os::expand_home("~/projects/ghostty")
|
||||
.unwrap_or_else(|_| "~/projects/ghostty".to_string());
|
||||
assert_eq!(wd.value().unwrap(), expected);
|
||||
let expected = ghostty_os::expand_home(&EncodedOs::from_encoded("~/projects/ghostty"))
|
||||
.unwrap_or_else(|_| EncodedOs::from_encoded("~/projects/ghostty"));
|
||||
assert_eq!(wd.value().unwrap(), &expected);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,9 @@ onig = "6.5.3"
|
||||
png = "0.18.1"
|
||||
thiserror = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
temp-env = "0.3.6"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
|
||||
@@ -234,7 +234,7 @@ fn init_with(opts: InitOptions, default_logging: Logging) -> &'static GlobalStat
|
||||
|
||||
// Setup i18n
|
||||
if let Some(v) = resources_dir.app()
|
||||
&& let Err(err) = ghostty_os::i18n::init(v)
|
||||
&& let Err(err) = ghostty_os::i18n::init(&v.to_path())
|
||||
{
|
||||
log::warn!("failed to init i18n, translations will not be available err={err}");
|
||||
}
|
||||
|
||||
@@ -419,11 +419,16 @@ pub fn screen_file_contents(
|
||||
/// Write `contents` to `<tempdir>/<loc>.<ext>` (mode 0600) and return the
|
||||
/// canonical path. The temporary directory is intentionally left behind so
|
||||
/// the file stays around for the user (Zig does not delete it either).
|
||||
/// The raw bytes of a path (what Zig copies/pastes for a screen file).
|
||||
fn path_bytes(path: &std::path::Path) -> Vec<u8> {
|
||||
ghostty_os::osbytes::os_bytes(path.as_os_str())
|
||||
}
|
||||
|
||||
pub fn write_screen_temp_file(
|
||||
contents: &str,
|
||||
loc: WriteScreenLoc,
|
||||
emit: WriteScreenFormat,
|
||||
) -> Result<String, CoreError> {
|
||||
) -> Result<std::path::PathBuf, CoreError> {
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::fs::OpenOptionsExt as _;
|
||||
|
||||
@@ -432,7 +437,7 @@ pub fn write_screen_temp_file(
|
||||
let filename = screen_file_name(loc, emit);
|
||||
let file_path = tmp_dir.path().join(&filename);
|
||||
|
||||
let result = (|| -> Result<String, CoreError> {
|
||||
let result = (|| -> Result<std::path::PathBuf, CoreError> {
|
||||
let mut opts = std::fs::OpenOptions::new();
|
||||
opts.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
@@ -440,11 +445,7 @@ pub fn write_screen_temp_file(
|
||||
let mut file = opts.open(&file_path)?;
|
||||
file.write_all(contents.as_bytes())?;
|
||||
file.flush()?;
|
||||
// Reversible config-string encoding: a non-UTF-8 temp dir keeps its
|
||||
// bytes (decode with `config_str_to_path` at OS boundaries).
|
||||
Ok(ghostty_os::osbytes::path_to_config_string(
|
||||
&std::fs::canonicalize(&file_path)?,
|
||||
))
|
||||
Ok(std::fs::canonicalize(&file_path)?)
|
||||
})();
|
||||
|
||||
match result {
|
||||
@@ -628,13 +629,10 @@ impl Surface {
|
||||
|
||||
match write_screen.action {
|
||||
WriteScreenAction::Copy => {
|
||||
// The file name is copied as the raw bytes of the path.
|
||||
self.rt_surface.set_clipboard(
|
||||
Clipboard::Standard,
|
||||
&[(
|
||||
"text/plain".to_owned(),
|
||||
String::from_utf8_lossy(&ghostty_os::osbytes::config_str_to_bytes(&path))
|
||||
.into_owned(),
|
||||
)],
|
||||
&[("text/plain".to_owned(), path_bytes(&path))],
|
||||
false,
|
||||
);
|
||||
}
|
||||
@@ -643,11 +641,11 @@ impl Surface {
|
||||
WriteScreenFormat::Plain | WriteScreenFormat::Vt => OpenUrlKind::Text,
|
||||
WriteScreenFormat::Html => OpenUrlKind::Html,
|
||||
},
|
||||
url: path,
|
||||
url: ghostty_os::osbytes::EncodedOs::from_path(&path),
|
||||
})?,
|
||||
WriteScreenAction::Paste => self.queue_io(IoMessage::write_req(
|
||||
&ghostty_os::osbytes::config_str_to_bytes(&path),
|
||||
)),
|
||||
WriteScreenAction::Paste => {
|
||||
self.queue_io(IoMessage::write_req(&path_bytes(&path)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1278,6 +1276,7 @@ mod tests {
|
||||
let path =
|
||||
write_screen_temp_file(&s, WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap();
|
||||
assert!(path.ends_with("screen.txt"));
|
||||
let path = path.to_string_lossy().into_owned(); // test-only: ASCII temp path
|
||||
let read = std::fs::read_to_string(&path).unwrap();
|
||||
assert_eq!(read, s);
|
||||
#[cfg(unix)]
|
||||
@@ -1292,18 +1291,23 @@ mod tests {
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn screen_temp_file_returns_encoded_non_utf8_path() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
fn screen_temp_file_keeps_non_utf8_path_bytes() {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let td = ghostty_os::temp_dir::TempDir::init().unwrap();
|
||||
let bad = td.path().join(std::ffi::OsStr::from_bytes(b"d\xff"));
|
||||
std::fs::create_dir_all(&bad).unwrap();
|
||||
// Use TMPDIR to force the non-UTF-8 directory.
|
||||
let file = bad.join("x.txt");
|
||||
std::fs::write(&file, b"x").unwrap();
|
||||
let canon = std::fs::canonicalize(&file).unwrap();
|
||||
let enc = ghostty_os::osbytes::path_to_config_string(&canon);
|
||||
assert!(enc.contains('\u{fdd0}'));
|
||||
assert_eq!(ghostty_os::osbytes::config_str_to_path(&enc), canon);
|
||||
let tmpdir = std::ffi::OsString::from_vec(bad.as_os_str().as_bytes().to_vec());
|
||||
// Force the non-UTF-8 directory through TMPDIR.
|
||||
let path = temp_env::with_var("TMPDIR", Some(&tmpdir), || {
|
||||
write_screen_temp_file("x", WriteScreenLoc::Screen, WriteScreenFormat::Plain).unwrap()
|
||||
});
|
||||
assert!(path.as_os_str().as_bytes().contains(&0xff));
|
||||
assert_eq!(std::fs::read(&path).unwrap(), b"x");
|
||||
// Copy/paste carry the exact path bytes (no U+FFFD), and the open
|
||||
// action's EncodedOs decodes back to the same path.
|
||||
assert_eq!(path_bytes(&path), path.as_os_str().as_bytes());
|
||||
let url = ghostty_os::osbytes::EncodedOs::from_path(&path);
|
||||
assert_eq!(url.to_path(), path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -64,7 +64,8 @@ impl Surface {
|
||||
log::info!("application sent invalid base64 data for OSC 52");
|
||||
return;
|
||||
};
|
||||
let text = String::from_utf8_lossy(&decoded).into_owned();
|
||||
// OSC 52 data is written as the application sent it (bytes).
|
||||
let text = decoded;
|
||||
|
||||
// When clipboard-write is "ask" a prompt is displayed to the user
|
||||
// asking them to confirm the clipboard access. Each app runtime
|
||||
@@ -111,11 +112,11 @@ impl Surface {
|
||||
}
|
||||
};
|
||||
|
||||
let mut contents: Vec<(String, String)> = Vec::new();
|
||||
let mut contents: Vec<(String, Vec<u8>)> = Vec::new();
|
||||
match format {
|
||||
CopyToClipboard::Plain => {
|
||||
let Some(s) = run(opts) else { return };
|
||||
contents.push(("text/plain".to_owned(), s));
|
||||
contents.push(("text/plain".to_owned(), s.into_bytes()));
|
||||
}
|
||||
|
||||
CopyToClipboard::Vt => {
|
||||
@@ -125,7 +126,7 @@ impl Surface {
|
||||
// it contains escape sequences that should be preserved
|
||||
// as-is
|
||||
let Some(s) = run(o) else { return };
|
||||
contents.push(("text/plain".to_owned(), s));
|
||||
contents.push(("text/plain".to_owned(), s.into_bytes()));
|
||||
}
|
||||
|
||||
CopyToClipboard::Html => {
|
||||
@@ -135,7 +136,7 @@ impl Surface {
|
||||
// since HTML has its own character encoding and entity
|
||||
// system
|
||||
let Some(s) = run(o) else { return };
|
||||
contents.push(("text/html".to_owned(), s));
|
||||
contents.push(("text/html".to_owned(), s.into_bytes()));
|
||||
}
|
||||
|
||||
CopyToClipboard::Mixed => {
|
||||
@@ -143,7 +144,7 @@ impl Surface {
|
||||
let Some(plain) = run(opts.clone()) else {
|
||||
return;
|
||||
};
|
||||
contents.push(("text/plain".to_owned(), plain));
|
||||
contents.push(("text/plain".to_owned(), plain.into_bytes()));
|
||||
|
||||
// Second, generate HTML without codepoint mappings
|
||||
let mut o = opts;
|
||||
@@ -154,7 +155,7 @@ impl Surface {
|
||||
o.background = None;
|
||||
o.foreground = None;
|
||||
let Some(html) = run(o) else { return };
|
||||
contents.push(("text/html".to_owned(), html));
|
||||
contents.push(("text/html".to_owned(), html.into_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +208,7 @@ impl Surface {
|
||||
ClipboardRequest::Osc52Write(clipboard) => {
|
||||
self.rt_surface.set_clipboard(
|
||||
clipboard,
|
||||
&[("text/plain".to_owned(), data.to_owned())],
|
||||
&[("text/plain".to_owned(), data.as_bytes().to_vec())],
|
||||
!confirmed,
|
||||
);
|
||||
Ok(())
|
||||
|
||||
@@ -469,8 +469,8 @@ impl Surface {
|
||||
.working_directory
|
||||
.as_ref()
|
||||
.and_then(|wd| wd.value())
|
||||
.map(str::to_owned);
|
||||
ecfg.resources_dir = global::state().resources_dir.host().map(str::to_owned);
|
||||
.cloned();
|
||||
ecfg.resources_dir = global::state().resources_dir.host().cloned();
|
||||
// The Windows exec backend has no pre-exec/post-fork hooks,
|
||||
// cgroup scopes or rlimits (Zig: Linux/POSIX only).
|
||||
#[cfg(unix)]
|
||||
@@ -940,9 +940,8 @@ impl Surface {
|
||||
}
|
||||
|
||||
Message::PwdChange(w) => {
|
||||
// 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());
|
||||
// Keep the OSC 7 bytes (see `Pwd`).
|
||||
let pwd = ghostty_os::osbytes::EncodedOs::from_bytes(w.slice());
|
||||
self.perform(RtAction::Pwd(Pwd { pwd }));
|
||||
}
|
||||
|
||||
@@ -1368,10 +1367,10 @@ impl Surface {
|
||||
/// Returns the pwd of the terminal, if any. This is always copied
|
||||
/// because the pwd can change at any point from termio.
|
||||
#[must_use]
|
||||
pub fn pwd(&self) -> Option<String> {
|
||||
pub fn pwd(&self) -> Option<ghostty_os::osbytes::EncodedOs> {
|
||||
let t = self.lock_terminal();
|
||||
let pwd = t.get_pwd()?;
|
||||
Some(ghostty_os::osbytes::bytes_to_config_string(pwd))
|
||||
Some(ghostty_os::osbytes::EncodedOs::from_bytes(pwd))
|
||||
}
|
||||
|
||||
/// The raw bytes of [`Surface::pwd`] (Zig returns the OSC 7 bytes as is).
|
||||
|
||||
@@ -394,15 +394,20 @@ fn drag_left_click_triple_selection(
|
||||
/// Resolve a relative file path to an absolute path using `pwd` (Zig
|
||||
/// `resolvePathForOpening`). Returns `None` for absolute paths (use as-is),
|
||||
/// when there's no pwd, or when the resolved path doesn't exist.
|
||||
fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
|
||||
///
|
||||
/// `pwd` is the raw OSC 7 byte string; `path` is plain terminal text.
|
||||
fn resolve_path_for_opening(
|
||||
pwd: Option<&[u8]>,
|
||||
path: &str,
|
||||
) -> Option<ghostty_os::osbytes::EncodedOs> {
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
if Path::new(path).is_absolute() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let pwd = pwd?;
|
||||
let joined = Path::new(pwd).join(path);
|
||||
let pwd = ghostty_os::osbytes::os_from_bytes(pwd?.to_vec());
|
||||
let joined = Path::new(&pwd).join(path);
|
||||
|
||||
// Lexically normalize (std.fs.path.resolve).
|
||||
let mut out = PathBuf::new();
|
||||
@@ -419,7 +424,16 @@ fn resolve_path_for_opening(pwd: Option<&str>, path: &str) -> Option<String> {
|
||||
if !out.exists() {
|
||||
return None;
|
||||
}
|
||||
Some(out.to_string_lossy().into_owned())
|
||||
Some(ghostty_os::osbytes::EncodedOs::from_path(&out))
|
||||
}
|
||||
|
||||
/// What to hand to the opener for a matched `open` link: a relative path
|
||||
/// that resolves against the terminal's pwd becomes that (OS) path, anything
|
||||
/// else (a URL, an absolute path) is the matched *plain text*, wrapped so it
|
||||
/// is never mistaken for an escaped OS string.
|
||||
fn open_link_target(pwd: Option<&[u8]>, text: &str) -> ghostty_os::osbytes::EncodedOs {
|
||||
resolve_path_for_opening(pwd, text)
|
||||
.unwrap_or_else(|| ghostty_os::osbytes::EncodedOs::from_plain_text(text))
|
||||
}
|
||||
|
||||
impl Surface {
|
||||
@@ -1603,7 +1617,7 @@ impl Surface {
|
||||
|
||||
/// Return the URI for an OSC8 hyperlink at the given position or `None`
|
||||
/// if there is no hyperlink.
|
||||
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<String> {
|
||||
fn osc8_uri(screen: &Screen, pin: Pin) -> Option<Vec<u8>> {
|
||||
let node = screen.pages.node(pin.node)?;
|
||||
let page = &node.data;
|
||||
let idx = page
|
||||
@@ -1611,13 +1625,13 @@ impl Surface {
|
||||
.ok()?;
|
||||
let id = page.lookup_hyperlink(idx)?;
|
||||
let entry = page.hyperlink_set.get(id)?;
|
||||
Some(String::from_utf8_lossy(&entry.uri).into_owned())
|
||||
Some(entry.uri.clone())
|
||||
}
|
||||
|
||||
/// The URL of the link (`open` regex link or OSC8) under the current
|
||||
/// cursor position, if the mouse is over a link (Zig
|
||||
/// `copy_url_to_clipboard`). Takes the renderer lock.
|
||||
pub(crate) fn link_url_at_cursor(&self) -> Option<String> {
|
||||
pub(crate) fn link_url_at_cursor(&self) -> Option<Vec<u8>> {
|
||||
// If the mouse isn't over a link, nothing we can do.
|
||||
if !self.mouse.borrow().over_link {
|
||||
return None;
|
||||
@@ -1632,10 +1646,14 @@ impl Surface {
|
||||
let link = self.link_at_pos(&t, vp, mods)?;
|
||||
let screen = t.screen();
|
||||
match link.action {
|
||||
LinkAction::Open => Some(screen.selection_string(SelectionString {
|
||||
LinkAction::Open => Some(
|
||||
screen
|
||||
.selection_string(SelectionString {
|
||||
sel: link.selection,
|
||||
trim,
|
||||
})),
|
||||
})
|
||||
.into_bytes(),
|
||||
),
|
||||
LinkAction::_OpenOsc8 => {
|
||||
let uri = Self::osc8_uri(screen, link.selection.start(&screen.pages));
|
||||
if uri.is_none() {
|
||||
@@ -1664,8 +1682,9 @@ impl Surface {
|
||||
sel: link.selection,
|
||||
trim: false,
|
||||
});
|
||||
let pwd = t.get_pwd().map(ghostty_os::osbytes::bytes_to_config_string);
|
||||
resolve_path_for_opening(pwd.as_deref(), &str).unwrap_or(str)
|
||||
// The matched text is plain terminal text (a URL or a
|
||||
// path), never an escaped OS string.
|
||||
open_link_target(t.get_pwd(), &str)
|
||||
}
|
||||
|
||||
LinkAction::_OpenOsc8 => {
|
||||
@@ -1674,7 +1693,7 @@ impl Surface {
|
||||
log::warn!("failed to get URI for OSC8 hyperlink");
|
||||
return Ok(false);
|
||||
};
|
||||
uri
|
||||
ghostty_os::osbytes::EncodedOs::from_bytes(&uri)
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -1703,7 +1722,7 @@ impl Surface {
|
||||
OpenUrlKind::Text => ghostty_os::open::OpenUrlKind::Text,
|
||||
OpenUrlKind::Html => ghostty_os::open::OpenUrlKind::Html,
|
||||
};
|
||||
ghostty_os::open::open(kind, &url)?;
|
||||
ghostty_os::open::open(kind, &url.to_os())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1795,7 +1814,10 @@ impl Surface {
|
||||
break 'link (None, false);
|
||||
};
|
||||
(
|
||||
Some(MouseOverLink { url: uri }),
|
||||
// Display-only text for the status bar.
|
||||
Some(MouseOverLink {
|
||||
url: String::from_utf8_lossy(&uri).into_owned(),
|
||||
}),
|
||||
cfg.link_previews != LinkPreviews::False,
|
||||
)
|
||||
}
|
||||
@@ -2484,21 +2506,55 @@ mod tests {
|
||||
#[test]
|
||||
fn resolve_path_for_opening_semantics() {
|
||||
// Absolute paths are used as-is.
|
||||
assert_eq!(resolve_path_for_opening(Some("/tmp"), "/etc/passwd"), None);
|
||||
assert_eq!(resolve_path_for_opening(Some(b"/tmp"), "/etc/passwd"), None);
|
||||
// No pwd, no resolution.
|
||||
assert_eq!(resolve_path_for_opening(None, "foo"), None);
|
||||
// Non-existent relative paths are not resolved.
|
||||
assert_eq!(
|
||||
resolve_path_for_opening(Some("/tmp"), "does-not-exist-xyz"),
|
||||
resolve_path_for_opening(Some(b"/tmp"), "does-not-exist-xyz"),
|
||||
None
|
||||
);
|
||||
// Existing relative path resolves, including `..`.
|
||||
assert_eq!(
|
||||
resolve_path_for_opening(Some("/tmp"), "../etc"),
|
||||
Some("/etc".to_owned())
|
||||
resolve_path_for_opening(Some(b"/tmp"), "../etc"),
|
||||
Some(ghostty_os::osbytes::EncodedOs::from_plain_text("/etc"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_link_plain_text_is_never_decoded() {
|
||||
// Regex-matched text is ordinary text: the escape character followed
|
||||
// by U+00FF must not turn into the byte 0xFF.
|
||||
let text = "https://example.com/\u{fdd0}\u{ff}";
|
||||
let t = open_link_target(Some(b"/tmp"), text);
|
||||
assert_eq!(t.to_bytes(), text.as_bytes());
|
||||
let abs = open_link_target(None, "/etc/\u{fdd0}\u{ff}");
|
||||
assert_eq!(abs.to_bytes(), "/etc/\u{fdd0}\u{ff}".as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn open_link_resolves_against_non_utf8_pwd() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let td = ghostty_os::TempDir::init().unwrap();
|
||||
let dir = td.path().join(std::ffi::OsStr::from_bytes(b"d\xffir"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("file.txt"), "x").unwrap();
|
||||
// The OSC 7 pwd is raw bytes.
|
||||
let pwd = dir.as_os_str().as_bytes().to_vec();
|
||||
let t = open_link_target(Some(&pwd), "file.txt");
|
||||
assert_eq!(t.to_path(), dir.join("file.txt"));
|
||||
assert!(t.to_path().exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_uri_bytes_are_carried_unchanged() {
|
||||
let uri = b"https://example.com/a\xffb".to_vec();
|
||||
let t = ghostty_os::osbytes::EncodedOs::from_bytes(&uri);
|
||||
assert_eq!(t.to_bytes(), uri);
|
||||
assert_ne!(t.as_encoded().as_bytes(), uri.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroll_amount_direction() {
|
||||
let a = ScrollAmount { delta: -3 };
|
||||
|
||||
@@ -50,7 +50,7 @@ impl Waker for NopWaker {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ClipboardWrite {
|
||||
pub clipboard: Clipboard,
|
||||
pub contents: Vec<(String, String)>,
|
||||
pub contents: Vec<(String, Vec<u8>)>,
|
||||
pub confirm: bool,
|
||||
}
|
||||
|
||||
@@ -131,13 +131,15 @@ impl RtSurface for FakeRt {
|
||||
self.pending.borrow_mut().push_back((clipboard, state));
|
||||
true
|
||||
}
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) {
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
|
||||
self.writes.borrow_mut().push(ClipboardWrite {
|
||||
clipboard,
|
||||
contents: contents.to_vec(),
|
||||
confirm,
|
||||
});
|
||||
if let Some((_, text)) = contents.iter().find(|(m, _)| m == "text/plain") {
|
||||
if let Some((_, bytes)) = contents.iter().find(|(m, _)| m == "text/plain") {
|
||||
// Test harness: the fake clipboard stores text.
|
||||
let text = &String::from_utf8_lossy(bytes).into_owned();
|
||||
// Like GTK, a confirm=true write shows a dialog first; the
|
||||
// harness only stores unconfirmed writes.
|
||||
if confirm {
|
||||
|
||||
@@ -111,7 +111,7 @@ fn pwd_osc7() {
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(pwd, "/tmp/a b");
|
||||
assert_eq!(h.surface.pwd().as_deref(), Some("/tmp/a b"));
|
||||
assert_eq!(h.surface.pwd().unwrap(), "/tmp/a b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -124,12 +124,9 @@ fn pwd_osc7_keeps_non_utf8_bytes() {
|
||||
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!(pwd.to_bytes(), 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()));
|
||||
assert_eq!(h.surface.pwd(), Some(pwd));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -37,6 +37,7 @@ windows-sys = { version = "0.61", features = [
|
||||
[dev-dependencies]
|
||||
minidump = "0.27.0"
|
||||
tempfile = "3.27.0"
|
||||
temp-env = "0.3.6"
|
||||
|
||||
# The fatal-signal handler needs sigaction/sigaltstack/raw syscalls. Unsafe is
|
||||
# denied crate-wide and only allowed in `signal.rs` with SAFETY comments.
|
||||
|
||||
@@ -19,7 +19,8 @@ pub fn default_dir() -> io::Result<Dir> {
|
||||
})
|
||||
.map_err(|e| io::Error::other(e.to_string()))?;
|
||||
Ok(Dir {
|
||||
path: PathBuf::from(path),
|
||||
// The XDG helpers return the encoded form of the OS string.
|
||||
path: path.to_path(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,7 +63,7 @@ impl ReportIterator {
|
||||
if !entry.file_type()?.is_file() {
|
||||
continue;
|
||||
}
|
||||
let name = entry.file_name().to_string_lossy().into_owned();
|
||||
let name = entry.file_name();
|
||||
// statFile follows symlinks; entries here are regular files.
|
||||
let mtime = fs::metadata(self.dir.join(entry.file_name()))?.modified()?;
|
||||
let mtime = match mtime.duration_since(UNIX_EPOCH) {
|
||||
@@ -84,7 +85,8 @@ impl Iterator for ReportIterator {
|
||||
/// A crash report file.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
pub name: String,
|
||||
/// The file name as the raw OS string (Zig `[]const u8`).
|
||||
pub name: std::ffi::OsString,
|
||||
/// Modification time in nanoseconds since the unix epoch.
|
||||
pub mtime: i128,
|
||||
}
|
||||
@@ -121,6 +123,29 @@ mod tests {
|
||||
assert_eq!("a.ghosttycrash", r[0].name);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn default_dir_decodes_non_utf8_xdg_state() {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let v = std::ffi::OsString::from_vec(b"/tmp/state\xff".to_vec());
|
||||
let d = temp_env::with_var("XDG_STATE_HOME", Some(&v), || default_dir().unwrap());
|
||||
assert_eq!(
|
||||
d.path.as_os_str().as_bytes(),
|
||||
b"/tmp/state\xff/ghostty/crash"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn non_utf8_report_name_is_kept() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let t = tempfile::tempdir().unwrap();
|
||||
let name = std::ffi::OsStr::from_bytes(b"a\xff.ghosttycrash");
|
||||
fs::write(t.path().join(name), "x").unwrap();
|
||||
let r = list_reports(t.path()).unwrap();
|
||||
assert_eq!(r[0].name, name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_dir_uses_xdg_state() {
|
||||
let d = default_dir().unwrap();
|
||||
|
||||
@@ -15,8 +15,51 @@ pub(crate) const MACH_EXCEPTION_RAISE: i32 = 2405;
|
||||
pub(crate) const EXC_BAD_ACCESS: u32 = 1;
|
||||
pub(crate) const EXC_BAD_INSTRUCTION: u32 = 2;
|
||||
pub(crate) const EXC_ARITHMETIC: u32 = 3;
|
||||
pub(crate) const EXC_SOFTWARE: u32 = 5;
|
||||
pub(crate) const EXC_BREAKPOINT: u32 = 6;
|
||||
|
||||
/// `EXC_SOFT_SIGNAL`: the code of an `EXC_SOFTWARE` exception that stands for
|
||||
/// a BSD signal (the subcode is the signal number). This is how an
|
||||
/// `abort()` (SIGABRT) is described, as Apple's crash reporter does.
|
||||
pub(crate) const EXC_SOFT_SIGNAL: i64 = 0x10003;
|
||||
|
||||
/// Message id of the "write a dump for this signal" request the signal
|
||||
/// handler sends to the exception handler thread (not a Mach exception id).
|
||||
pub(crate) const ABORT_MSG_ID: i32 = 0x6768_6f73;
|
||||
|
||||
impl MachException {
|
||||
/// The exception describing a fatal BSD signal that never went through
|
||||
/// the Mach exception path (`abort()` raises SIGABRT directly).
|
||||
pub(crate) fn from_signal(signo: i32) -> MachException {
|
||||
MachException {
|
||||
kind: EXC_SOFTWARE,
|
||||
code: EXC_SOFT_SIGNAL,
|
||||
subcode: i64::from(signo),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wire layout of the abort request after the 24 byte Mach header:
|
||||
/// `thread: u32`, `signo: i32` (native endian).
|
||||
pub(crate) const ABORT_BODY_LEN: usize = 8;
|
||||
|
||||
/// Encode the body of an abort request.
|
||||
pub(crate) fn abort_body(thread: u32, signo: i32) -> [u8; ABORT_BODY_LEN] {
|
||||
let mut b = [0u8; ABORT_BODY_LEN];
|
||||
b[..4].copy_from_slice(&thread.to_ne_bytes());
|
||||
b[4..].copy_from_slice(&signo.to_ne_bytes());
|
||||
b
|
||||
}
|
||||
|
||||
/// Decode an abort request message (header included): the faulting thread
|
||||
/// and the signal. `None` for any other message.
|
||||
pub(crate) fn parse_abort(msg: &[u8]) -> Option<(u32, i32)> {
|
||||
if u32_at(msg, 20)? as i32 != ABORT_MSG_ID {
|
||||
return None;
|
||||
}
|
||||
Some((u32_at(msg, HEADER)?, u32_at(msg, HEADER + 4)? as i32))
|
||||
}
|
||||
|
||||
/// A decoded Mach exception.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct MachException {
|
||||
@@ -61,6 +104,7 @@ impl MachException {
|
||||
EXC_BAD_INSTRUCTION => (4, 1), // SIGILL, ILL_ILLOPC
|
||||
EXC_ARITHMETIC => (8, 0), // SIGFPE
|
||||
EXC_BREAKPOINT => (5, 1), // SIGTRAP, TRAP_BRKPT
|
||||
EXC_SOFTWARE if self.code == EXC_SOFT_SIGNAL => (self.subcode as i32, 0),
|
||||
_ => (0, 0),
|
||||
}
|
||||
}
|
||||
@@ -248,6 +292,28 @@ mod tests {
|
||||
assert_eq!(e(EXC_BREAKPOINT, 1).signal().0, 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_request_round_trips() {
|
||||
let mut msg = vec![0u8; 20];
|
||||
msg.extend_from_slice(&ABORT_MSG_ID.to_ne_bytes());
|
||||
msg.extend_from_slice(&abort_body(77, 6));
|
||||
assert_eq!(parse_abort(&msg), Some((77, 6)));
|
||||
// A Mach exception message or a truncated one is not an abort request.
|
||||
assert_eq!(parse_abort(&raise(EXC_BAD_ACCESS, &[1, 2])), None);
|
||||
assert_eq!(parse_abort(&msg[..26]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sigabrt_is_described_as_a_soft_signal() {
|
||||
let e = MachException::from_signal(6);
|
||||
assert_eq!(e.name(), "EXC_SOFTWARE");
|
||||
assert_eq!(e.signal(), (6, 0));
|
||||
assert_eq!(e.fault_addr(), 0);
|
||||
let mut s = String::new();
|
||||
e.describe(&mut s);
|
||||
assert_eq!(s, "EXC_SOFTWARE (code=0x10003, subcode=0x6)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_names() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -28,6 +28,7 @@ mod signal;
|
||||
#[cfg(windows)]
|
||||
#[path = "signal_windows.rs"]
|
||||
mod signal;
|
||||
mod suspend;
|
||||
|
||||
pub use context::{DimsSnapshot, SurfaceDims, ThreadGuard, ThreadType, current_dims};
|
||||
pub use dir::{Dir, Report, ReportIterator, default_dir};
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
//! sends a `mach_exception_raise` message; the handler thread (which runs on
|
||||
//! its own healthy stack, so stack overflows work) then:
|
||||
//!
|
||||
//! 1. suspends all other threads,
|
||||
//! 1. suspends all other threads (and resumes *all* of them, the faulting one
|
||||
//! included, before replying; otherwise the process hangs instead of
|
||||
//! dying),
|
||||
//! 2. reads the crashed thread's registers with `thread_get_state` for the
|
||||
//! event's stack trace,
|
||||
//! 3. writes a minidump with `minidump-writer` for the whole task and attaches
|
||||
@@ -20,9 +22,11 @@
|
||||
//! the process dies as usual (Apple's crash reporter included). The signal
|
||||
//! path (`signal.rs`) sees `BUSY` set and writes nothing more.
|
||||
//!
|
||||
//! Signals that are not Mach exceptions (`SIGABRT` from `abort()`) keep going
|
||||
//! through the signal handler. A watchdog `alarm(2)` kills the process if the
|
||||
//! dump hangs (e.g. a suspended thread held the allocator lock).
|
||||
//! `SIGABRT` from `abort()` is not a Mach exception. Like Breakpad, the signal
|
||||
//! handler asks this handler thread for the dump (`request_abort_dump`: a
|
||||
//! message to the exception port, then it waits on a reply port), so
|
||||
//! `abort()` gets a minidump too. A watchdog `alarm(2)` kills the process if
|
||||
//! the dump hangs (e.g. a suspended thread held the allocator lock).
|
||||
//!
|
||||
//! Unlike Breakpad this does not forward the exception to previously
|
||||
//! registered handlers; it restores them, which only matters for debuggers
|
||||
@@ -53,12 +57,14 @@ use mach2::{
|
||||
mach_types::{thread_act_t, thread_t},
|
||||
message::{
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_MSG_TYPE_COPY_SEND,
|
||||
MACH_MSG_TYPE_MAKE_SEND,
|
||||
MACH_MSGH_BITS,
|
||||
MACH_MSGH_BITS_REMOTE_MASK,
|
||||
MACH_RCV_LARGE,
|
||||
MACH_RCV_MSG,
|
||||
MACH_RCV_PORT_DIED,
|
||||
MACH_RCV_TIMEOUT,
|
||||
MACH_SEND_MSG,
|
||||
mach_msg,
|
||||
mach_msg_header_t,
|
||||
@@ -77,6 +83,7 @@ use crate::{
|
||||
native::Native,
|
||||
sentry,
|
||||
signal::BUSY,
|
||||
suspend::{SuspendGuard, Threads},
|
||||
};
|
||||
|
||||
/// Maximum number of registered exception handlers (`EXC_TYPES_COUNT`).
|
||||
@@ -99,6 +106,11 @@ struct Previous {
|
||||
|
||||
static PREVIOUS: OnceLock<Previous> = OnceLock::new();
|
||||
static PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL);
|
||||
/// Receive port the signal handler waits on while the handler thread dumps.
|
||||
static REPLY_PORT: AtomicU32 = AtomicU32::new(MACH_PORT_NULL);
|
||||
|
||||
/// How long the signal handler waits for the dump (a bit above the watchdog).
|
||||
const ABORT_WAIT_MS: u32 = (WATCHDOG_SECS + 10) * 1000;
|
||||
|
||||
#[cfg(target_arch = "x86_64")]
|
||||
type ThreadState = mach2::structs::x86_thread_state64_t;
|
||||
@@ -136,6 +148,21 @@ pub(crate) fn install() -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reply port for the SIGABRT path (best effort: without it SIGABRT
|
||||
// keeps the event-only report).
|
||||
let mut reply: mach_port_t = MACH_PORT_NULL;
|
||||
if mach2::mach_port::mach_port_allocate(task, MACH_PORT_RIGHT_RECEIVE, &raw mut reply)
|
||||
== KERN_SUCCESS
|
||||
{
|
||||
if mach2::mach_port::mach_port_insert_right(task, reply, reply, MACH_MSG_TYPE_MAKE_SEND)
|
||||
== KERN_SUCCESS
|
||||
{
|
||||
REPLY_PORT.store(reply, Ordering::Release);
|
||||
} else {
|
||||
mach2::mach_port::mach_port_destroy(task, reply);
|
||||
}
|
||||
}
|
||||
|
||||
let mut prev = Previous {
|
||||
count: MAX_PORTS as u32,
|
||||
masks: [0; MAX_PORTS],
|
||||
@@ -237,6 +264,9 @@ fn handler_thread(port: mach_port_t) {
|
||||
continue;
|
||||
}
|
||||
let Some(req) = exception::parse_raise(&buf.0) else {
|
||||
if let Some((thread, signo)) = exception::parse_abort(&buf.0) {
|
||||
handle_abort(thread, signo, handler);
|
||||
}
|
||||
continue;
|
||||
};
|
||||
handle_exception(&req, handler);
|
||||
@@ -273,22 +303,39 @@ fn handler_thread(port: mach_port_t) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Suspend every thread of the task except `keep` (the handler thread).
|
||||
fn suspend_other_threads(keep: thread_act_t) {
|
||||
// SAFETY: task_threads returns a vm-allocated array we only read.
|
||||
/// The Mach threads of this task (for [`SuspendGuard`]).
|
||||
struct MachThreads;
|
||||
|
||||
impl Threads for MachThreads {
|
||||
type Id = thread_act_t;
|
||||
|
||||
fn list(&self) -> Vec<thread_act_t> {
|
||||
// SAFETY: task_threads returns a vm-allocated array we copy and free.
|
||||
unsafe {
|
||||
let task = mach2::traps::mach_task_self();
|
||||
let mut list: mach2::mach_types::thread_act_array_t = std::ptr::null_mut();
|
||||
let mut n: u32 = 0;
|
||||
if mach2::task::task_threads(task, &raw mut list, &raw mut n) != KERN_SUCCESS {
|
||||
return;
|
||||
return Vec::new();
|
||||
}
|
||||
for i in 0..n as usize {
|
||||
let t = *list.add(i);
|
||||
if t != keep {
|
||||
mach2::thread_act::thread_suspend(t);
|
||||
let threads = std::slice::from_raw_parts(list, n as usize).to_vec();
|
||||
mach2::vm::mach_vm_deallocate(
|
||||
task,
|
||||
list as mach2::vm_types::mach_vm_address_t,
|
||||
u64::from(n) * size_of::<thread_act_t>() as u64,
|
||||
);
|
||||
threads
|
||||
}
|
||||
}
|
||||
|
||||
fn suspend(&self, t: thread_act_t) {
|
||||
// SAFETY: plain Mach call on a thread port of this task.
|
||||
unsafe { mach2::thread_act::thread_suspend(t) };
|
||||
}
|
||||
|
||||
fn resume(&self, t: thread_act_t) {
|
||||
// SAFETY: as above.
|
||||
unsafe { mach2::thread_act::thread_resume(t) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,17 +346,141 @@ fn handle_exception(req: &exception::RaiseRequest, handler: thread_t) {
|
||||
// SAFETY: alarm has no preconditions. SIGALRM's default action ends
|
||||
// the process if the dump below hangs.
|
||||
unsafe { libc::alarm(WATCHDOG_SECS) };
|
||||
suspend_other_threads(handler);
|
||||
write_report(st, req, handler);
|
||||
{
|
||||
// Every thread (the faulting one included) is resumed when the
|
||||
// guard drops, before the exception is handed back to the kernel.
|
||||
let _threads = SuspendGuard::suspend_all_except(&MachThreads, handler);
|
||||
write_report(st, req.exception, req.thread, req.task, handler);
|
||||
}
|
||||
// SAFETY: as above.
|
||||
unsafe { libc::alarm(0) };
|
||||
}
|
||||
restore_ports();
|
||||
}
|
||||
|
||||
fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thread_t) {
|
||||
let ex: MachException = req.exception;
|
||||
/// A `SIGABRT` dump request from the signal handler: dump the whole task with
|
||||
/// `thread` as the crashed thread, then wake the waiting signal handler.
|
||||
fn handle_abort(thread: u32, signo: i32, handler: thread_t) {
|
||||
if let Some(st) = sentry::state()
|
||||
&& !BUSY.swap(true, Ordering::Acquire)
|
||||
{
|
||||
// SAFETY: as in `handle_exception`.
|
||||
unsafe { libc::alarm(WATCHDOG_SECS) };
|
||||
{
|
||||
let _threads = SuspendGuard::suspend_all_except(&MachThreads, handler);
|
||||
// SAFETY: plain Mach trap.
|
||||
let task = unsafe { mach2::traps::mach_task_self() };
|
||||
write_report(st, MachException::from_signal(signo), thread, task, handler);
|
||||
}
|
||||
// SAFETY: as above.
|
||||
unsafe { libc::alarm(0) };
|
||||
}
|
||||
notify_signal_handler();
|
||||
}
|
||||
|
||||
/// Wake the signal handler waiting in [`request_abort_dump`].
|
||||
fn notify_signal_handler() {
|
||||
let reply = REPLY_PORT.load(Ordering::Acquire);
|
||||
if reply == MACH_PORT_NULL {
|
||||
return;
|
||||
}
|
||||
let mut head = mach_msg_header_t {
|
||||
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0),
|
||||
msgh_size: size_of::<mach_msg_header_t>() as u32,
|
||||
msgh_remote_port: reply,
|
||||
msgh_local_port: MACH_PORT_NULL,
|
||||
msgh_voucher_port: 0,
|
||||
msgh_id: exception::ABORT_MSG_ID + 1,
|
||||
};
|
||||
// SAFETY: `head` is a complete message with no body.
|
||||
unsafe {
|
||||
mach_msg(
|
||||
&raw mut head,
|
||||
MACH_SEND_MSG,
|
||||
size_of::<mach_msg_header_t>() as u32,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_PORT_NULL,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[repr(C, packed(4))]
|
||||
struct Request {
|
||||
head: mach_msg_header_t,
|
||||
body: [u8; exception::ABORT_BODY_LEN],
|
||||
}
|
||||
|
||||
/// Called from the `SIGABRT` signal handler: ask the exception handler thread
|
||||
/// to write the report with a minidump and wait for it. Returns false if the
|
||||
/// handler thread is unavailable (the caller then writes the event-only
|
||||
/// report itself).
|
||||
pub(crate) fn request_abort_dump(signo: libc::c_int) -> bool {
|
||||
let port = PORT.load(Ordering::Acquire);
|
||||
let reply = REPLY_PORT.load(Ordering::Acquire);
|
||||
if port == MACH_PORT_NULL || reply == MACH_PORT_NULL || sentry::state().is_none() {
|
||||
return false;
|
||||
}
|
||||
if BUSY.load(Ordering::Acquire) {
|
||||
// Another crash is already being written.
|
||||
return false;
|
||||
}
|
||||
|
||||
// SAFETY: mach_thread_self has no preconditions.
|
||||
let thread = unsafe { mach2::mach_init::mach_thread_self() };
|
||||
let mut req = Request {
|
||||
head: mach_msg_header_t {
|
||||
msgh_bits: MACH_MSGH_BITS(MACH_MSG_TYPE_COPY_SEND, 0),
|
||||
msgh_size: size_of::<Request>() as u32,
|
||||
msgh_remote_port: port,
|
||||
msgh_local_port: MACH_PORT_NULL,
|
||||
msgh_voucher_port: 0,
|
||||
msgh_id: exception::ABORT_MSG_ID,
|
||||
},
|
||||
body: exception::abort_body(thread, signo),
|
||||
};
|
||||
// SAFETY: `req` is a fully initialized message of the given size.
|
||||
let sent = unsafe {
|
||||
mach_msg(
|
||||
(&raw mut req).cast(),
|
||||
MACH_SEND_MSG,
|
||||
size_of::<Request>() as u32,
|
||||
0,
|
||||
MACH_PORT_NULL,
|
||||
MACH_MSG_TIMEOUT_NONE,
|
||||
MACH_PORT_NULL,
|
||||
)
|
||||
};
|
||||
if sent != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block until the handler thread has finished (it resumes this thread's
|
||||
// suspension before replying). Bounded, in case the dump hangs.
|
||||
let mut buf = Buffer([0; 1024]);
|
||||
// SAFETY: `buf` is a writable 1024 byte aligned buffer.
|
||||
unsafe {
|
||||
mach_msg(
|
||||
buf.0.as_mut_ptr().cast(),
|
||||
MACH_RCV_MSG | MACH_RCV_TIMEOUT,
|
||||
0,
|
||||
buf.0.len() as u32,
|
||||
reply,
|
||||
ABORT_WAIT_MS,
|
||||
MACH_PORT_NULL,
|
||||
);
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn write_report(
|
||||
st: &sentry::State,
|
||||
ex: MachException,
|
||||
thread: thread_t,
|
||||
task: mach_port_t,
|
||||
handler: thread_t,
|
||||
) {
|
||||
// Registers and frame-pointer walk of the faulting thread.
|
||||
let mut native = Box::new(Native::new());
|
||||
(native.signo, native.code) = ex.signal();
|
||||
@@ -319,7 +490,7 @@ fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thre
|
||||
// SAFETY: `state` is a writable thread state of the requested flavor.
|
||||
let kr = unsafe {
|
||||
mach2::thread_act::thread_get_state(
|
||||
req.thread,
|
||||
thread,
|
||||
state_flavor(),
|
||||
(&raw mut state).cast(),
|
||||
&raw mut count,
|
||||
@@ -331,8 +502,8 @@ fn write_report(st: &sentry::State, req: &exception::RaiseRequest, handler: thre
|
||||
}
|
||||
|
||||
let cc = CrashContext {
|
||||
task: req.task,
|
||||
thread: req.thread,
|
||||
task,
|
||||
thread,
|
||||
handler_thread: handler,
|
||||
exception: Some(ExceptionInfo {
|
||||
kind: ex.kind,
|
||||
|
||||
@@ -138,7 +138,8 @@ pub fn install(opts: &Options) -> Result<(), InitError> {
|
||||
}
|
||||
|
||||
let state = State {
|
||||
dir: dir.as_os_str().as_encoded_bytes().to_vec(),
|
||||
// Bytes on Unix, WTF-8 on Windows (lossless both ways).
|
||||
dir: ghostty_os::osbytes::os_bytes(dir.as_os_str()),
|
||||
prefix,
|
||||
};
|
||||
if STATE.set(state).is_err() {
|
||||
@@ -415,17 +416,8 @@ pub(crate) fn write_report_ext(
|
||||
data.extend_from_slice(&enc.payload);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
let dir = {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
std::path::Path::new(std::ffi::OsStr::from_bytes(&st.dir))
|
||||
};
|
||||
// Windows paths are not arbitrary bytes; the dir was UTF-8 in practice.
|
||||
#[cfg(not(unix))]
|
||||
let dir_buf = PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned());
|
||||
#[cfg(not(unix))]
|
||||
let dir = dir_buf.as_path();
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let dir = PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone()));
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
let path = dir.join(report_file_name(uuid));
|
||||
std::fs::write(&path, data)?;
|
||||
Ok(path)
|
||||
|
||||
@@ -252,7 +252,14 @@ fn write_signal_report(name: &str, desc: &str, info: *mut libc::siginfo_t, uc: *
|
||||
}
|
||||
|
||||
extern "C" fn handler(sig: libc::c_int, info: *mut libc::siginfo_t, ctx: *mut libc::c_void) {
|
||||
if let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) {
|
||||
// macOS: `abort()` raises SIGABRT directly (no Mach exception), so ask the
|
||||
// exception handler thread for the report with a minidump, like
|
||||
// Breakpad. The event-only report below is the fallback.
|
||||
#[cfg(target_os = "macos")]
|
||||
let dumped = sig == libc::SIGABRT && crate::mach_handler::request_abort_dump(sig);
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
let dumped = false;
|
||||
if !dumped && let Some((_, name, desc)) = SIGNALS.iter().find(|(s, ..)| *s == sig) {
|
||||
write_signal_report(name, desc, info, ctx);
|
||||
}
|
||||
// Restore the default action and re-raise so the process dies with the
|
||||
|
||||
@@ -124,7 +124,7 @@ fn write_report(st: &sentry::State, info: *mut EXCEPTION_POINTERS, tid: u32) {
|
||||
.map_or(0, |r| r.ExceptionCode) as u32;
|
||||
let (kind, desc) = exception::windows_exception(code);
|
||||
|
||||
let dir = std::path::PathBuf::from(String::from_utf8_lossy(&st.dir).into_owned());
|
||||
let dir = std::path::PathBuf::from(ghostty_os::osbytes::os_from_bytes(st.dir.clone()));
|
||||
let dump = (|| -> Result<Vec<u8>, String> {
|
||||
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
let tmp = dir.join(format!("minidump-{}.tmp", std::process::id()));
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Suspending every other thread of the process while a crash report is
|
||||
//! written, and *always* resuming them afterwards.
|
||||
//!
|
||||
//! The macOS exception handler thread suspends all other threads so the dump
|
||||
//! is consistent. Breakpad resumes them before handing the exception back to
|
||||
//! the kernel; without that, the faulting thread (which the kernel parked
|
||||
//! until the handler replies) stays suspended forever and the process hangs
|
||||
//! instead of dying through BSD signal delivery. The platform specific
|
||||
//! thread operations are behind [`Threads`] so the bookkeeping is testable
|
||||
//! everywhere.
|
||||
|
||||
// Only the macOS handler uses this outside of tests.
|
||||
#![cfg_attr(not(target_os = "macos"), allow(dead_code))]
|
||||
|
||||
/// The thread operations needed by [`SuspendGuard`].
|
||||
pub(crate) trait Threads {
|
||||
type Id: Copy + PartialEq;
|
||||
|
||||
/// All threads of the process.
|
||||
fn list(&self) -> Vec<Self::Id>;
|
||||
fn suspend(&self, id: Self::Id);
|
||||
fn resume(&self, id: Self::Id);
|
||||
}
|
||||
|
||||
/// Resumes the threads it suspended when dropped (also on panic).
|
||||
pub(crate) struct SuspendGuard<'a, T: Threads> {
|
||||
ops: &'a T,
|
||||
suspended: Vec<T::Id>,
|
||||
}
|
||||
|
||||
impl<'a, T: Threads> SuspendGuard<'a, T> {
|
||||
/// Suspend every thread except `keep` (the calling handler thread).
|
||||
pub(crate) fn suspend_all_except(ops: &'a T, keep: T::Id) -> Self {
|
||||
let suspended: Vec<T::Id> = ops.list().into_iter().filter(|t| *t != keep).collect();
|
||||
for t in &suspended {
|
||||
ops.suspend(*t);
|
||||
}
|
||||
SuspendGuard { ops, suspended }
|
||||
}
|
||||
|
||||
/// The number of threads this guard suspended.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn len(&self) -> usize {
|
||||
self.suspended.len()
|
||||
}
|
||||
}
|
||||
|
||||
impl<T: Threads> Drop for SuspendGuard<'_, T> {
|
||||
fn drop(&mut self) {
|
||||
for t in self.suspended.drain(..) {
|
||||
self.ops.resume(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::cell::RefCell;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Mock {
|
||||
// (id, suspend count)
|
||||
threads: RefCell<Vec<(u32, i32)>>,
|
||||
}
|
||||
|
||||
impl Threads for Mock {
|
||||
type Id = u32;
|
||||
fn list(&self) -> Vec<u32> {
|
||||
self.threads.borrow().iter().map(|t| t.0).collect()
|
||||
}
|
||||
fn suspend(&self, id: u32) {
|
||||
for t in self.threads.borrow_mut().iter_mut().filter(|t| t.0 == id) {
|
||||
t.1 += 1;
|
||||
}
|
||||
}
|
||||
fn resume(&self, id: u32) {
|
||||
for t in self.threads.borrow_mut().iter_mut().filter(|t| t.0 == id) {
|
||||
t.1 -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suspends_all_but_handler_and_resumes_all_on_drop() {
|
||||
// Thread 1 is the faulting thread, 9 the handler.
|
||||
let m = Mock {
|
||||
threads: RefCell::new(vec![(1, 0), (2, 0), (9, 0)]),
|
||||
};
|
||||
{
|
||||
let g = SuspendGuard::suspend_all_except(&m, 9);
|
||||
assert_eq!(g.len(), 2);
|
||||
assert_eq!(*m.threads.borrow(), [(1, 1), (2, 1), (9, 0)]);
|
||||
}
|
||||
// Every thread, including the faulting one, runs again.
|
||||
assert_eq!(*m.threads.borrow(), [(1, 0), (2, 0), (9, 0)]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resumes_even_if_the_dump_panics() {
|
||||
let m = Mock {
|
||||
threads: RefCell::new(vec![(1, 0), (9, 0)]),
|
||||
};
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
let _g = SuspendGuard::suspend_all_except(&m, 9);
|
||||
panic!("dump failed");
|
||||
}));
|
||||
assert!(r.is_err());
|
||||
assert_eq!(*m.threads.borrow(), [(1, 0), (9, 0)]);
|
||||
}
|
||||
}
|
||||
@@ -69,10 +69,11 @@ impl DeferredFace {
|
||||
.expect("deferred face has no fontconfig data");
|
||||
|
||||
// Filename and index for our face so we can load it
|
||||
let filename = fc.pattern.get_string(fc::Property::File, 0)?;
|
||||
// File names are raw OS bytes, not text.
|
||||
let filename = fc.pattern.get_os_string(fc::Property::File, 0)?;
|
||||
let face_index = fc.pattern.get_integer(fc::Property::Index, 0)?;
|
||||
|
||||
let mut face = Face::init_file(lib, &filename, face_index, opts)?;
|
||||
let mut face = Face::init_file(lib, std::path::Path::new(&filename), face_index, opts)?;
|
||||
face.set_variations(&fc.variations, opts)?;
|
||||
Ok(face)
|
||||
}
|
||||
|
||||
@@ -131,11 +131,13 @@ impl Face {
|
||||
/// Initialize a new font face from a file.
|
||||
pub fn init_file(
|
||||
lib: &Library,
|
||||
path: &str,
|
||||
path: impl AsRef<std::path::Path>,
|
||||
index: i32,
|
||||
opts: Options,
|
||||
) -> Result<Face, FaceError> {
|
||||
let cpath = CString::new(path).map_err(|_| FaceError::InvalidPath)?;
|
||||
// File names are raw OS bytes (they need not be UTF-8).
|
||||
let cpath = CString::new(path.as_ref().as_os_str().as_encoded_bytes())
|
||||
.map_err(|_| FaceError::InvalidPath)?;
|
||||
let face = {
|
||||
let _g = lib.lock();
|
||||
ft::FtFace::new_file(&lib.inner.lib, &cpath, index).map_err(FaceError::Freetype)?
|
||||
|
||||
@@ -186,6 +186,11 @@ impl Pattern {
|
||||
}
|
||||
|
||||
pub fn add_string(&mut self, prop: Property, v: &str) -> bool {
|
||||
self.add_string_bytes(prop, v.as_bytes())
|
||||
}
|
||||
|
||||
/// [`Pattern::add_string`] for arbitrary bytes (file names).
|
||||
pub fn add_string_bytes(&mut self, prop: Property, v: &[u8]) -> bool {
|
||||
let Ok(c) = CString::new(v) else {
|
||||
return false;
|
||||
};
|
||||
@@ -205,8 +210,31 @@ impl Pattern {
|
||||
unsafe { sys::FcPatternAddCharSet(self.as_ptr(), prop.cstr().as_ptr(), cs.0.as_ptr()) != 0 }
|
||||
}
|
||||
|
||||
/// Get the string value at index `id`.
|
||||
/// Get the string value at index `id` as the raw OS string. Use this for
|
||||
/// file names: they are arbitrary bytes, not necessarily UTF-8.
|
||||
pub fn get_os_string(&self, prop: Property, id: u32) -> Result<std::ffi::OsString, Error> {
|
||||
let bytes = self.get_string_bytes(prop, id)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
Ok(std::ffi::OsString::from_vec(bytes))
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
Ok(std::ffi::OsString::from(
|
||||
String::from_utf8_lossy(&bytes).into_owned(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the string value at index `id` as text. Only for properties that
|
||||
/// fontconfig defines as UTF-8 (family/style/full names); invalid bytes
|
||||
/// are replaced. File names use [`Pattern::get_os_string`].
|
||||
pub fn get_string(&self, prop: Property, id: u32) -> Result<String, Error> {
|
||||
Ok(String::from_utf8_lossy(&self.get_string_bytes(prop, id)?).into_owned())
|
||||
}
|
||||
|
||||
fn get_string_bytes(&self, prop: Property, id: u32) -> Result<Vec<u8>, Error> {
|
||||
let mut out: *mut sys::FcChar8 = ptr::null_mut();
|
||||
// SAFETY: valid pattern; out is a valid out pointer.
|
||||
let r = unsafe {
|
||||
@@ -223,7 +251,7 @@ impl Pattern {
|
||||
}
|
||||
// SAFETY: fontconfig returned a NUL-terminated string owned by the pattern.
|
||||
let s = unsafe { CStr::from_ptr(out.cast()) };
|
||||
Ok(s.to_string_lossy().into_owned())
|
||||
Ok(s.to_bytes().to_vec())
|
||||
}
|
||||
|
||||
pub fn get_integer(&self, prop: Property, id: u32) -> Result<i32, Error> {
|
||||
@@ -420,6 +448,22 @@ mod tests {
|
||||
assert!(!emoji.has_lang("en-US"));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn file_name_keeps_non_utf8_bytes() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let mut p = Pattern::create();
|
||||
assert!(p.add_string_bytes(Property::File, b"/fonts/a\xffb.ttf"));
|
||||
let f = p.get_os_string(Property::File, 0).unwrap();
|
||||
assert_eq!(f.as_bytes(), b"/fonts/a\xffb.ttf");
|
||||
// The text accessor is for UTF-8 names only and is lossy.
|
||||
assert!(
|
||||
p.get_string(Property::File, 0)
|
||||
.unwrap()
|
||||
.contains('\u{fffd}')
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn charset_add_has() {
|
||||
let mut cs = CharSet::create();
|
||||
|
||||
@@ -131,7 +131,7 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
|
||||
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_bytes(&ghostty_os::osbytes::config_str_to_bytes(&p.pwd));
|
||||
v.pwd.pwd = keep.cstr_bytes(&p.pwd.to_bytes());
|
||||
}
|
||||
Action::MouseShape(s) => v.mouse_shape = *s as c_int,
|
||||
Action::MouseVisibility(m) => v.mouse_visibility = *m as c_int,
|
||||
@@ -185,7 +185,7 @@ pub fn action_to_c(action: &Action, keep: &mut Keep) -> ghostty_action_s {
|
||||
v.open_url.kind = u.kind as c_int;
|
||||
// The URL may be the escaped form of a non-UTF-8 path; hand the
|
||||
// embedder the real bytes.
|
||||
let url = ghostty_os::osbytes::config_str_to_bytes(&u.url);
|
||||
let url = u.url.to_bytes();
|
||||
v.open_url.url = keep.cstr_bytes(&url);
|
||||
v.open_url.len = url.len();
|
||||
}
|
||||
|
||||
@@ -7,27 +7,27 @@ use ghostty_bench::{args::Args, benches};
|
||||
|
||||
/// Split the argument string like `std.process.ArgIteratorGeneral(.{})`:
|
||||
/// whitespace separated, with double quotes grouping and backslash
|
||||
/// escapes inside quotes.
|
||||
fn split_args(s: &str) -> Vec<String> {
|
||||
/// escapes inside quotes. Works on raw bytes: file names need not be UTF-8.
|
||||
fn split_args(s: &[u8]) -> Vec<std::ffi::OsString> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut cur: Vec<u8> = Vec::new();
|
||||
let mut have = false;
|
||||
let mut in_quote = false;
|
||||
let mut chars = s.chars().peekable();
|
||||
while let Some(c) = chars.next() {
|
||||
let mut bytes = s.iter().copied();
|
||||
while let Some(c) = bytes.next() {
|
||||
match c {
|
||||
'"' => {
|
||||
b'"' => {
|
||||
in_quote = !in_quote;
|
||||
have = true;
|
||||
}
|
||||
'\\' if in_quote => {
|
||||
if let Some(n) = chars.next() {
|
||||
b'\\' if in_quote => {
|
||||
if let Some(n) = bytes.next() {
|
||||
cur.push(n);
|
||||
}
|
||||
}
|
||||
c if c.is_whitespace() && !in_quote => {
|
||||
c if c.is_ascii_whitespace() && !in_quote => {
|
||||
if have {
|
||||
out.push(std::mem::take(&mut cur));
|
||||
out.push(ghostty_os::osbytes::os_from_bytes(std::mem::take(&mut cur)));
|
||||
have = false;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@ fn split_args(s: &str) -> Vec<String> {
|
||||
}
|
||||
}
|
||||
if have {
|
||||
out.push(cur);
|
||||
out.push(ghostty_os::osbytes::os_from_bytes(cur));
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -62,7 +62,7 @@ pub unsafe extern "C" fn ghostty_benchmark_cli(
|
||||
log::warn!("unknown action={action}");
|
||||
return false;
|
||||
}
|
||||
let argv = split_args(&args.to_string_lossy());
|
||||
let argv = split_args(args.to_bytes());
|
||||
let parsed = match Args::parse(argv) {
|
||||
Ok(a) => a,
|
||||
Err(err) => {
|
||||
@@ -85,9 +85,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn split_args_basic() {
|
||||
assert_eq!(split_args("a b c"), ["a", "b", "c"]);
|
||||
assert_eq!(split_args("--x=\"a b\" c"), ["--x=a b", "c"]);
|
||||
assert_eq!(split_args(" ").len(), 0);
|
||||
assert_eq!(split_args(b"a b c"), ["a", "b", "c"]);
|
||||
assert_eq!(split_args(b"--x=\"a b\" c"), ["--x=a b", "c"]);
|
||||
assert_eq!(split_args(b" ").len(), 0);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn split_args_keeps_non_utf8_bytes() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let v = split_args(b"--data=/tmp/a\xffb x");
|
||||
assert_eq!(v[0].as_bytes(), b"--data=/tmp/a\xffb");
|
||||
assert_eq!(v[1], "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -16,7 +16,7 @@ use ghostty_config::{
|
||||
use ghostty_input::binding::{Action as BindingAction, TriggerC, TriggerCTag};
|
||||
|
||||
use crate::{
|
||||
main_c::{EMPTY_STRING, string_from_owned},
|
||||
main_c::{EMPTY_STRING, string_from_bytes},
|
||||
types::{
|
||||
ghostty_command_s,
|
||||
ghostty_config_color_list_s,
|
||||
@@ -103,6 +103,15 @@ fn cstring(s: &str) -> CString {
|
||||
CString::new(s.replace('\0', "")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A C string for a config *value*: config strings carry non-UTF-8 bytes in
|
||||
/// the reversible [`ghostty_os::osbytes`] encoding, so the embedder gets the
|
||||
/// decoded bytes (Zig hands out the original byte slice).
|
||||
fn cstring_value(s: &str) -> CString {
|
||||
let mut bytes = ghostty_os::osbytes::config_str_to_bytes(s);
|
||||
bytes.retain(|b| *b != 0);
|
||||
CString::new(bytes).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Intern a string for the lifetime of the process (enum tag names).
|
||||
fn intern(s: &str) -> *const c_char {
|
||||
static POOL: OnceLock<Mutex<HashSet<Box<CStr>>>> = OnceLock::new();
|
||||
@@ -122,8 +131,8 @@ fn intern(s: &str) -> *const c_char {
|
||||
fn build_backing(value: &CValue) -> Backing {
|
||||
let mut b = Backing::default();
|
||||
match value {
|
||||
CValue::String(s) => b.strings.push(cstring(s)),
|
||||
CValue::Path { path, .. } => b.strings.push(cstring(path)),
|
||||
CValue::String(s) => b.strings.push(cstring_value(s)),
|
||||
CValue::Path { path, .. } => b.strings.push(cstring_value(path)),
|
||||
CValue::ColorList(list) => {
|
||||
b.colors = list
|
||||
.iter()
|
||||
@@ -136,10 +145,10 @@ fn build_backing(value: &CValue) -> Backing {
|
||||
}
|
||||
CValue::Commands(cmds) => {
|
||||
for c in cmds {
|
||||
b.strings.push(cstring(&c.action_key));
|
||||
b.strings.push(cstring(&c.action));
|
||||
b.strings.push(cstring(&c.title));
|
||||
b.strings.push(cstring(&c.description));
|
||||
b.strings.push(cstring_value(&c.action_key));
|
||||
b.strings.push(cstring_value(&c.action));
|
||||
b.strings.push(cstring_value(&c.title));
|
||||
b.strings.push(cstring_value(&c.description));
|
||||
}
|
||||
// The CStrings' heap buffers don't move when the Vec grows.
|
||||
b.commands = b
|
||||
@@ -252,9 +261,12 @@ pub unsafe extern "C" fn ghostty_config_load_file(ptr: ghostty_config_t, path: *
|
||||
// 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());
|
||||
ghostty_os::osbytes::EncodedOs::from_bytes(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}");
|
||||
log::error!(
|
||||
"error loading config from file path={} err={err}",
|
||||
path.as_encoded()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -574,7 +586,8 @@ pub unsafe extern "C" fn ghostty_config_get_diagnostic(
|
||||
#[unsafe(no_mangle)]
|
||||
pub extern "C" fn ghostty_config_open_path() -> ghostty_string_s {
|
||||
match ghostty_config::edit::open_path() {
|
||||
Ok(path) => string_from_owned(&path),
|
||||
// The embedder gets the real bytes of the path.
|
||||
Ok(path) => string_from_bytes(&path.to_bytes()),
|
||||
Err(err) => {
|
||||
log::error!("error opening config in editor err={err}");
|
||||
EMPTY_STRING
|
||||
@@ -588,6 +601,23 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn config_values_reach_c_as_decoded_bytes() {
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
let enc = EncodedOs::from_bytes(b"/a\xffb");
|
||||
assert_ne!(enc.as_encoded().as_bytes(), b"/a\xffb");
|
||||
let b = build_backing(&CValue::Path {
|
||||
path: enc.as_encoded().to_owned(),
|
||||
optional: false,
|
||||
});
|
||||
assert_eq!(b.strings[0].as_bytes(), b"/a\xffb");
|
||||
let b = build_backing(&CValue::String(enc.into_encoded()));
|
||||
assert_eq!(b.strings[0].as_bytes(), b"/a\xffb");
|
||||
// Plain text (even with the escape char) is unchanged.
|
||||
let b = build_backing(&CValue::String("h\u{e9}llo".to_owned()));
|
||||
assert_eq!(b.strings[0].to_str().unwrap(), "h\u{e9}llo");
|
||||
}
|
||||
|
||||
fn get<T: Copy>(cfg: ghostty_config_t, key: &str, init: T) -> (bool, T) {
|
||||
let mut out = init;
|
||||
// SAFETY: `out` is a valid destination of the key's C type.
|
||||
|
||||
@@ -619,7 +619,7 @@ impl RtSurface for SurfaceRt {
|
||||
true
|
||||
}
|
||||
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, String)], confirm: bool) {
|
||||
fn set_clipboard(&self, clipboard: Clipboard, contents: &[(String, Vec<u8>)], confirm: bool) {
|
||||
let Some(cb) = self.app.opts.write_clipboard_cb else {
|
||||
return;
|
||||
};
|
||||
@@ -628,7 +628,8 @@ impl RtSurface for SurfaceRt {
|
||||
.map(|(m, d)| {
|
||||
(
|
||||
CString::new(m.replace('\0', "")).unwrap_or_default(),
|
||||
CString::new(d.replace('\0', "")).unwrap_or_default(),
|
||||
CString::new(d.iter().copied().filter(|b| *b != 0).collect::<Vec<u8>>())
|
||||
.unwrap_or_default(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
@@ -889,7 +890,7 @@ unsafe fn apply_surface_options(config: &mut Config, opts: &ghostty_surface_conf
|
||||
&& !wd.is_empty()
|
||||
{
|
||||
// Raw bytes are kept (reversible escape in the String based config).
|
||||
let wd_str = osbytes::bytes_to_config_string(wd);
|
||||
let wd_str = osbytes::EncodedOs::from_bytes(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) {
|
||||
@@ -2207,7 +2208,7 @@ mod tests {
|
||||
.as_ref()
|
||||
.and_then(|w| w.value())
|
||||
.unwrap();
|
||||
assert_eq!(osbytes::config_str_to_bytes(got_wd), dir);
|
||||
assert_eq!(got_wd.to_bytes(), dir);
|
||||
let Some(Command::Shell(c)) = &config.command else {
|
||||
panic!("no command");
|
||||
};
|
||||
|
||||
@@ -21,9 +21,15 @@ static ARGS: OnceLock<Vec<String>> = OnceLock::new();
|
||||
/// Build the `ghostty_string_s` for an owned string (Zig:
|
||||
/// `String.fromSlice` of an allocated `[:0]const u8`). Free with
|
||||
/// `ghostty_string_free`.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn string_from_owned(s: &str) -> ghostty_string_s {
|
||||
string_from_bytes(s.as_bytes())
|
||||
}
|
||||
|
||||
/// [`string_from_owned`] for raw bytes (paths need not be UTF-8).
|
||||
pub(crate) fn string_from_bytes(s: &[u8]) -> ghostty_string_s {
|
||||
let mut bytes = Vec::with_capacity(s.len() + 1);
|
||||
bytes.extend_from_slice(s.as_bytes());
|
||||
bytes.extend_from_slice(s);
|
||||
bytes.push(0);
|
||||
let boxed = bytes.into_boxed_slice();
|
||||
let len = boxed.len() - 1;
|
||||
|
||||
@@ -259,13 +259,13 @@ pub fn getenv_not_empty_os(key: &str) -> Option<OsString> {
|
||||
/// Gets the value of an environment variable, or None if not found. A
|
||||
/// non-UTF-8 value is still present: its invalid bytes are carried through
|
||||
/// the reversible [`crate::osbytes`] encoding.
|
||||
pub fn getenv(key: &str) -> Option<String> {
|
||||
getenv_os(key).map(|v| crate::osbytes::os_to_config_string(&v))
|
||||
pub fn getenv(key: &str) -> Option<crate::osbytes::EncodedOs> {
|
||||
getenv_os(key).map(|v| crate::osbytes::EncodedOs::from_os(&v))
|
||||
}
|
||||
|
||||
/// Gets the value of an environment variable. Returns None if not found or
|
||||
/// the value is empty.
|
||||
pub fn getenv_not_empty(key: &str) -> Option<String> {
|
||||
pub fn getenv_not_empty(key: &str) -> Option<crate::osbytes::EncodedOs> {
|
||||
getenv(key).filter(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
@@ -324,7 +324,10 @@ mod tests {
|
||||
assert_eq!(getenv_os("GHOSTTY_RS_T3").unwrap(), v);
|
||||
assert_eq!(getenv_not_empty_os("GHOSTTY_RS_T3").unwrap(), v);
|
||||
let s = getenv("GHOSTTY_RS_T3").unwrap();
|
||||
assert_eq!(crate::osbytes::config_str_to_bytes(&s), vec![b'a', 0xff]);
|
||||
assert_eq!(
|
||||
crate::osbytes::config_str_to_bytes(s.as_encoded()),
|
||||
vec![b'a', 0xff]
|
||||
);
|
||||
assert!(getenv_not_empty("GHOSTTY_RS_T3").is_some());
|
||||
});
|
||||
}
|
||||
@@ -361,9 +364,9 @@ mod tests {
|
||||
temp_env::with_vars(
|
||||
[("GHOSTTY_RS_T1", Some("")), ("GHOSTTY_RS_T2", Some("v"))],
|
||||
|| {
|
||||
assert_eq!(getenv("GHOSTTY_RS_T1").as_deref(), Some(""));
|
||||
assert_eq!(getenv("GHOSTTY_RS_T1").unwrap(), "");
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_T1"), None);
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").as_deref(), Some("v"));
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_T2").unwrap(), "v");
|
||||
assert_eq!(getenv_not_empty("GHOSTTY_RS_NOPE"), None);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -101,12 +101,6 @@ pub fn alloc_tmp_dir_os() -> Option<std::path::PathBuf> {
|
||||
Some("/tmp".into())
|
||||
}
|
||||
|
||||
/// Return the recommended path for temporary files. Non-UTF-8 bytes are
|
||||
/// carried via [`crate::osbytes`]; see [`alloc_tmp_dir_os`].
|
||||
pub fn alloc_tmp_dir() -> Option<String> {
|
||||
alloc_tmp_dir_os().map(|p| crate::osbytes::os_to_config_string(p.as_os_str()))
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -115,13 +109,13 @@ mod tests {
|
||||
#[test]
|
||||
fn tmp_dir_env_precedence() {
|
||||
temp_env::with_vars([("TMPDIR", Some("/a")), ("TMP", Some("/b"))], || {
|
||||
assert_eq!(alloc_tmp_dir().as_deref(), Some("/a"));
|
||||
assert_eq!(alloc_tmp_dir_os(), Some("/a".into()));
|
||||
});
|
||||
temp_env::with_vars([("TMPDIR", None), ("TMP", Some("/b"))], || {
|
||||
assert_eq!(alloc_tmp_dir().as_deref(), Some("/b"));
|
||||
assert_eq!(alloc_tmp_dir_os(), Some("/b".into()));
|
||||
});
|
||||
temp_env::with_vars([("TMPDIR", None::<&str>), ("TMP", None)], || {
|
||||
assert_eq!(alloc_tmp_dir().as_deref(), Some("/tmp"));
|
||||
assert_eq!(alloc_tmp_dir_os(), Some("/tmp".into()));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ pub struct FlatpakHostCommand {
|
||||
|
||||
/// The cwd for the new process. If this is not set then it will use
|
||||
/// the current cwd of the calling process.
|
||||
pub cwd: Option<String>,
|
||||
pub cwd: Option<crate::osbytes::EncodedOs>,
|
||||
|
||||
/// Environment variables for the child process. If this is None, this
|
||||
/// does not send any environment variables.
|
||||
@@ -298,15 +298,15 @@ impl FlatpakSignalHandle {
|
||||
|
||||
/// Decode a reversible config string to the original bytes plus NUL, the
|
||||
/// D-Bus `ay` form of a path/argument.
|
||||
fn nul_terminated_bytes(s: &str) -> Vec<u8> {
|
||||
let mut v = crate::osbytes::config_str_to_bytes(s);
|
||||
fn nul_terminated_bytes(s: &crate::osbytes::EncodedOs) -> Vec<u8> {
|
||||
let mut v = s.to_bytes();
|
||||
v.push(0);
|
||||
v
|
||||
}
|
||||
|
||||
struct Request {
|
||||
argv: Vec<String>,
|
||||
cwd: Option<String>,
|
||||
cwd: Option<crate::osbytes::EncodedOs>,
|
||||
env: HashMap<String, String>,
|
||||
fds: [OwnedFd; 3],
|
||||
}
|
||||
@@ -443,8 +443,8 @@ fn wait_for_exit(shared: &Shared, iter: MessageIterator, pid: u32) {
|
||||
mod tests {
|
||||
#[test]
|
||||
fn dbus_bytes_are_decoded() {
|
||||
let enc = crate::osbytes::bytes_to_config_string(b"/tmp/\xff\xfe");
|
||||
assert_ne!(enc.as_bytes(), b"/tmp/\xff\xfe");
|
||||
let enc = crate::osbytes::EncodedOs::from_bytes(b"/tmp/\xff\xfe");
|
||||
assert_ne!(enc.as_encoded().as_bytes(), b"/tmp/\xff\xfe");
|
||||
assert_eq!(super::nul_terminated_bytes(&enc), b"/tmp/\xff\xfe\0");
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ const MAX_PATH_BYTES: usize = 4096;
|
||||
///
|
||||
/// Non-UTF-8 bytes are preserved through [`crate::osbytes`]; use
|
||||
/// [`home_os`] for the raw OS string.
|
||||
pub fn home() -> Result<Option<String>, Error> {
|
||||
pub fn home() -> Result<Option<osbytes::EncodedOs>, Error> {
|
||||
home_with_capacity(MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ pub fn home_os() -> Result<Option<OsString>, Error> {
|
||||
|
||||
/// Like [`home`] but emulates the fixed output buffer of the Zig version:
|
||||
/// errors with `BufferTooSmall` if the result is longer than `cap` bytes.
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<String>, Error> {
|
||||
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::os_to_config_string(&h)))
|
||||
pub fn home_with_capacity(cap: usize) -> Result<Option<osbytes::EncodedOs>, Error> {
|
||||
Ok(home_os_with_capacity(cap)?.map(|h| osbytes::EncodedOs::from_os(&h)))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`home_with_capacity`].
|
||||
@@ -98,25 +98,29 @@ pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
|
||||
/// either variable is missing.
|
||||
#[cfg(windows)]
|
||||
pub fn home_os_with_capacity(cap: usize) -> Result<Option<OsString>, Error> {
|
||||
let drive = std::env::var_os("HOMEDRIVE").map(|v| v.to_string_lossy().into_owned());
|
||||
let path = std::env::var_os("HOMEPATH").map(|v| v.to_string_lossy().into_owned());
|
||||
Ok(home_windows(drive.as_deref(), path.as_deref(), cap)?.map(OsString::from))
|
||||
let drive = std::env::var_os("HOMEDRIVE");
|
||||
let path = std::env::var_os("HOMEPATH");
|
||||
home_windows(drive.as_deref(), path.as_deref(), cap)
|
||||
}
|
||||
|
||||
/// Pure part of [`home_with_capacity`] on Windows.
|
||||
#[cfg(any(windows, test))]
|
||||
fn home_windows(
|
||||
drive: Option<&str>,
|
||||
path: Option<&str>,
|
||||
drive: Option<&OsStr>,
|
||||
path: Option<&OsStr>,
|
||||
cap: usize,
|
||||
) -> Result<Option<String>, Error> {
|
||||
) -> Result<Option<OsString>, Error> {
|
||||
let (Some(drive), Some(path)) = (drive, path) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if cap < drive.len() + path.len() {
|
||||
// Lossless (WTF-16 safe) concatenation; the size is measured in WTF-8
|
||||
// bytes like the Zig buffer.
|
||||
if cap < osbytes::os_bytes(drive).len() + osbytes::os_bytes(path).len() {
|
||||
return Err(Error::BufferTooSmall);
|
||||
}
|
||||
Ok(Some(format!("{drive}{path}")))
|
||||
let mut home = drive.to_owned();
|
||||
home.push(path);
|
||||
Ok(Some(home))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
@@ -135,7 +139,7 @@ fn trim_space(input: &[u8]) -> &[u8] {
|
||||
///
|
||||
/// Errors if `home` fails or if the size of the expanded path is larger
|
||||
/// than the maximum path length.
|
||||
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
|
||||
pub fn expand_home(path: &osbytes::EncodedOs) -> Result<osbytes::EncodedOs, ExpandError> {
|
||||
expand_home_with_capacity(path, MAX_PATH_BYTES)
|
||||
}
|
||||
|
||||
@@ -146,9 +150,11 @@ pub fn expand_home_os(path: &OsStr) -> Result<OsString, ExpandError> {
|
||||
|
||||
/// Like [`expand_home`] with an explicit output capacity (emulating the
|
||||
/// caller-provided buffer of the Zig version).
|
||||
pub fn expand_home_with_capacity(path: &str, cap: usize) -> Result<String, ExpandError> {
|
||||
expand_home_os_with_capacity(&osbytes::config_str_to_os(path), cap)
|
||||
.map(|p| osbytes::os_to_config_string(&p))
|
||||
pub fn expand_home_with_capacity(
|
||||
path: &osbytes::EncodedOs,
|
||||
cap: usize,
|
||||
) -> Result<osbytes::EncodedOs, ExpandError> {
|
||||
expand_home_os_with_capacity(&path.to_os(), cap).map(osbytes::EncodedOs::from)
|
||||
}
|
||||
|
||||
/// Windows: `~/` is not an idiom generally used on Windows.
|
||||
@@ -183,13 +189,17 @@ mod tests {
|
||||
#[test]
|
||||
fn home_windows_joins_drive_and_path() {
|
||||
assert_eq!(
|
||||
home_windows(Some("C:"), Some("\\Users\\me"), 4096),
|
||||
Ok(Some("C:\\Users\\me".to_owned()))
|
||||
home_windows(
|
||||
Some(OsStr::new("C:")),
|
||||
Some(OsStr::new("\\Users\\me")),
|
||||
4096
|
||||
),
|
||||
Ok(Some(OsString::from("C:\\Users\\me")))
|
||||
);
|
||||
assert_eq!(home_windows(None, Some("\\x"), 4096), Ok(None));
|
||||
assert_eq!(home_windows(Some("C:"), None, 4096), Ok(None));
|
||||
assert_eq!(home_windows(None, Some(OsStr::new("\\x")), 4096), Ok(None));
|
||||
assert_eq!(home_windows(Some(OsStr::new("C:")), None, 4096), Ok(None));
|
||||
assert_eq!(
|
||||
home_windows(Some("C:"), Some("\\Users"), 3),
|
||||
home_windows(Some(OsStr::new("C:")), Some(OsStr::new("\\Users")), 3),
|
||||
Err(Error::BufferTooSmall)
|
||||
);
|
||||
}
|
||||
@@ -204,28 +214,30 @@ mod tests_unix {
|
||||
fn expand_home_unix() {
|
||||
with_env_lock(|| {
|
||||
let cap = MAX_PATH_BYTES;
|
||||
let home_dir = expand_home_with_capacity("~/", cap).unwrap();
|
||||
let ex =
|
||||
|s: &str| expand_home_with_capacity(&osbytes::EncodedOs::from_plain_text(s), cap);
|
||||
let home_dir = ex("~/").unwrap();
|
||||
// Joining the home directory `~` with the path `/`
|
||||
// the result should end with a separator here. (e.g. `/home/user/`)
|
||||
assert!(home_dir.ends_with('/'));
|
||||
assert!(home_dir.ends_with_plain("/"));
|
||||
|
||||
let downloads = expand_home_with_capacity("~/Downloads/shader.glsl", cap).unwrap();
|
||||
let expected_downloads = format!("{home_dir}Downloads/shader.glsl");
|
||||
assert_eq!(expected_downloads, downloads);
|
||||
let downloads = ex("~/Downloads/shader.glsl").unwrap();
|
||||
let expected_downloads = format!("{}Downloads/shader.glsl", home_dir.as_encoded());
|
||||
assert_eq!(downloads, expected_downloads.as_str());
|
||||
|
||||
assert_eq!("~", expand_home_with_capacity("~", cap).unwrap());
|
||||
assert_eq!("~abc/", expand_home_with_capacity("~abc/", cap).unwrap());
|
||||
assert_eq!(
|
||||
"/home/user",
|
||||
expand_home_with_capacity("/home/user", cap).unwrap()
|
||||
);
|
||||
assert_eq!("", expand_home_with_capacity("", cap).unwrap());
|
||||
assert_eq!(ex("~").unwrap(), "~");
|
||||
assert_eq!(ex("~abc/").unwrap(), "~abc/");
|
||||
assert_eq!(ex("/home/user").unwrap(), "/home/user");
|
||||
assert_eq!(ex("").unwrap(), "");
|
||||
|
||||
// Expect an error if the buffer is large enough to hold the home directory,
|
||||
// but not the expanded path
|
||||
assert_eq!(
|
||||
Err(ExpandError::BufferTooSmall),
|
||||
expand_home_with_capacity("~/Downloads", home_dir.len())
|
||||
expand_home_with_capacity(
|
||||
&osbytes::EncodedOs::from_plain_text("~/Downloads"),
|
||||
home_dir.as_encoded().len()
|
||||
)
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -248,16 +260,19 @@ mod tests_unix {
|
||||
let e = expand_home_os(OsStr::from_bytes(b"~/x\xfe")).unwrap();
|
||||
assert_eq!(e.as_bytes(), b"/home/\xffu/x\xfe");
|
||||
// The String API round-trips through the escape encoding.
|
||||
let s = expand_home("~/y").unwrap();
|
||||
assert_eq!(osbytes::config_str_to_bytes(&s), b"/home/\xffu/y");
|
||||
let s = expand_home(&osbytes::EncodedOs::from_plain_text("~/y")).unwrap();
|
||||
assert_eq!(s.to_bytes(), b"/home/\xffu/y");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn home_prefers_env() {
|
||||
temp_env::with_var("HOME", Some("/some/home"), || {
|
||||
assert_eq!(home().unwrap().as_deref(), Some("/some/home"));
|
||||
assert_eq!(expand_home("~/x").unwrap(), "/some/home/x");
|
||||
assert_eq!(home().unwrap().unwrap(), "/some/home");
|
||||
assert_eq!(
|
||||
expand_home(&osbytes::EncodedOs::from_plain_text("~/x")).unwrap(),
|
||||
"/some/home/x"
|
||||
);
|
||||
assert_eq!(home_with_capacity(3), Err(Error::BufferTooSmall));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -27,19 +27,18 @@ pub enum InitError {
|
||||
/// used by libghostty.
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
pub fn init(_resources_dir: &str) -> Result<(), InitError> {
|
||||
pub fn init(_resources_dir: &std::path::Path) -> Result<(), InitError> {
|
||||
// i18n is unsupported on Windows
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// See the Windows variant above.
|
||||
#[cfg(not(windows))]
|
||||
pub fn init(resources_dir: &str) -> Result<(), InitError> {
|
||||
pub fn init(resources_dir: &std::path::Path) -> Result<(), InitError> {
|
||||
// Our resources dir is always nested below the share dir that
|
||||
// is standard for translations.
|
||||
// (`std.fs.path.dirname` returns null for a bare relative name, where
|
||||
// `Path::parent` returns an empty path.)
|
||||
let resources_dir = crate::osbytes::config_str_to_path(resources_dir);
|
||||
let share_dir = resources_dir
|
||||
.parent()
|
||||
.filter(|p| !p.as_os_str().is_empty())
|
||||
@@ -248,10 +247,13 @@ mod tests {
|
||||
#[test]
|
||||
fn init_rejects_dirless_resources_dir() {
|
||||
assert!(matches!(
|
||||
init("ghostty"),
|
||||
init(std::path::Path::new("ghostty")),
|
||||
Err(InitError::InvalidResourcesDir)
|
||||
));
|
||||
assert!(matches!(
|
||||
init(std::path::Path::new("/")),
|
||||
Err(InitError::InvalidResourcesDir)
|
||||
));
|
||||
assert!(matches!(init("/"), Err(InitError::InvalidResourcesDir)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -145,7 +145,7 @@ pub use env::{
|
||||
prepend_env_os,
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use file::{Rlimit, alloc_tmp_dir, alloc_tmp_dir_os, fix_max_files, restore_max_files};
|
||||
pub use file::{Rlimit, alloc_tmp_dir_os, fix_max_files, restore_max_files};
|
||||
#[cfg(unix)]
|
||||
pub use flatpak::FlatpakHostCommand;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
ffi::{OsStr, OsString},
|
||||
ffi::OsStr,
|
||||
io,
|
||||
process::{Command, Stdio},
|
||||
};
|
||||
@@ -18,9 +18,10 @@ pub enum OpenUrlKind {
|
||||
|
||||
/// Open a URL in the default handling application (via `xdg-open`).
|
||||
///
|
||||
/// `url` is the core's "config string" form (see [`crate::osbytes`]): a
|
||||
/// non-UTF-8 path is carried in an escaped form and is decoded to the real
|
||||
/// path bytes here, right before it is handed to the opener.
|
||||
/// `url` is the exact OS string to hand to the opener. Callers holding an
|
||||
/// [`EncodedOs`](crate::osbytes::EncodedOs) decode it explicitly
|
||||
/// (`to_os`); plain text (a regex link, an OSC 8 URI) is wrapped with
|
||||
/// `EncodedOs::from_plain_text` first, so it is never decoded by mistake.
|
||||
///
|
||||
/// Any output on stderr is logged as a warning in the application logs.
|
||||
/// Output on stdout is ignored.
|
||||
@@ -29,9 +30,8 @@ pub enum OpenUrlKind {
|
||||
/// some portable way to open URLs. If you are implementing an
|
||||
/// apprt for Ghostty, you should consider doing something special-cased
|
||||
/// for your platform.
|
||||
pub fn open(kind: OpenUrlKind, url: &str) -> io::Result<()> {
|
||||
let url: OsString = crate::osbytes::config_str_to_os(url);
|
||||
let mut cmd = open_command(kind, &url)?;
|
||||
pub fn open(kind: OpenUrlKind, url: &OsStr) -> io::Result<()> {
|
||||
let mut cmd = open_command(kind, url)?;
|
||||
|
||||
// Pipe stdout/stderr so we can collect output from the command.
|
||||
cmd.stdin(Stdio::null())
|
||||
@@ -141,12 +141,20 @@ mod tests {
|
||||
assert_eq!(macos_open_args(OpenUrlKind::Unknown, u), ["u"]);
|
||||
}
|
||||
|
||||
/// The opener receives decoded bytes for encoded paths and the text
|
||||
/// unchanged for plain URLs (even when they contain the escape char).
|
||||
#[test]
|
||||
fn encoded_non_utf8_path_is_decoded_for_the_opener() {
|
||||
fn encoded_paths_decode_but_plain_urls_do_not() {
|
||||
use std::os::unix::ffi::OsStrExt as _;
|
||||
let enc = crate::osbytes::bytes_to_config_string(b"/tmp/a\xffb");
|
||||
let os = crate::osbytes::config_str_to_os(&enc);
|
||||
assert_eq!(os.as_bytes(), b"/tmp/a\xffb");
|
||||
|
||||
use crate::osbytes::EncodedOs;
|
||||
let path = EncodedOs::from_bytes(b"/tmp/a\xffb");
|
||||
assert_eq!(path.to_os().as_bytes(), b"/tmp/a\xffb");
|
||||
|
||||
// U+FDD0 U+00FF in an ordinary URL must stay two chars, not byte FF.
|
||||
let url = "https://x/\u{fdd0}\u{ff}";
|
||||
let plain = EncodedOs::from_plain_text(url);
|
||||
assert_eq!(plain.to_os().as_bytes(), url.as_bytes());
|
||||
}
|
||||
|
||||
#[cfg(not(target_vendor = "apple"))]
|
||||
@@ -155,7 +163,7 @@ mod tests {
|
||||
// With an empty PATH, xdg-open can't be found; Zig detects this
|
||||
// synchronously and so do we.
|
||||
temp_env::with_var("PATH", Some("/nonexistent-dir"), || {
|
||||
let err = open(OpenUrlKind::Unknown, "https://example.com").unwrap_err();
|
||||
let err = open(OpenUrlKind::Unknown, OsStr::new("https://example.com")).unwrap_err();
|
||||
assert_eq!(err.kind(), io::ErrorKind::NotFound);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -62,52 +62,127 @@ pub fn config_str_to_bytes(s: &str) -> Vec<u8> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The raw bytes of an OS string (Unix; lossless). On other platforms the
|
||||
/// string is converted via its UTF-8 (WTF-8) form.
|
||||
/// The raw bytes of an OS string. Lossless on every platform: Unix returns
|
||||
/// the bytes, Windows returns WTF-8 (unpaired UTF-16 surrogates are encoded
|
||||
/// as 3-byte sequences, like Zig's Windows argument/environment handling).
|
||||
#[cfg(unix)]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
s.as_bytes().to_vec()
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
s.to_string_lossy().into_owned().into_bytes()
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
wide_to_wtf8(&s.encode_wide().collect::<Vec<u16>>())
|
||||
}
|
||||
|
||||
/// Build an OS string from raw bytes.
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
pub fn os_bytes(s: &OsStr) -> Vec<u8> {
|
||||
s.to_str().map_or_else(Vec::new, |s| s.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
/// Build an OS string from raw bytes (WTF-8 on Windows; bytes that are not
|
||||
/// well-formed WTF-8 become U+FFFD, like Zig's `wtf8ToWtf16Le`/`utf8ToUtf16`).
|
||||
#[cfg(unix)]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
OsString::from_vec(b)
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
#[cfg(windows)]
|
||||
#[allow(clippy::needless_pass_by_value)] // same signature as the Unix variant
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
// Windows OS strings are WTF-16; valid UTF-8 converts losslessly and is
|
||||
// reused without copying, anything else is replaced like Zig's utf8ToUtf16.
|
||||
OsString::from(
|
||||
String::from_utf8(b).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()),
|
||||
)
|
||||
use std::os::windows::ffi::OsStringExt;
|
||||
OsString::from_wide(&wtf8_to_wide(&b))
|
||||
}
|
||||
|
||||
/// OS string to reversible `String` (see the module docs).
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
#[allow(clippy::needless_pass_by_value)]
|
||||
pub fn os_from_bytes(b: Vec<u8>) -> OsString {
|
||||
OsString::from(String::from_utf8_lossy(&b).into_owned())
|
||||
}
|
||||
|
||||
/// Encode UTF-16 code units (possibly with unpaired surrogates) as WTF-8.
|
||||
/// Platform independent so the Windows logic is testable everywhere.
|
||||
pub fn wide_to_wtf8(wide: &[u16]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(wide.len());
|
||||
for r in char::decode_utf16(wide.iter().copied()) {
|
||||
match r {
|
||||
Ok(c) => {
|
||||
let mut buf = [0u8; 4];
|
||||
out.extend_from_slice(c.encode_utf8(&mut buf).as_bytes());
|
||||
}
|
||||
Err(e) => {
|
||||
let u = u32::from(e.unpaired_surrogate());
|
||||
out.push(0xE0 | (u >> 12) as u8);
|
||||
out.push(0x80 | ((u >> 6) & 0x3F) as u8);
|
||||
out.push(0x80 | (u & 0x3F) as u8);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Decode WTF-8 into UTF-16 code units. Surrogate code points encoded as
|
||||
/// 3-byte sequences are kept as lone surrogates; anything not well-formed
|
||||
/// WTF-8 yields U+FFFD for each offending byte.
|
||||
pub fn wtf8_to_wide(b: &[u8]) -> Vec<u16> {
|
||||
let mut out = Vec::with_capacity(b.len());
|
||||
let mut i = 0;
|
||||
let cont = |j: usize| {
|
||||
b.get(j)
|
||||
.filter(|c| (**c & 0xC0) == 0x80)
|
||||
.map(|c| u32::from(*c & 0x3F))
|
||||
};
|
||||
while i < b.len() {
|
||||
let lead = b[i];
|
||||
let (cp, len) = match lead {
|
||||
0x00..=0x7F => (u32::from(lead), 1),
|
||||
0xC2..=0xDF => match cont(i + 1) {
|
||||
Some(c1) => ((u32::from(lead & 0x1F) << 6) | c1, 2),
|
||||
None => (0xFFFD, 1),
|
||||
},
|
||||
0xE0..=0xEF => match (cont(i + 1), cont(i + 2)) {
|
||||
(Some(c1), Some(c2)) if !(lead == 0xE0 && b[i + 1] < 0xA0) => {
|
||||
((u32::from(lead & 0x0F) << 12) | (c1 << 6) | c2, 3)
|
||||
}
|
||||
_ => (0xFFFD, 1),
|
||||
},
|
||||
0xF0..=0xF4 => match (cont(i + 1), cont(i + 2), cont(i + 3)) {
|
||||
(Some(c1), Some(c2), Some(c3)) => {
|
||||
let cp = (u32::from(lead & 0x07) << 18) | (c1 << 12) | (c2 << 6) | c3;
|
||||
if (0x1_0000..=0x10_FFFF).contains(&cp) {
|
||||
(cp, 4)
|
||||
} else {
|
||||
(0xFFFD, 1)
|
||||
}
|
||||
}
|
||||
_ => (0xFFFD, 1),
|
||||
},
|
||||
_ => (0xFFFD, 1),
|
||||
};
|
||||
i += len;
|
||||
if cp >= 0x1_0000 {
|
||||
let c = cp - 0x1_0000;
|
||||
out.push(0xD800 | (c >> 10) as u16);
|
||||
out.push(0xDC00 | (c & 0x3FF) as u16);
|
||||
} else {
|
||||
out.push(cp as u16);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// OS string to reversible `String` (see the module docs). Lossless on every
|
||||
/// platform (Windows goes through WTF-8).
|
||||
pub fn os_to_config_string(s: &OsStr) -> String {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
bytes_to_config_string(s.as_bytes())
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
valid_str_to_config_string(&s.to_string_lossy())
|
||||
}
|
||||
bytes_to_config_string(&os_bytes(s))
|
||||
}
|
||||
|
||||
/// Encode a string that is already valid UTF-8 (e.g. the WTF-8 to UTF-8
|
||||
/// conversion of a non-Unix OS string). The only thing that needs escaping is
|
||||
/// a literal escape character, so decoding stays a bijection on every
|
||||
/// platform. Shared (not cfg gated) so it is testable everywhere.
|
||||
/// Encode a string that is already valid UTF-8 (plain text, e.g. a URL). The
|
||||
/// only thing that needs escaping is a literal escape character, so decoding
|
||||
/// stays a bijection on every platform.
|
||||
pub fn valid_str_to_config_string(s: &str) -> String {
|
||||
bytes_to_config_string(s.as_bytes())
|
||||
}
|
||||
@@ -117,6 +192,189 @@ pub fn config_str_to_os(s: &str) -> OsString {
|
||||
os_from_bytes(config_str_to_bytes(s))
|
||||
}
|
||||
|
||||
/// An OS string (path, environment value, home directory, ...) carried as a
|
||||
/// `String` through the reversible escape encoding of this module.
|
||||
///
|
||||
/// This is deliberately a distinct type: it cannot be used as text or passed
|
||||
/// to filesystem/exec APIs without an explicit [`EncodedOs::to_os`] /
|
||||
/// [`EncodedOs::to_path`] decode, and plain text (URLs, regex matches, ...)
|
||||
/// never becomes an `EncodedOs` except through [`EncodedOs::from_plain_text`],
|
||||
/// which escapes instead of decoding.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub struct EncodedOs(String);
|
||||
|
||||
impl EncodedOs {
|
||||
/// Empty value.
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
EncodedOs(String::new())
|
||||
}
|
||||
|
||||
/// Encode an OS string.
|
||||
#[must_use]
|
||||
pub fn from_os(s: &OsStr) -> Self {
|
||||
EncodedOs(os_to_config_string(s))
|
||||
}
|
||||
|
||||
/// Encode a path.
|
||||
#[must_use]
|
||||
pub fn from_path(p: &std::path::Path) -> Self {
|
||||
Self::from_os(p.as_os_str())
|
||||
}
|
||||
|
||||
/// Encode raw bytes.
|
||||
#[must_use]
|
||||
pub fn from_bytes(b: &[u8]) -> Self {
|
||||
EncodedOs(bytes_to_config_string(b))
|
||||
}
|
||||
|
||||
/// Adopt a string that is *already* in the escape encoding (config
|
||||
/// values, CLI arguments collected with `args::collect_config_strings`).
|
||||
#[must_use]
|
||||
pub fn from_encoded(s: impl Into<String>) -> Self {
|
||||
EncodedOs(s.into())
|
||||
}
|
||||
|
||||
/// Wrap plain text (not previously encoded): a literal escape character
|
||||
/// is escaped so decoding returns exactly the same text.
|
||||
#[must_use]
|
||||
pub fn from_plain_text(s: &str) -> Self {
|
||||
EncodedOs(valid_str_to_config_string(s))
|
||||
}
|
||||
|
||||
/// The encoded text (config files, `+show-config`, display of config
|
||||
/// values). Never pass this to the OS; decode first.
|
||||
#[must_use]
|
||||
pub fn as_encoded(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Consume into the encoded string.
|
||||
#[must_use]
|
||||
pub fn into_encoded(self) -> String {
|
||||
self.0
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
/// Whether the decoded value ends with the given plain suffix.
|
||||
#[must_use]
|
||||
pub fn ends_with_plain(&self, suffix: &str) -> bool {
|
||||
self.0.ends_with(suffix)
|
||||
}
|
||||
|
||||
/// Decode to the raw bytes.
|
||||
#[must_use]
|
||||
pub fn to_bytes(&self) -> Vec<u8> {
|
||||
config_str_to_bytes(&self.0)
|
||||
}
|
||||
|
||||
/// Decode to an OS string.
|
||||
#[must_use]
|
||||
pub fn to_os(&self) -> OsString {
|
||||
config_str_to_os(&self.0)
|
||||
}
|
||||
|
||||
/// Decode to UTF-16 code units (WTF-16: lone surrogates round-trip).
|
||||
/// This is the Windows API form; it is platform independent so the
|
||||
/// Windows logic is testable everywhere.
|
||||
#[must_use]
|
||||
pub fn to_wide(&self) -> Vec<u16> {
|
||||
wtf8_to_wide(&self.to_bytes())
|
||||
}
|
||||
|
||||
/// [`EncodedOs::to_wide`] plus a trailing NUL.
|
||||
#[must_use]
|
||||
pub fn to_wide_nul(&self) -> Vec<u16> {
|
||||
let mut w = self.to_wide();
|
||||
w.push(0);
|
||||
w
|
||||
}
|
||||
|
||||
/// Encode UTF-16 code units (possibly with lone surrogates).
|
||||
#[must_use]
|
||||
pub fn from_wide(w: &[u16]) -> Self {
|
||||
Self::from_bytes(&wide_to_wtf8(w))
|
||||
}
|
||||
|
||||
/// Decode to a path.
|
||||
#[must_use]
|
||||
pub fn to_path(&self) -> std::path::PathBuf {
|
||||
config_str_to_path(&self.0)
|
||||
}
|
||||
|
||||
/// Whether the decoded value starts with the given plain prefix.
|
||||
#[must_use]
|
||||
pub fn starts_with_plain(&self, prefix: &str) -> bool {
|
||||
// Valid UTF-8 prefixes (without the escape char) encode to themselves.
|
||||
self.0.starts_with(prefix)
|
||||
}
|
||||
|
||||
/// Join plain-text components like `std.fs.path.join` (empty components
|
||||
/// skipped, exactly one separator in between). Separators are ASCII and
|
||||
/// never part of an escape, so joining the encoded text is exact.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[must_use]
|
||||
pub fn join_plain(&self, parts: &[&str]) -> Self {
|
||||
let esc: Vec<String> = parts
|
||||
.iter()
|
||||
.map(|p| valid_str_to_config_string(p))
|
||||
.collect();
|
||||
let mut all: Vec<&str> = Vec::with_capacity(parts.len() + 1);
|
||||
all.push(&self.0);
|
||||
all.extend(esc.iter().map(String::as_str));
|
||||
EncodedOs(crate::path_join(&all))
|
||||
}
|
||||
|
||||
/// Join another encoded path (same rules as [`EncodedOs::join_plain`]).
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[must_use]
|
||||
pub fn join(&self, other: &EncodedOs) -> Self {
|
||||
EncodedOs(crate::path_join(&[&self.0, &other.0]))
|
||||
}
|
||||
}
|
||||
|
||||
/// Compares the *encoded* form with a literal (for plain ASCII/UTF-8 text
|
||||
/// without the escape character the two are identical).
|
||||
impl PartialEq<str> for EncodedOs {
|
||||
fn eq(&self, other: &str) -> bool {
|
||||
self.0 == other
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialEq<&str> for EncodedOs {
|
||||
fn eq(&self, other: &&str) -> bool {
|
||||
self.0 == *other
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&OsStr> for EncodedOs {
|
||||
fn from(s: &OsStr) -> Self {
|
||||
Self::from_os(s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<OsString> for EncodedOs {
|
||||
fn from(s: OsString) -> Self {
|
||||
Self::from_os(&s)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&std::path::Path> for EncodedOs {
|
||||
fn from(p: &std::path::Path) -> Self {
|
||||
Self::from_path(p)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::path::PathBuf> for EncodedOs {
|
||||
fn from(p: std::path::PathBuf) -> Self {
|
||||
Self::from_path(&p)
|
||||
}
|
||||
}
|
||||
|
||||
/// Path to reversible `String` (see the module docs).
|
||||
pub fn path_to_config_string(p: &std::path::Path) -> String {
|
||||
os_to_config_string(p.as_os_str())
|
||||
@@ -237,6 +495,59 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wtf8_round_trips_unpaired_surrogates() {
|
||||
// "a", lone high, "b", valid pair (U+1F600), lone low.
|
||||
let wide = [0x61u16, 0xD800, 0x62, 0xD83D, 0xDE00, 0xDC00];
|
||||
let b = wide_to_wtf8(&wide);
|
||||
assert_eq!(&b[..1], b"a");
|
||||
assert_eq!(&b[1..4], &[0xED, 0xA0, 0x80]);
|
||||
assert_eq!(wtf8_to_wide(&b), wide);
|
||||
// Through the reversible string encoding too.
|
||||
let s = bytes_to_config_string(&b);
|
||||
assert_eq!(wtf8_to_wide(&config_str_to_bytes(&s)), wide);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wtf8_invalid_bytes_become_replacement() {
|
||||
assert_eq!(wtf8_to_wide(b"a\xffb"), vec![0x61, 0xFFFD, 0x62]);
|
||||
assert_eq!(wtf8_to_wide(b"\xe0\x80\x80"), vec![0xFFFD; 3]);
|
||||
assert_eq!(
|
||||
wtf8_to_wide("\u{e9}\u{1F600}".as_bytes()),
|
||||
vec![0xE9, 0xD83D, 0xDE00]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_os_plain_text_is_not_decoded() {
|
||||
let plain = "http://x/\u{fdd0}\u{ff}";
|
||||
let e = EncodedOs::from_plain_text(plain);
|
||||
assert_eq!(e.to_bytes(), plain.as_bytes());
|
||||
// Adopting the same text as "already encoded" would decode to 0xFF.
|
||||
assert_ne!(EncodedOs::from_encoded(plain).to_bytes(), plain.as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_os_wide_round_trips_lone_surrogates() {
|
||||
let wide = [0x43u16, 0x3A, 0x5C, 0xD800, 0x61, 0xD83D, 0xDE00, 0xDFFF];
|
||||
let e = EncodedOs::from_wide(&wide);
|
||||
assert_eq!(e.to_wide(), wide);
|
||||
assert_eq!(*e.to_wide_nul().last().unwrap(), 0);
|
||||
// A literal escape char in a Windows name is still preserved.
|
||||
let w2: Vec<u16> = "a\u{fdd0}\u{ff}".encode_utf16().collect();
|
||||
assert_eq!(EncodedOs::from_wide(&w2).to_wide(), w2);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn encoded_os_unix_round_trip() {
|
||||
let o = os_from_bytes(vec![b'/', 0xff, b'x']);
|
||||
let e = EncodedOs::from_os(&o);
|
||||
assert_eq!(e.to_os(), o);
|
||||
assert_eq!(e.to_path(), std::path::PathBuf::from(&o));
|
||||
assert_eq!(e.join_plain(&["a", "", "b"]).to_bytes(), b"/\xffx/a/b");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn os_roundtrip() {
|
||||
|
||||
@@ -2,15 +2,15 @@ use std::ffi::OsString;
|
||||
|
||||
#[cfg(any(target_os = "linux", target_os = "android"))]
|
||||
use crate::flatpak::is_flatpak;
|
||||
use crate::osbytes;
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
/// Entry that is retrieved from the passwd API. This only contains the fields
|
||||
/// we care about.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub shell: Option<String>,
|
||||
pub home: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub shell: Option<EncodedOs>,
|
||||
pub home: Option<EncodedOs>,
|
||||
pub name: Option<EncodedOs>,
|
||||
}
|
||||
|
||||
/// Byte-preserving [`Entry`] (Zig slices are arbitrary bytes).
|
||||
@@ -23,7 +23,7 @@ pub struct EntryOs {
|
||||
|
||||
impl From<EntryOs> for Entry {
|
||||
fn from(e: EntryOs) -> Self {
|
||||
let f = |v: Option<OsString>| v.map(|v| osbytes::os_to_config_string(&v));
|
||||
let f = |v: Option<OsString>| v.map(|v| EncodedOs::from_os(&v));
|
||||
Entry {
|
||||
shell: f(e.shell),
|
||||
home: f(e.home),
|
||||
@@ -152,8 +152,8 @@ fn parse_getent(output: &[u8]) -> EntryOs {
|
||||
.rposition(|b| !matches!(b, b' ' | b'\r' | b'\n'))
|
||||
.map_or(0, |i| i + 1);
|
||||
let mut it = output[..end].rsplit(|&b| b == b':');
|
||||
let shell = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
|
||||
let home = it.next().map(|b| osbytes::os_from_bytes(b.to_vec()));
|
||||
let shell = it.next().map(|b| crate::osbytes::os_from_bytes(b.to_vec()));
|
||||
let home = it.next().map(|b| crate::osbytes::os_from_bytes(b.to_vec()));
|
||||
EntryOs {
|
||||
shell,
|
||||
home,
|
||||
|
||||
@@ -27,9 +27,8 @@ pub enum Error {
|
||||
|
||||
/// Search for "cmd" in the PATH and return the absolute path. Non-UTF-8
|
||||
/// bytes are carried via [`crate::osbytes`]; see [`expand_os`].
|
||||
pub fn expand(cmd: &str) -> Result<Option<String>, Error> {
|
||||
let cmd = crate::osbytes::config_str_to_os(cmd);
|
||||
Ok(expand_os(&cmd)?.map(|p| crate::osbytes::os_to_config_string(p.as_os_str())))
|
||||
pub fn expand(cmd: &crate::osbytes::EncodedOs) -> Result<Option<crate::osbytes::EncodedOs>, Error> {
|
||||
Ok(expand_os(&cmd.to_os())?.map(crate::osbytes::EncodedOs::from))
|
||||
}
|
||||
|
||||
/// Byte-preserving [`expand`]: `$PATH` and the command are handled as raw
|
||||
@@ -105,6 +104,12 @@ fn is_executable(_meta: &std::fs::Metadata) -> bool {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Plain-text wrapper over [`expand`] for the literals used below.
|
||||
fn expand(cmd: &str) -> Result<Option<String>, Error> {
|
||||
super::expand(&crate::osbytes::EncodedOs::from_plain_text(cmd))
|
||||
.map(|o| o.map(crate::osbytes::EncodedOs::into_encoded))
|
||||
}
|
||||
|
||||
// `uname -n` is the *nix equivalent of `hostname.exe` on Windows
|
||||
#[test]
|
||||
fn expand_hostname() {
|
||||
|
||||
@@ -4,24 +4,24 @@ use std::{
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
use crate::osbytes::{config_str_to_os, os_to_config_string};
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
fn enc(p: &Path) -> String {
|
||||
os_to_config_string(p.as_os_str())
|
||||
fn enc(p: &Path) -> EncodedOs {
|
||||
EncodedOs::from_path(p)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ResourcesDir {
|
||||
/// Avoid accessing these directly, use the app() and host() methods instead.
|
||||
pub app_path: Option<String>,
|
||||
pub host_path: Option<String>,
|
||||
pub app_path: Option<EncodedOs>,
|
||||
pub host_path: Option<EncodedOs>,
|
||||
}
|
||||
|
||||
impl ResourcesDir {
|
||||
/// Get the directory to the bundled resources directory accessible
|
||||
/// by the application.
|
||||
pub fn app(&self) -> Option<&str> {
|
||||
self.app_path.as_deref()
|
||||
pub fn app(&self) -> Option<&EncodedOs> {
|
||||
self.app_path.as_ref()
|
||||
}
|
||||
|
||||
/// Get the directory to the bundled resources directory accessible
|
||||
@@ -30,19 +30,19 @@ impl ResourcesDir {
|
||||
/// itself.
|
||||
///
|
||||
/// In non-sandboxed environment, this should be the same as app().
|
||||
pub fn host(&self) -> Option<&str> {
|
||||
self.host_path.as_deref().or(self.app_path.as_deref())
|
||||
pub fn host(&self) -> Option<&EncodedOs> {
|
||||
self.host_path.as_ref().or(self.app_path.as_ref())
|
||||
}
|
||||
|
||||
/// Byte-preserving [`ResourcesDir::app`]: the string fields carry
|
||||
/// non-UTF-8 bytes via [`crate::osbytes`], this decodes them.
|
||||
pub fn app_os(&self) -> Option<PathBuf> {
|
||||
self.app().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
self.app().map(EncodedOs::to_path)
|
||||
}
|
||||
|
||||
/// Byte-preserving [`ResourcesDir::host`].
|
||||
pub fn host_os(&self) -> Option<PathBuf> {
|
||||
self.host().map(|p| PathBuf::from(config_str_to_os(p)))
|
||||
self.host().map(EncodedOs::to_path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) ->
|
||||
// freshly built ones under zig-out/share/ghostty.
|
||||
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(os_to_config_string(dir)),
|
||||
app_path: Some(EncodedOs::from_os(dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -127,7 +127,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) ->
|
||||
// fallback and use the provided resources dir.
|
||||
if debug && let Some(dir) = env.filter(|d| !d.is_empty()) {
|
||||
return ResourcesDir {
|
||||
app_path: Some(os_to_config_string(&dir)),
|
||||
app_path: Some(EncodedOs::from_os(&dir)),
|
||||
host_path: None,
|
||||
};
|
||||
}
|
||||
@@ -176,22 +176,6 @@ pub fn maybe_dir_os(base: &Path, sub: &str, suffix: &str) -> Option<PathBuf> {
|
||||
dir.join(suffix).exists().then_some(dir)
|
||||
}
|
||||
|
||||
/// Little helper to check if the "base/sub/suffix" directory exists and
|
||||
/// if so return "base/sub". The "suffix" is just used as a way to verify a
|
||||
/// directory seems roughly right.
|
||||
pub fn maybe_dir(base: &str, sub: &str, suffix: &str) -> Option<String> {
|
||||
let path = format!("{base}/{sub}/{suffix}");
|
||||
|
||||
// Folder doesn't exist. If a different error happens its okay
|
||||
// we just ignore it and move on.
|
||||
if Path::new(&path).exists() {
|
||||
let len = path.len() - suffix.len() - 1;
|
||||
return Some(path[..len].to_owned());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -199,13 +183,13 @@ mod tests {
|
||||
#[test]
|
||||
fn resources_dir_accessors() {
|
||||
let mut r = ResourcesDir::default();
|
||||
assert_eq!(r.app(), None);
|
||||
assert_eq!(r.host(), None);
|
||||
r.app_path = Some("/a".into());
|
||||
assert_eq!(r.host(), Some("/a"));
|
||||
r.host_path = Some("/h".into());
|
||||
assert_eq!(r.app(), Some("/a"));
|
||||
assert_eq!(r.host(), Some("/h"));
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
|
||||
assert_eq!(r.host().map(EncodedOs::as_encoded), None);
|
||||
r.app_path = Some(EncodedOs::from_encoded("/a"));
|
||||
assert_eq!(r.host().map(EncodedOs::as_encoded), Some("/a"));
|
||||
r.host_path = Some(EncodedOs::from_encoded("/h"));
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), Some("/a"));
|
||||
assert_eq!(r.host().map(EncodedOs::as_encoded), Some("/h"));
|
||||
}
|
||||
|
||||
fn make_tree() -> crate::TempDir {
|
||||
@@ -222,7 +206,7 @@ mod tests {
|
||||
let exe = td.path().join("bin/ghostty");
|
||||
let r = resources_dir_impl(true, Some(&exe), None);
|
||||
let expected = format!("{}/share/ghostty", td.path().display());
|
||||
assert_eq!(r.app(), Some(expected.as_str()));
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), Some(expected.as_str()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -231,21 +215,29 @@ mod tests {
|
||||
let exe = td.path().join("bin/ghostty");
|
||||
let env = Some(OsString::from("/env/res"));
|
||||
assert_eq!(
|
||||
resources_dir_impl(false, Some(&exe), env.clone()).app(),
|
||||
resources_dir_impl(false, Some(&exe), env.clone())
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
Some("/env/res")
|
||||
);
|
||||
assert_ne!(
|
||||
resources_dir_impl(true, Some(&exe), env.clone()).app(),
|
||||
resources_dir_impl(true, Some(&exe), env.clone())
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
Some("/env/res")
|
||||
);
|
||||
// Debug fallback when detection fails.
|
||||
assert_eq!(
|
||||
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env).app(),
|
||||
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env)
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
Some("/env/res")
|
||||
);
|
||||
// Empty env is ignored.
|
||||
assert_eq!(
|
||||
resources_dir_impl(false, None, Some(OsString::new())).app(),
|
||||
resources_dir_impl(false, None, Some(OsString::new()))
|
||||
.app()
|
||||
.map(EncodedOs::as_encoded),
|
||||
None
|
||||
);
|
||||
}
|
||||
@@ -254,13 +246,13 @@ mod tests {
|
||||
fn dev_fallback_finds_workspace_resources() {
|
||||
let exe = workspace_root().join("target/debug/ghostty");
|
||||
let r = resources_dir_impl(true, Some(&exe), None);
|
||||
assert!(r.app().is_some_and(|d| d.ends_with("/resources")));
|
||||
assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources")));
|
||||
// Outside the workspace there is no fallback.
|
||||
let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None);
|
||||
assert_eq!(r.app(), None);
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
|
||||
// Release builds never use it.
|
||||
let r = resources_dir_impl(false, Some(&exe), None);
|
||||
assert_eq!(r.app(), None);
|
||||
assert_eq!(r.app().map(EncodedOs::as_encoded), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -279,15 +271,4 @@ mod tests {
|
||||
let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None);
|
||||
assert_eq!(r.app_os().unwrap(), root.join("share/ghostty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maybe_dir_checks_suffix() {
|
||||
let td = make_tree();
|
||||
let base = td.path().to_string_lossy().into_owned();
|
||||
assert_eq!(
|
||||
maybe_dir(&base, "share", "terminfo/x/xterm-ghostty"),
|
||||
Some(format!("{base}/share"))
|
||||
);
|
||||
assert_eq!(maybe_dir(&base, "share", "terminfo/g/ghostty"), None);
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,12 @@
|
||||
|
||||
use std::io;
|
||||
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
/// `homedir.zig` is unavailable: nothing can be expanded.
|
||||
pub mod homedir {
|
||||
use crate::osbytes::EncodedOs;
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
pub enum ExpandError {
|
||||
#[error("home detection failed")]
|
||||
@@ -17,11 +21,11 @@ pub mod homedir {
|
||||
}
|
||||
|
||||
/// Paths without a leading `~/` pass through; others fail.
|
||||
pub fn expand_home(path: &str) -> Result<String, ExpandError> {
|
||||
if path.starts_with("~/") {
|
||||
pub fn expand_home(path: &EncodedOs) -> Result<EncodedOs, ExpandError> {
|
||||
if path.starts_with_plain("~/") {
|
||||
Err(ExpandError::HomeDetectionFailed)
|
||||
} else {
|
||||
Ok(path.to_owned())
|
||||
Ok(path.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +33,9 @@ pub mod homedir {
|
||||
pub mod passwd {
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct Entry {
|
||||
pub shell: Option<String>,
|
||||
pub home: Option<String>,
|
||||
pub name: Option<String>,
|
||||
pub shell: Option<crate::osbytes::EncodedOs>,
|
||||
pub home: Option<crate::osbytes::EncodedOs>,
|
||||
pub name: Option<crate::osbytes::EncodedOs>,
|
||||
}
|
||||
|
||||
/// Zig: `@compileError("passwd is not available for wasm")`.
|
||||
@@ -49,7 +53,7 @@ pub mod xdg {
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct Options<'a> {
|
||||
pub subdir: Option<&'a str>,
|
||||
pub home: Option<&'a str>,
|
||||
pub home: Option<&'a crate::osbytes::EncodedOs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
|
||||
@@ -58,15 +62,15 @@ pub mod xdg {
|
||||
Unavailable,
|
||||
}
|
||||
|
||||
pub fn config(_: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn config(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
|
||||
Err(Error::Unavailable)
|
||||
}
|
||||
|
||||
pub fn cache(_: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn cache(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
|
||||
Err(Error::Unavailable)
|
||||
}
|
||||
|
||||
pub fn state(_: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn state(_: &Options<'_>) -> Result<crate::osbytes::EncodedOs, Error> {
|
||||
Err(Error::Unavailable)
|
||||
}
|
||||
|
||||
@@ -78,17 +82,17 @@ pub mod xdg {
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ResourcesDir {
|
||||
pub app_path: Option<String>,
|
||||
pub host_path: Option<String>,
|
||||
pub app_path: Option<EncodedOs>,
|
||||
pub host_path: Option<EncodedOs>,
|
||||
}
|
||||
|
||||
impl ResourcesDir {
|
||||
pub fn app(&self) -> Option<&str> {
|
||||
self.app_path.as_deref()
|
||||
pub fn app(&self) -> Option<&EncodedOs> {
|
||||
self.app_path.as_ref()
|
||||
}
|
||||
|
||||
pub fn host(&self) -> Option<&str> {
|
||||
self.host_path.as_deref()
|
||||
pub fn host(&self) -> Option<&EncodedOs> {
|
||||
self.host_path.as_ref()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
use std::ffi::CStr;
|
||||
|
||||
use crate::{env::getenv_not_empty, homedir, path_join};
|
||||
use crate::{env::getenv_not_empty, homedir, osbytes::EncodedOs};
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct Options<'a> {
|
||||
@@ -11,10 +11,10 @@ pub struct Options<'a> {
|
||||
/// when building up the directory. This is commonly the application.
|
||||
pub subdir: Option<&'a str>,
|
||||
|
||||
/// The home directory for the user. If this is not set, we will attempt
|
||||
/// The home directory for the user (encoded OS string). If this is not set, we will attempt
|
||||
/// to look it up which is an expensive process. By setting this, you can
|
||||
/// avoid lookups.
|
||||
pub home: Option<&'a str>,
|
||||
pub home: Option<&'a EncodedOs>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -28,29 +28,25 @@ pub enum Error {
|
||||
}
|
||||
|
||||
/// Get the XDG user config directory.
|
||||
pub fn config(opts: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn config(opts: &Options<'_>) -> Result<EncodedOs, Error> {
|
||||
dir(opts, "XDG_CONFIG_HOME", ".config")
|
||||
}
|
||||
|
||||
/// Get the XDG cache directory.
|
||||
pub fn cache(opts: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn cache(opts: &Options<'_>) -> Result<EncodedOs, Error> {
|
||||
dir(opts, "XDG_CACHE_HOME", ".cache")
|
||||
}
|
||||
|
||||
/// Get the XDG state directory.
|
||||
pub fn state(opts: &Options<'_>) -> Result<String, Error> {
|
||||
pub fn state(opts: &Options<'_>) -> Result<EncodedOs, Error> {
|
||||
dir(opts, "XDG_STATE_HOME", ".local/state")
|
||||
}
|
||||
|
||||
/// Unified helper to get XDG directories that follow a common pattern.
|
||||
fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Error> {
|
||||
fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<EncodedOs, Error> {
|
||||
// If we have a cached home dir, use that.
|
||||
if let Some(home) = opts.home {
|
||||
return Ok(path_join(&[
|
||||
home,
|
||||
default_subdir,
|
||||
opts.subdir.unwrap_or(""),
|
||||
]));
|
||||
return Ok(home.join_plain(&[default_subdir, opts.subdir.unwrap_or("")]));
|
||||
}
|
||||
|
||||
// First check the env var. On Windows we treat `LOCALAPPDATA` as a
|
||||
@@ -61,7 +57,7 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
|
||||
if let Some(env) = env_value {
|
||||
// If we have a subdir, then we join it.
|
||||
if let Some(subdir) = opts.subdir {
|
||||
return Ok(path_join(&[&env, subdir]));
|
||||
return Ok(env.join_plain(&[subdir]));
|
||||
}
|
||||
|
||||
return Ok(env);
|
||||
@@ -69,11 +65,7 @@ fn dir(opts: &Options<'_>, env: &str, default_subdir: &str) -> Result<String, Er
|
||||
|
||||
// Get our home dir
|
||||
if let Ok(Some(home)) = homedir::home() {
|
||||
return Ok(path_join(&[
|
||||
&home,
|
||||
default_subdir,
|
||||
opts.subdir.unwrap_or(""),
|
||||
]));
|
||||
return Ok(home.join_plain(&[default_subdir, opts.subdir.unwrap_or("")]));
|
||||
}
|
||||
|
||||
Err(Error::NoHomeDir)
|
||||
@@ -110,7 +102,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cache_directory_paths() {
|
||||
let mock_home = "/Users/test";
|
||||
let mock_home = &EncodedOs::from_plain_text("/Users/test");
|
||||
|
||||
// Test base path
|
||||
let p = cache(&Options {
|
||||
@@ -129,7 +121,7 @@ mod tests {
|
||||
assert_eq!(p, "/Users/test/.cache/ghostty");
|
||||
}
|
||||
|
||||
type DirFn = fn(&Options<'_>) -> Result<String, Error>;
|
||||
type DirFn = fn(&Options<'_>) -> Result<EncodedOs, Error>;
|
||||
const CASES: [(&str, DirFn, &str); 3] = [
|
||||
("XDG_CONFIG_HOME", config, ".config"),
|
||||
("XDG_CACHE_HOME", cache, ".cache"),
|
||||
@@ -141,9 +133,9 @@ mod tests {
|
||||
let temp_home = "/tmp/ghostty-test-home";
|
||||
for (name, func, default_subdir) in CASES {
|
||||
temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || {
|
||||
let expected = path_join(&[temp_home, default_subdir]);
|
||||
let expected = crate::path_join(&[temp_home, default_subdir]);
|
||||
let actual = func(&Options::default()).unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(actual, expected.as_str());
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -153,13 +145,13 @@ mod tests {
|
||||
let temp_home = "/tmp/ghostty-test-home";
|
||||
for (name, func, default_subdir) in CASES {
|
||||
temp_env::with_vars([("HOME", Some(temp_home)), (name, Some(""))], || {
|
||||
let expected = path_join(&[temp_home, default_subdir, "ghostty"]);
|
||||
let expected = crate::path_join(&[temp_home, default_subdir, "ghostty"]);
|
||||
let actual = func(&Options {
|
||||
subdir: Some("ghostty"),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(expected, actual);
|
||||
assert_eq!(actual, expected.as_str());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
w,
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
let mut env = ghostty_os::env::EnvMap::default();
|
||||
env.insert("FOO", "bar");
|
||||
cmd.env = Some(env);
|
||||
@@ -84,7 +84,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
devnull(),
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
cmd.spawn().unwrap();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
cmd.wait_callback(move |r| tx.send(r).unwrap()).unwrap();
|
||||
@@ -99,7 +99,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
devnull(),
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
assert_eq!(cmd.spawn(), Err(Error::FlatpakSpawnFail));
|
||||
}
|
||||
|
||||
@@ -113,7 +113,7 @@ fn host_command_spawn_exit_status_and_signal() {
|
||||
devnull(),
|
||||
devnull(),
|
||||
);
|
||||
cmd.cwd = Some("/".into());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/"));
|
||||
let pid = cmd.spawn().unwrap();
|
||||
let handle = cmd.signal_handle();
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
@@ -244,8 +244,9 @@ impl DerivedConfig {
|
||||
.value
|
||||
.iter()
|
||||
.map(|p| match p {
|
||||
ConfigPath::Optional(s) => PathItem::Optional(s.clone()),
|
||||
ConfigPath::Required(s) => PathItem::Required(s.clone()),
|
||||
// Decode the config path (encoded OS string) here, once.
|
||||
ConfigPath::Optional(s) => PathItem::Optional(s.to_path()),
|
||||
ConfigPath::Required(s) => PathItem::Required(s.to_path()),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -858,7 +859,8 @@ impl<G: GraphicsApi> DrawState<G> {
|
||||
fn prep_background_image(&mut self) {
|
||||
// Then we try to load the background image if we have a path.
|
||||
if let Some(p) = &self.config.bg_image
|
||||
&& let Some(image) = load_background_image(p.path()).map(image::Image::Pending)
|
||||
&& let Some(image) =
|
||||
load_background_image(&p.path().to_path()).map(image::Image::Pending)
|
||||
{
|
||||
// If we have an existing background image, replace it.
|
||||
// Otherwise, set this as our background image directly.
|
||||
@@ -1094,16 +1096,17 @@ fn upload_background_image<G: GraphicsApi>(bg_image: &mut BgImageState<G>, gpu:
|
||||
|
||||
/// Decode a background image file (`prepBackgroundImage`). Failures are
|
||||
/// logged and result in no image, as in Zig.
|
||||
fn load_background_image(path: &str) -> Option<image::Pending> {
|
||||
fn load_background_image(path: &std::path::Path) -> Option<image::Pending> {
|
||||
let shown = path.display();
|
||||
// Read it (Max size of 4 GiB, for now.)
|
||||
let contents = match std::fs::read(path) {
|
||||
Ok(c) if u32::try_from(c.len()).is_ok() => c,
|
||||
Ok(_) => {
|
||||
log::warn!("background image file \"{path}\" is too large");
|
||||
log::warn!("background image file \"{shown}\" is too large");
|
||||
return None;
|
||||
}
|
||||
Err(err) => {
|
||||
log::warn!("error opening background image file \"{path}\": {err}");
|
||||
log::warn!("error opening background image file \"{shown}\": {err}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -1119,23 +1122,23 @@ fn load_background_image(path: &str) -> Option<image::Pending> {
|
||||
FileType::Png => match decode_png(&contents) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::warn!("error decoding png background image \"{path}\": {err}");
|
||||
log::warn!("error decoding png background image \"{shown}\": {err}");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
FileType::Jpeg => match decode_jpeg(&contents) {
|
||||
Ok(v) => v,
|
||||
Err(err) => {
|
||||
log::warn!("error decoding jpeg background image \"{path}\": {err}");
|
||||
log::warn!("error decoding jpeg background image \"{shown}\": {err}");
|
||||
return None;
|
||||
}
|
||||
},
|
||||
FileType::Unknown => {
|
||||
log::warn!("Cannot determine file type for background image file \"{path}\"!");
|
||||
log::warn!("Cannot determine file type for background image file \"{shown}\"!");
|
||||
return None;
|
||||
}
|
||||
f => {
|
||||
log::warn!("Unsupported file type {f:?} for background image file \"{path}\"!");
|
||||
log::warn!("Unsupported file type {f:?} for background image file \"{shown}\"!");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
@@ -1179,8 +1182,8 @@ fn detect_file_type(contents: &[u8]) -> FileType {
|
||||
}
|
||||
}
|
||||
|
||||
fn guess_file_type_from_extension(path: &str) -> FileType {
|
||||
let ext = std::path::Path::new(path)
|
||||
fn guess_file_type_from_extension(path: &std::path::Path) -> FileType {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(str::to_ascii_lowercase);
|
||||
@@ -3634,9 +3637,18 @@ mod tests {
|
||||
assert_eq!(detect_file_type(&[0xFF, 0xD8, 0xFF, 0xE0]), FileType::Jpeg);
|
||||
assert_eq!(detect_file_type(b"GIF89a.."), FileType::Gif);
|
||||
assert_eq!(detect_file_type(b"hello"), FileType::Unknown);
|
||||
assert_eq!(guess_file_type_from_extension("/a/b.JPG"), FileType::Jpeg);
|
||||
assert_eq!(guess_file_type_from_extension("/a/b.png"), FileType::Png);
|
||||
assert_eq!(guess_file_type_from_extension("/a/b"), FileType::Unknown);
|
||||
assert_eq!(
|
||||
guess_file_type_from_extension("/a/b.JPG".as_ref()),
|
||||
FileType::Jpeg
|
||||
);
|
||||
assert_eq!(
|
||||
guess_file_type_from_extension("/a/b.png".as_ref()),
|
||||
FileType::Png
|
||||
);
|
||||
assert_eq!(
|
||||
guess_file_type_from_extension("/a/b".as_ref()),
|
||||
FileType::Unknown
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3655,15 +3667,15 @@ mod tests {
|
||||
let path = dir.join("bg.png");
|
||||
std::fs::write(&path, &bytes).unwrap();
|
||||
|
||||
let img = load_background_image(path.to_str().unwrap()).expect("decoded");
|
||||
let img = load_background_image(&path).expect("decoded");
|
||||
assert_eq!((img.width, img.height), (2, 1));
|
||||
assert_eq!(img.data, [1, 2, 3, 255, 4, 5, 6, 128]);
|
||||
|
||||
// Missing / undecodable files are not an error, just no image.
|
||||
assert!(load_background_image(dir.join("missing.png").to_str().unwrap()).is_none());
|
||||
assert!(load_background_image(&dir.join("missing.png")).is_none());
|
||||
let bad = dir.join("bad.txt");
|
||||
std::fs::write(&bad, b"not an image").unwrap();
|
||||
assert!(load_background_image(bad.to_str().unwrap()).is_none());
|
||||
assert!(load_background_image(&bad).is_none());
|
||||
std::fs::remove_dir_all(&dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -80,9 +80,9 @@ pub enum Target {
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PathItem {
|
||||
/// A path that may be missing (`?path`); missing files are skipped.
|
||||
Optional(String),
|
||||
Optional(std::path::PathBuf),
|
||||
/// A path that must exist.
|
||||
Required(String),
|
||||
Required(std::path::PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -124,14 +124,14 @@ pub fn load_from_files(paths: &[PathItem], target: Target) -> Result<Vec<String>
|
||||
PathItem::Required(p) => (p, false),
|
||||
};
|
||||
|
||||
let shader = match load_from_file(&ghostty_os::osbytes::config_str_to_path(path), target) {
|
||||
let shader = match load_from_file(path, target) {
|
||||
Ok(s) => s,
|
||||
Err(Error::Io(e)) if optional && e.kind() == std::io::ErrorKind::NotFound => {
|
||||
continue;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
log::info!("loaded custom shader path={path}");
|
||||
log::info!("loaded custom shader path={}", path.display());
|
||||
list.push(shader);
|
||||
}
|
||||
|
||||
@@ -709,13 +709,13 @@ mod tests {
|
||||
let missing = dir.join("missing.glsl");
|
||||
|
||||
let items = [
|
||||
PathItem::Optional(missing.to_string_lossy().into_owned()),
|
||||
PathItem::Required(good.to_string_lossy().into_owned()),
|
||||
PathItem::Optional(missing.clone()),
|
||||
PathItem::Required(good.clone()),
|
||||
];
|
||||
let shaders = load_from_files(&items, Target::Glsl).unwrap();
|
||||
assert_eq!(shaders.len(), 1);
|
||||
|
||||
let items = [PathItem::Required(missing.to_string_lossy().into_owned())];
|
||||
let items = [PathItem::Required(missing.clone())];
|
||||
assert!(matches!(
|
||||
load_from_files(&items, Target::Glsl),
|
||||
Err(Error::Io(_))
|
||||
|
||||
@@ -560,14 +560,20 @@ fn contains_subslice(hay: &[u8], needle: &[u8]) -> bool {
|
||||
/// Returns true if path appears to be in a temporary directory.
|
||||
/// Copies logic from Kitty.
|
||||
fn is_path_in_temp_dir(path: &[u8]) -> bool {
|
||||
is_path_in_temp_dir_with(path, alloc_tmp_dir())
|
||||
}
|
||||
|
||||
/// [`is_path_in_temp_dir`] with an explicit temporary directory (an OS
|
||||
/// string: it may be non-UTF-8, so it is compared byte for byte).
|
||||
fn is_path_in_temp_dir_with(path: &[u8], tmp_dir: Option<std::ffi::OsString>) -> bool {
|
||||
if path.starts_with(b"/tmp") {
|
||||
return true;
|
||||
}
|
||||
if path.starts_with(b"/dev/shm") {
|
||||
return true;
|
||||
}
|
||||
if let Some(dir) = alloc_tmp_dir() {
|
||||
if path.starts_with(dir.as_bytes()) {
|
||||
if let Some(dir) = tmp_dir {
|
||||
if path.starts_with(dir.as_os_str().as_bytes()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -582,22 +588,23 @@ fn is_path_in_temp_dir(path: &[u8]) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp).
|
||||
/// `os/file.zig allocTmpDir` on POSIX (TMPDIR, TMP, then /tmp), as the raw
|
||||
/// OS string.
|
||||
#[cfg(not(windows))]
|
||||
#[allow(clippy::unnecessary_wraps)]
|
||||
fn alloc_tmp_dir() -> Option<String> {
|
||||
fn alloc_tmp_dir() -> Option<std::ffi::OsString> {
|
||||
for key in ["TMPDIR", "TMP"] {
|
||||
if let Some(v) = std::env::var_os(key) {
|
||||
return Some(v.to_string_lossy().into_owned());
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
Some("/tmp".to_owned())
|
||||
Some("/tmp".into())
|
||||
}
|
||||
|
||||
/// `os/file.zig allocTmpDir` on Windows (`TMP` only).
|
||||
#[cfg(windows)]
|
||||
fn alloc_tmp_dir() -> Option<String> {
|
||||
std::env::var_os("TMP").map(|v| v.to_string_lossy().into_owned())
|
||||
fn alloc_tmp_dir() -> Option<std::ffi::OsString> {
|
||||
std::env::var_os("TMP")
|
||||
}
|
||||
|
||||
/// Image represents a single fully loaded image.
|
||||
@@ -655,6 +662,23 @@ pub struct Rect {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn temp_dir_check_keeps_non_utf8_tmpdir_bytes() {
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
let dir = std::ffi::OsString::from_vec(b"/data/t\xffmp".to_vec());
|
||||
assert!(is_path_in_temp_dir_with(
|
||||
b"/data/t\xffmp/tty-graphics-protocol-x",
|
||||
Some(dir.clone())
|
||||
));
|
||||
// A lossy conversion would have turned the byte into U+FFFD.
|
||||
assert!(!is_path_in_temp_dir_with(
|
||||
b"/data/t\xef\xbf\xbdmp/tty-graphics-protocol-x",
|
||||
Some(dir)
|
||||
));
|
||||
assert!(!is_path_in_temp_dir_with(b"/data/x", None));
|
||||
}
|
||||
|
||||
use ghostty_vt::kitty::graphics_command::{Control, Transmission};
|
||||
|
||||
use super::*;
|
||||
|
||||
@@ -185,9 +185,9 @@ impl<'a> Handler<'a> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn color_operation(&mut self, requests: &osc_color::List) {
|
||||
for req in requests {
|
||||
match *req {
|
||||
fn color_operation(&mut self, requests: &osc_color::List<'_>) {
|
||||
for req in requests.iter() {
|
||||
match req {
|
||||
Request::Set(set) => match set.target {
|
||||
Target::Palette(i) => {
|
||||
self.terminal.flags.dirty.palette = true;
|
||||
@@ -231,10 +231,10 @@ impl<'a> Handler<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
fn kitty_color_operation(&mut self, request: &kitty::color::OSC) {
|
||||
fn kitty_color_operation(&mut self, request: &kitty::color::OSC<'_>) {
|
||||
use kitty::color::{Kind, Request as KReq, Special};
|
||||
for item in &request.list {
|
||||
match item {
|
||||
for item in request.list.iter() {
|
||||
match &item {
|
||||
KReq::Set { key, color } => match key {
|
||||
Kind::Palette(palette) => {
|
||||
self.terminal.flags.dirty.palette = true;
|
||||
|
||||
@@ -177,7 +177,7 @@ pub struct Command {
|
||||
/// Working directory to change to in the child process. If not set, the
|
||||
/// working directory of the calling process is preserved. Failure to
|
||||
/// change directory is ignored (as in Ghostty).
|
||||
pub cwd: Option<String>,
|
||||
pub cwd: Option<ghostty_os::osbytes::EncodedOs>,
|
||||
|
||||
/// The file handle to set for stdin/out/err. If this isn't set, we do
|
||||
/// nothing explicitly so it is up to the behavior of the operating system.
|
||||
@@ -317,8 +317,8 @@ impl Command {
|
||||
let prep = self.prepare()?;
|
||||
let cwd = self
|
||||
.cwd
|
||||
.as_deref()
|
||||
.and_then(|c| CString::new(config_str_to_bytes(c)).ok());
|
||||
.as_ref()
|
||||
.and_then(|c| CString::new(c.to_bytes()).ok());
|
||||
|
||||
let mut argv_ptrs: Vec<*const c_char> = prep.argv.iter().map(|c| c.as_ptr()).collect();
|
||||
argv_ptrs.push(std::ptr::null());
|
||||
@@ -872,7 +872,7 @@ mod tests {
|
||||
|
||||
let mut cmd = sh("pwd");
|
||||
cmd.stdout = Some(stdout.try_clone().unwrap().into());
|
||||
cmd.cwd = Some("/tmp".to_owned());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/tmp"));
|
||||
cmd.start().unwrap();
|
||||
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
|
||||
|
||||
@@ -894,7 +894,7 @@ mod tests {
|
||||
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.cwd = Some(ghostty_os::osbytes::EncodedOs::from_bytes(&raw));
|
||||
cmd.start().unwrap();
|
||||
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
|
||||
|
||||
@@ -915,7 +915,9 @@ mod tests {
|
||||
|
||||
let mut cmd = sh("echo ok");
|
||||
cmd.stdout = Some(stdout.try_clone().unwrap().into());
|
||||
cmd.cwd = Some("/definitely/not/a/dir".to_owned());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded(
|
||||
"/definitely/not/a/dir",
|
||||
));
|
||||
cmd.start().unwrap();
|
||||
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(0));
|
||||
assert_eq!("ok\n", read_all(stdout));
|
||||
@@ -933,7 +935,7 @@ mod tests {
|
||||
let mut cmd = Command::new("/not/a/binary", &["/not/a/binary", ""]);
|
||||
cmd.stdout = Some(stdout.try_clone().unwrap().into());
|
||||
cmd.stderr = Some(stderr.try_clone().unwrap().into());
|
||||
cmd.cwd = Some("/bin".to_owned());
|
||||
cmd.cwd = Some(ghostty_os::osbytes::EncodedOs::from_encoded("/bin"));
|
||||
cmd.start().unwrap();
|
||||
assert!(cmd.pid.is_some());
|
||||
assert_eq!(cmd.wait(true).unwrap(), Exit::Exited(1));
|
||||
|
||||
@@ -16,11 +16,13 @@ use std::{
|
||||
ptr,
|
||||
};
|
||||
|
||||
use ghostty_os::windows::{
|
||||
use ghostty_os::{
|
||||
osbytes::EncodedOs,
|
||||
windows::{
|
||||
CREATE_UNICODE_ENVIRONMENT,
|
||||
EXTENDED_STARTUPINFO_PRESENT,
|
||||
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
|
||||
to_wide_nul,
|
||||
},
|
||||
};
|
||||
use windows_sys::Win32::{
|
||||
Foundation::{HANDLE, TRUE, WAIT_FAILED},
|
||||
@@ -43,7 +45,7 @@ use windows_sys::Win32::{
|
||||
},
|
||||
};
|
||||
|
||||
use crate::windows_args::{self, create_windows_env_block, windows_create_command_line};
|
||||
use crate::windows_args::{self, create_windows_env_block, windows_create_command_line_wide};
|
||||
|
||||
/// Errors from [`Command::start`].
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
@@ -77,7 +79,7 @@ pub struct Command {
|
||||
|
||||
/// Working directory for the child. If not set, the working directory of
|
||||
/// the calling process is preserved.
|
||||
pub cwd: Option<String>,
|
||||
pub cwd: Option<ghostty_os::osbytes::EncodedOs>,
|
||||
|
||||
/// The file handles to set for stdin/out/err. If not set the NUL device
|
||||
/// is used. Ignored if `pseudo_console` is set.
|
||||
@@ -127,12 +129,21 @@ impl Command {
|
||||
/// available.
|
||||
#[allow(unsafe_code)]
|
||||
pub fn start(&mut self) -> Result<(), StartError> {
|
||||
let application_w = to_wide_nul(&self.path);
|
||||
let cwd_w = self.cwd.as_deref().map(to_wide_nul);
|
||||
// `path` and `args` are the config command's argv strings (encoded
|
||||
// OS strings); decode them to WTF-16 for the Windows API.
|
||||
let application_w = EncodedOs::from_encoded(&self.path).to_wide_nul();
|
||||
let cwd_w = self.cwd.as_ref().map(EncodedOs::to_wide_nul);
|
||||
let mut command_line_w = if self.args.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(to_wide_nul(&windows_create_command_line(&self.args)))
|
||||
let wide: Vec<Vec<u16>> = self
|
||||
.args
|
||||
.iter()
|
||||
.map(|a| EncodedOs::from_encoded(a).to_wide())
|
||||
.collect();
|
||||
let mut line = windows_create_command_line_wide(&wide);
|
||||
line.push(0);
|
||||
Some(line)
|
||||
};
|
||||
let mut env_w = match &self.env {
|
||||
Some(map) => Some(create_windows_env_block(map.iter())?),
|
||||
|
||||
@@ -32,7 +32,7 @@ use ghostty_config::{
|
||||
types_b::{ShellIntegration, ShellIntegrationFeatures},
|
||||
};
|
||||
use ghostty_datastruct::blocking_queue::Timeout;
|
||||
use ghostty_os::file::Rlimit;
|
||||
use ghostty_os::{file::Rlimit, osbytes::EncodedOs};
|
||||
use ghostty_renderer::size::{GridSize, ScreenSize};
|
||||
use ghostty_terminal::terminal::Terminal;
|
||||
use rustix::{
|
||||
@@ -107,8 +107,8 @@ pub struct Config {
|
||||
pub shell_integration: ShellIntegration,
|
||||
pub shell_integration_features: ShellIntegrationFeatures,
|
||||
pub cursor_blink: Option<bool>,
|
||||
pub working_directory: Option<String>,
|
||||
pub resources_dir: Option<String>,
|
||||
pub working_directory: Option<EncodedOs>,
|
||||
pub resources_dir: Option<EncodedOs>,
|
||||
pub term: String,
|
||||
|
||||
pub rt_pre_exec_info: RtPreExecInfo,
|
||||
@@ -158,8 +158,8 @@ impl Exec {
|
||||
}
|
||||
|
||||
/// The working directory the subprocess will be started in, if any.
|
||||
pub fn cwd(&self) -> Option<&str> {
|
||||
self.subprocess.cwd.as_deref()
|
||||
pub fn cwd(&self) -> Option<&EncodedOs> {
|
||||
self.subprocess.cwd.as_ref()
|
||||
}
|
||||
|
||||
/// The argv that will be executed.
|
||||
@@ -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(&ghostty_os::osbytes::config_str_to_bytes(cwd))
|
||||
&& let Err(err) = term.set_pwd(&cwd.to_bytes())
|
||||
{
|
||||
log::warn!("error setting initial pwd err={err:?}");
|
||||
}
|
||||
@@ -766,7 +766,7 @@ struct FlatpakProcess {
|
||||
}
|
||||
|
||||
struct Subprocess {
|
||||
cwd: Option<String>,
|
||||
cwd: Option<EncodedOs>,
|
||||
env: Option<EnvMap>,
|
||||
args: Vec<String>,
|
||||
grid_size: GridSize,
|
||||
@@ -790,11 +790,8 @@ impl Subprocess {
|
||||
|
||||
// If we have a resources dir then set our env var
|
||||
if let Some(dir) = &cfg.resources_dir {
|
||||
log::info!("found Ghostty resources dir: {dir}");
|
||||
env.insert(
|
||||
"GHOSTTY_RESOURCES_DIR",
|
||||
ghostty_os::osbytes::config_str_to_os(dir),
|
||||
);
|
||||
log::info!("found Ghostty resources dir: {}", dir.as_encoded());
|
||||
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
|
||||
}
|
||||
|
||||
// Set our TERM var. This is a bit complicated because we want to use
|
||||
@@ -923,7 +920,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", ghostty_os::osbytes::config_str_to_os(pwd));
|
||||
env.insert("PWD", pwd.to_os());
|
||||
}
|
||||
|
||||
Ok(Subprocess {
|
||||
@@ -1002,7 +999,7 @@ impl Subprocess {
|
||||
// If we can't access the cwd, then don't set any cwd and inherit.
|
||||
// This is important because our cwd can be set by the shell (OSC 7)
|
||||
// and we don't want to break new windows.
|
||||
let cwd: Option<String> = match &self.cwd {
|
||||
let cwd: Option<EncodedOs> = match &self.cwd {
|
||||
None => None,
|
||||
Some(proposed) if flatpak => {
|
||||
// Flatpak sandboxing prevents access to certain reserved paths
|
||||
@@ -1035,10 +1032,10 @@ impl Subprocess {
|
||||
}
|
||||
}
|
||||
Some(proposed) => {
|
||||
if std::path::Path::new(&ghostty_os::osbytes::config_str_to_os(proposed)).exists() {
|
||||
if proposed.to_path().exists() {
|
||||
Some(proposed.clone())
|
||||
} else {
|
||||
log::warn!("cannot access cwd, ignoring: {proposed}");
|
||||
log::warn!("cannot access cwd, ignoring: {}", proposed.as_encoded());
|
||||
None
|
||||
}
|
||||
}
|
||||
@@ -1188,10 +1185,17 @@ fn add_ghostty_path(env: &mut EnvMap) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else {
|
||||
add_ghostty_path_for(env, &exe);
|
||||
}
|
||||
|
||||
/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8
|
||||
/// directories).
|
||||
fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
|
||||
// Keep the exact bytes of the directory (it may be non-UTF-8).
|
||||
let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else {
|
||||
return;
|
||||
};
|
||||
log::debug!("appending ghostty bin to path dir={exe_dir}");
|
||||
log::debug!("appending ghostty bin to path dir={}", exe_dir.display());
|
||||
|
||||
// We always set this so that if the shell overwrites the path
|
||||
// scripts still have a way to find the Ghostty binary when
|
||||
@@ -1211,10 +1215,7 @@ fn add_ghostty_path(env: &mut EnvMap) {
|
||||
{
|
||||
return;
|
||||
}
|
||||
env.insert(
|
||||
"PATH",
|
||||
ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)),
|
||||
);
|
||||
env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir));
|
||||
} else {
|
||||
env.insert("PATH", exe_dir);
|
||||
}
|
||||
@@ -1222,8 +1223,8 @@ fn add_ghostty_path(env: &mut EnvMap) {
|
||||
|
||||
/// The `TERMINFO` directory for a resources dir (reversible config-string
|
||||
/// encoding in, raw OS string out).
|
||||
fn terminfo_dir(base: &str) -> std::ffi::OsString {
|
||||
let base = ghostty_os::osbytes::config_str_to_os(base);
|
||||
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString {
|
||||
let base = base.to_os();
|
||||
let own = std::path::Path::new(&base).join("terminfo");
|
||||
if own.join("x/xterm-ghostty").exists() {
|
||||
own.into_os_string()
|
||||
@@ -1238,9 +1239,9 @@ fn terminfo_dir(base: &str) -> std::ffi::OsString {
|
||||
|
||||
/// macOS: export additional data directories from our application bundle
|
||||
/// (`XDG_DATA_DIRS` and `MANPATH`).
|
||||
pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) {
|
||||
pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &EncodedOs) {
|
||||
let xdg_data_dir_key = "XDG_DATA_DIRS";
|
||||
let mut data_dir = ghostty_os::osbytes::config_str_to_os(resources_dir);
|
||||
let mut data_dir = resources_dir.to_os();
|
||||
data_dir.push("/..");
|
||||
let current = env.get(xdg_data_dir_key).map_or_else(
|
||||
|| std::ffi::OsString::from("/usr/local/share:/usr/share"),
|
||||
@@ -1252,7 +1253,7 @@ pub fn darwin_bundle_env(env: &mut EnvMap, resources_dir: &str) {
|
||||
);
|
||||
|
||||
let manpath_key = "MANPATH";
|
||||
let mut man_dir = ghostty_os::osbytes::config_str_to_os(resources_dir);
|
||||
let mut man_dir = resources_dir.to_os();
|
||||
man_dir.push("/../man");
|
||||
// Always append with colon in front, as it mean that if
|
||||
// `MANPATH` is empty, then it should be treated as an extra
|
||||
@@ -1439,11 +1440,13 @@ fn darwin_login_command(
|
||||
|
||||
let hush = match &passwd.home {
|
||||
Some(home) => {
|
||||
if std::fs::read_dir(home).is_err() {
|
||||
// The passwd home is an encoded OS string: decode it first.
|
||||
let home = home.to_path();
|
||||
if std::fs::read_dir(&home).is_err() {
|
||||
log::warn!("failed to open home dir, not checking for hushlogin");
|
||||
false
|
||||
} else {
|
||||
std::path::Path::new(home).join(".hushlogin").exists()
|
||||
home.join(".hushlogin").exists()
|
||||
}
|
||||
}
|
||||
None => false,
|
||||
@@ -1507,7 +1510,7 @@ fn darwin_login_command(
|
||||
args.push("-q".into());
|
||||
}
|
||||
args.push("-flp".into());
|
||||
args.push(username);
|
||||
args.push(username.into_encoded());
|
||||
|
||||
match command {
|
||||
// Direct args can be passed directly to login, since
|
||||
@@ -1552,11 +1555,64 @@ impl Drop for ThreadData {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ghostty_path_keeps_non_utf8_exe_dir_bytes() {
|
||||
use std::os::unix::ffi::{OsStrExt, OsStringExt};
|
||||
let exe = std::path::PathBuf::from(std::ffi::OsString::from_vec(
|
||||
b"/opt/gh\xffostty/bin/ghostty".to_vec(),
|
||||
));
|
||||
|
||||
// No PATH: PATH becomes the directory itself.
|
||||
let mut env = EnvMap::new();
|
||||
add_ghostty_path_for(&mut env, &exe);
|
||||
assert_eq!(
|
||||
env.get("GHOSTTY_BIN_DIR").unwrap().as_bytes(),
|
||||
b"/opt/gh\xffostty/bin"
|
||||
);
|
||||
assert_eq!(env.get("PATH").unwrap().as_bytes(), b"/opt/gh\xffostty/bin");
|
||||
|
||||
// Existing PATH: appended byte for byte (no U+FFFD), and not twice.
|
||||
let mut env = EnvMap::new();
|
||||
env.insert("PATH", "/usr/bin");
|
||||
add_ghostty_path_for(&mut env, &exe);
|
||||
assert_eq!(
|
||||
env.get("PATH").unwrap().as_bytes(),
|
||||
b"/usr/bin:/opt/gh\xffostty/bin"
|
||||
);
|
||||
add_ghostty_path_for(&mut env, &exe);
|
||||
assert_eq!(
|
||||
env.get("PATH").unwrap().as_bytes(),
|
||||
b"/usr/bin:/opt/gh\xffostty/bin"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exec_command_darwin_hushlogin_with_non_utf8_home() {
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
let tmp = ghostty_os::TempDir::init().unwrap();
|
||||
let home = tmp.path().join(std::ffi::OsStr::from_bytes(b"h\xffome"));
|
||||
std::fs::create_dir_all(&home).unwrap();
|
||||
std::fs::write(home.join(".hushlogin"), "").unwrap();
|
||||
// The passwd entry carries the *encoded* home; it must be decoded
|
||||
// before the lookup.
|
||||
let enc = EncodedOs::from_path(&home);
|
||||
assert_ne!(enc.as_encoded().as_bytes(), home.as_os_str().as_bytes());
|
||||
let passwd = move || {
|
||||
Ok(ghostty_os::passwd::Entry {
|
||||
name: Some(EncodedOs::from_encoded("u")),
|
||||
home: Some(enc.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
let result = exec_command_with(&ConfigCommand::Direct(vec!["x".into()]), true, &passwd);
|
||||
assert_eq!(result, ["/usr/bin/login", "-q", "-flp", "u", "x"]);
|
||||
}
|
||||
|
||||
fn pw(name: Option<&str>) -> impl Fn() -> std::io::Result<ghostty_os::passwd::Entry> {
|
||||
let name = name.map(str::to_owned);
|
||||
move || {
|
||||
Ok(ghostty_os::passwd::Entry {
|
||||
name: name.clone(),
|
||||
name: name.clone().map(EncodedOs::from_encoded),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
@@ -1602,10 +1658,10 @@ mod tests {
|
||||
let dir = std::env::temp_dir().join(format!("ghostty-hush-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join(".hushlogin"), "").unwrap();
|
||||
let home = dir.to_str().unwrap().to_owned();
|
||||
let home = EncodedOs::from_path(&dir);
|
||||
let passwd = move || {
|
||||
Ok(ghostty_os::passwd::Entry {
|
||||
name: Some("u".into()),
|
||||
name: Some(EncodedOs::from_encoded("u")),
|
||||
home: Some(home.clone()),
|
||||
..Default::default()
|
||||
})
|
||||
|
||||
@@ -35,6 +35,7 @@ use ghostty_config::{
|
||||
types_b::{ShellIntegration, ShellIntegrationFeatures},
|
||||
};
|
||||
use ghostty_datastruct::blocking_queue::Timeout;
|
||||
use ghostty_os::osbytes::EncodedOs;
|
||||
use ghostty_renderer::size::{GridSize, ScreenSize};
|
||||
use ghostty_terminal::terminal::Terminal;
|
||||
use windows_sys::Win32::{
|
||||
@@ -99,8 +100,8 @@ pub struct Config {
|
||||
pub shell_integration: ShellIntegration,
|
||||
pub shell_integration_features: ShellIntegrationFeatures,
|
||||
pub cursor_blink: Option<bool>,
|
||||
pub working_directory: Option<String>,
|
||||
pub resources_dir: Option<String>,
|
||||
pub working_directory: Option<EncodedOs>,
|
||||
pub resources_dir: Option<EncodedOs>,
|
||||
pub term: String,
|
||||
}
|
||||
|
||||
@@ -136,8 +137,8 @@ impl Exec {
|
||||
}
|
||||
|
||||
/// The working directory the subprocess will be started in, if any.
|
||||
pub fn cwd(&self) -> Option<&str> {
|
||||
self.subprocess.cwd.as_deref()
|
||||
pub fn cwd(&self) -> Option<&EncodedOs> {
|
||||
self.subprocess.cwd.as_ref()
|
||||
}
|
||||
|
||||
/// The argv that will be executed.
|
||||
@@ -157,7 +158,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(&cwd.to_bytes())
|
||||
{
|
||||
log::warn!("error setting initial pwd err={err:?}");
|
||||
}
|
||||
@@ -583,7 +584,7 @@ fn write_thread_main(handle: &OwnedHandle, rx: &Receiver<Vec<u8>>) {
|
||||
// Subprocess
|
||||
|
||||
struct Subprocess {
|
||||
cwd: Option<String>,
|
||||
cwd: Option<EncodedOs>,
|
||||
env: Option<EnvMap>,
|
||||
args: Vec<String>,
|
||||
grid_size: GridSize,
|
||||
@@ -602,8 +603,10 @@ impl Subprocess {
|
||||
|
||||
// If we have a resources dir then set our env var
|
||||
if let Some(dir) = &cfg.resources_dir {
|
||||
log::info!("found Ghostty resources dir: {dir}");
|
||||
env.insert("GHOSTTY_RESOURCES_DIR", dir.clone());
|
||||
log::info!("found Ghostty resources dir: {}", dir.as_encoded());
|
||||
// The resources dir is an encoded OS string: decode it for the
|
||||
// child's environment.
|
||||
env.insert("GHOSTTY_RESOURCES_DIR", dir.to_os());
|
||||
}
|
||||
|
||||
// Set our TERM var. This is a bit complicated because we want to use
|
||||
@@ -615,10 +618,7 @@ impl Subprocess {
|
||||
|
||||
// Assume that the resources directory is adjacent to the terminfo
|
||||
// database
|
||||
let parent = std::path::Path::new(base)
|
||||
.parent()
|
||||
.map_or_else(String::new, |p| p.to_string_lossy().into_owned());
|
||||
env.insert("TERMINFO", format!("{parent}\\terminfo"));
|
||||
env.insert("TERMINFO", terminfo_dir(base));
|
||||
} else {
|
||||
env.insert("TERM", "xterm-256color");
|
||||
env.insert("COLORTERM", "truecolor");
|
||||
@@ -693,7 +693,10 @@ impl Subprocess {
|
||||
|
||||
// Build our args list. If we fail to find the shell, we'd still like
|
||||
// to try to run something so Ghostty can launch.
|
||||
let args = match exec_command(&shell_command, std::env::var("WINDIR").ok().as_deref()) {
|
||||
// %WINDIR% may hold lone surrogates: carry it in the encoded form the
|
||||
// argv strings use (decoded to WTF-16 when the process is created).
|
||||
let windir = std::env::var_os("WINDIR").map(|v| EncodedOs::from_os(&v).into_encoded());
|
||||
let args = match exec_command(&shell_command, windir.as_deref()) {
|
||||
Ok(args) => args,
|
||||
Err(Error::SystemError) => {
|
||||
log::warn!("failed to get WINDIR, cannot run shell command");
|
||||
@@ -708,7 +711,7 @@ impl Subprocess {
|
||||
|
||||
// Propagate the current working directory (CWD) to the shell.
|
||||
if let Some(pwd) = &cwd {
|
||||
env.insert("PWD", pwd.clone());
|
||||
env.insert("PWD", pwd.to_os());
|
||||
}
|
||||
|
||||
Ok(Subprocess {
|
||||
@@ -777,9 +780,9 @@ impl Subprocess {
|
||||
// If we can't access the cwd, then don't set any cwd and inherit.
|
||||
// This is important because our cwd can be set by the shell (OSC 7)
|
||||
// and we don't want to break new windows.
|
||||
let cwd: Option<String> = match &self.cwd {
|
||||
let cwd: Option<EncodedOs> = match &self.cwd {
|
||||
None => None,
|
||||
Some(proposed) => match std::fs::metadata(proposed) {
|
||||
Some(proposed) => match std::fs::metadata(proposed.to_path()) {
|
||||
Ok(_) => Some(proposed.clone()),
|
||||
Err(err) => {
|
||||
log::warn!("cannot access cwd, ignoring: {err}");
|
||||
@@ -852,10 +855,17 @@ fn add_ghostty_path(env: &mut EnvMap) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Some(exe_dir) = exe.parent().map(|p| p.to_string_lossy().into_owned()) else {
|
||||
add_ghostty_path_for(env, &exe);
|
||||
}
|
||||
|
||||
/// [`add_ghostty_path`] for a given executable (testable with non-UTF-8
|
||||
/// directories).
|
||||
fn add_ghostty_path_for(env: &mut EnvMap, exe: &std::path::Path) {
|
||||
// Keep the exact OS string (it may hold lone UTF-16 surrogates).
|
||||
let Some(exe_dir) = exe.parent().map(|p| p.as_os_str().to_owned()) else {
|
||||
return;
|
||||
};
|
||||
log::debug!("appending ghostty bin to path dir={exe_dir}");
|
||||
log::debug!("appending ghostty bin to path dir={}", exe_dir.display());
|
||||
|
||||
// We always set this so that if the shell overwrites the path
|
||||
// scripts still have a way to find the Ghostty binary when
|
||||
@@ -868,19 +878,27 @@ fn add_ghostty_path(env: &mut EnvMap) {
|
||||
if let Some(path) = env.get("PATH").map(std::ffi::OsStr::to_owned) {
|
||||
// Verify that our path doesn't already contain this entry
|
||||
if std::env::split_paths(&path)
|
||||
.any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_str())
|
||||
.any(|entry| !entry.as_os_str().is_empty() && entry.as_os_str() == exe_dir.as_os_str())
|
||||
{
|
||||
return;
|
||||
}
|
||||
env.insert(
|
||||
"PATH",
|
||||
ghostty_os::env::append_env_os(&path, std::ffi::OsStr::new(&exe_dir)),
|
||||
);
|
||||
env.insert("PATH", ghostty_os::env::append_env_os(&path, &exe_dir));
|
||||
} else {
|
||||
env.insert("PATH", exe_dir);
|
||||
}
|
||||
}
|
||||
|
||||
/// The `TERMINFO` value for a resources dir: `<parent of base>\\terminfo` as
|
||||
/// an OS string (the resources dir is an encoded OS string).
|
||||
fn terminfo_dir(base: &EncodedOs) -> std::ffi::OsString {
|
||||
let base = base.to_path();
|
||||
let mut out = base
|
||||
.parent()
|
||||
.map_or_else(std::ffi::OsString::new, |p| p.as_os_str().to_owned());
|
||||
out.push("\\terminfo");
|
||||
out
|
||||
}
|
||||
|
||||
/// Terminate the underlying subprocess and wait for it to exit
|
||||
/// (`killCommand`: `TerminateProcess(pid, 0)` then wait).
|
||||
fn kill_command(command: &Command) -> Result<(), Error> {
|
||||
@@ -908,6 +926,23 @@ pub fn exec_command(command: &ConfigCommand, windir: Option<&str>) -> Result<Vec
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn terminfo_dir_is_sibling_of_resources_and_lossless() {
|
||||
use std::os::windows::ffi::{OsStrExt, OsStringExt};
|
||||
|
||||
// C:\Users\<lone surrogate>\share\ghostty -> ...\share\terminfo
|
||||
let mut wide: Vec<u16> = "C:\\Users\\".encode_utf16().collect();
|
||||
wide.push(0xD800);
|
||||
wide.extend("\\share\\ghostty".encode_utf16());
|
||||
let base = EncodedOs::from_wide(&wide);
|
||||
let got = super::terminfo_dir(&base);
|
||||
let mut want: Vec<u16> = "C:\\Users\\".encode_utf16().collect();
|
||||
want.push(0xD800);
|
||||
want.extend("\\share\\terminfo".encode_utf16());
|
||||
assert_eq!(got.encode_wide().collect::<Vec<_>>(), want);
|
||||
assert_eq!(std::ffi::OsString::from_wide(&want), got);
|
||||
}
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use ghostty_os::{env::prepend_env_os, homedir, shell::ShellCommandBuilder};
|
||||
use ghostty_os::{env::prepend_env_os, homedir, osbytes::EncodedOs, shell::ShellCommandBuilder};
|
||||
|
||||
/// The environment passed to the child (Zig `std.process.EnvMap`).
|
||||
pub type EnvMap = ghostty_os::EnvMap;
|
||||
@@ -65,8 +65,8 @@ pub struct ShellIntegration {
|
||||
/// `<resource_dir>/<rel>` as an OS string. `resource_dir` carries the
|
||||
/// reversible config-string encoding (it may hold non-UTF-8 bytes), so it is
|
||||
/// decoded before reaching the filesystem or the child environment.
|
||||
fn resource_path(resource_dir: &str, rel: &str) -> std::ffi::OsString {
|
||||
let mut p = ghostty_os::osbytes::config_str_to_os(resource_dir);
|
||||
fn resource_path(resource_dir: &EncodedOs, rel: &str) -> std::ffi::OsString {
|
||||
let mut p = resource_dir.to_os();
|
||||
p.push("/");
|
||||
p.push(rel);
|
||||
p
|
||||
@@ -77,7 +77,7 @@ fn resource_path(resource_dir: &str, rel: &str) -> std::ffi::OsString {
|
||||
/// struct describing the integration. If integration fails
|
||||
/// (shell type couldn't be detected, etc.), this will return None.
|
||||
pub fn setup(
|
||||
resource_dir: &str,
|
||||
resource_dir: &EncodedOs,
|
||||
command: &Command,
|
||||
env: &mut EnvMap,
|
||||
force_shell: Option<Shell>,
|
||||
@@ -185,7 +185,7 @@ pub fn setup_features(env: &mut EnvMap, features: ShellIntegrationFeatures, curs
|
||||
/// or None if integration failed.
|
||||
fn setup_bash(
|
||||
command: &Command,
|
||||
resource_dir: &str,
|
||||
resource_dir: &EncodedOs,
|
||||
env: &mut EnvMap,
|
||||
) -> Result<Option<Command>, Error> {
|
||||
let mut cmd = ShellCommandBuilder::new();
|
||||
@@ -271,7 +271,10 @@ fn setup_bash(
|
||||
if !env.contains_key("HISTFILE")
|
||||
&& let Some(home) = homedir::home()?
|
||||
{
|
||||
env.insert("HISTFILE", format!("{home}/.bash_history"));
|
||||
// `home` is an encoded OS string: build the real OS value.
|
||||
let mut histfile = home.to_os();
|
||||
histfile.push("/.bash_history");
|
||||
env.insert("HISTFILE", histfile);
|
||||
env.insert("GHOSTTY_BASH_UNEXPORT_HISTFILE", "1");
|
||||
}
|
||||
|
||||
@@ -286,7 +289,7 @@ fn setup_bash(
|
||||
/// It is also saved in the `GHOSTTY_SHELL_INTEGRATION_XDG_DIR` variable
|
||||
/// so that the shell can refer to it and safely remove this directory
|
||||
/// from `XDG_DATA_DIRS` when integration is complete.
|
||||
fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool {
|
||||
fn setup_xdg_data_dirs(resource_dir: &EncodedOs, env: &mut EnvMap) -> bool {
|
||||
// Get our path to the shell integration directory.
|
||||
let integ_path = resource_path(resource_dir, "shell-integration");
|
||||
if let Err(err) = std::fs::read_dir(&integ_path) {
|
||||
@@ -319,7 +322,7 @@ fn setup_xdg_data_dirs(resource_dir: &str, env: &mut EnvMap) -> bool {
|
||||
///
|
||||
/// We then add `--execute 'use ghostty ...'` to the nu command line to
|
||||
/// automatically enable our shell features.
|
||||
fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option<Command> {
|
||||
fn setup_nushell(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> Option<Command> {
|
||||
// Add our XDG_DATA_DIRS entry (for nushell/vendor/autoload/). This
|
||||
// makes our 'ghostty' module automatically available, even if any
|
||||
// of the later checks abort the rest of our automatic integration.
|
||||
@@ -377,7 +380,7 @@ fn setup_nushell(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Opt
|
||||
/// Setup the zsh automatic shell integration. This works by setting
|
||||
/// ZDOTDIR to our resources dir so that zsh will load our config. This
|
||||
/// config then loads the true user config.
|
||||
fn setup_zsh(command: &Command, resource_dir: &str, env: &mut EnvMap) -> Option<Command> {
|
||||
fn setup_zsh(command: &Command, resource_dir: &EncodedOs, env: &mut EnvMap) -> Option<Command> {
|
||||
// Preserve an existing ZDOTDIR value. We're about to overwrite it.
|
||||
if let Some(old) = env.get("ZDOTDIR").map(std::ffi::OsStr::to_owned) {
|
||||
env.insert("GHOSTTY_ZSH_ZDOTDIR", old);
|
||||
@@ -419,6 +422,10 @@ mod tests {
|
||||
}
|
||||
|
||||
impl TmpResourcesDir {
|
||||
fn enc(&self) -> EncodedOs {
|
||||
EncodedOs::from_plain_text(&self.path)
|
||||
}
|
||||
|
||||
fn new(shell: Shell) -> Self {
|
||||
let tmp = TempDir::init().unwrap();
|
||||
let path = std::fs::canonicalize(tmp.path())
|
||||
@@ -455,7 +462,7 @@ mod tests {
|
||||
let mut env = EnvMap::new();
|
||||
for shell in Shell::ALL {
|
||||
let res = TmpResourcesDir::new(shell);
|
||||
let result = setup(&res.path, &sh("sh"), &mut env, Some(shell)).unwrap();
|
||||
let result = setup(&res.enc(), &sh("sh"), &mut env, Some(shell)).unwrap();
|
||||
assert_eq!(shell, result.unwrap().shell);
|
||||
}
|
||||
}
|
||||
@@ -463,7 +470,13 @@ mod tests {
|
||||
#[test]
|
||||
fn shell_integration_failure() {
|
||||
let mut env = EnvMap::new();
|
||||
let result = setup("/nonexistent", &sh("sh"), &mut env, None).unwrap();
|
||||
let result = setup(
|
||||
&EncodedOs::from_encoded("/nonexistent"),
|
||||
&sh("sh"),
|
||||
&mut env,
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(result.is_none());
|
||||
assert_eq!(0, env.len());
|
||||
}
|
||||
@@ -585,7 +598,7 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Bash);
|
||||
let mut env = EnvMap::new();
|
||||
|
||||
let command = setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!("bash --posix", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
"1",
|
||||
@@ -609,7 +622,7 @@ mod tests {
|
||||
] {
|
||||
let mut env = EnvMap::new();
|
||||
assert!(
|
||||
setup_bash(&sh(cmdline), &res.path, &mut env)
|
||||
setup_bash(&sh(cmdline), &res.enc(), &mut env)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -622,7 +635,7 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Bash);
|
||||
|
||||
let mut env = EnvMap::new();
|
||||
let command = setup_bash(&sh("bash --norc"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash --norc"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!("bash --posix", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
"1 --norc",
|
||||
@@ -630,7 +643,7 @@ mod tests {
|
||||
);
|
||||
|
||||
let mut env = EnvMap::new();
|
||||
let command = setup_bash(&sh("bash --noprofile"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash --noprofile"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!("bash --posix", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
"1 --noprofile",
|
||||
@@ -643,14 +656,14 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Bash);
|
||||
let mut env = EnvMap::new();
|
||||
|
||||
let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash --rcfile profile.sh"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!("bash --posix", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
"profile.sh",
|
||||
env.get("GHOSTTY_BASH_RCFILE").unwrap().to_str().unwrap()
|
||||
);
|
||||
|
||||
let command = setup_bash(&sh("bash --init-file profile.sh"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash --init-file profile.sh"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!("bash --posix", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
"profile.sh",
|
||||
@@ -665,7 +678,7 @@ mod tests {
|
||||
// HISTFILE unset (needs a home dir; set HOME so it is deterministic)
|
||||
temp_env::with_var("HOME", Some("/home/test"), || {
|
||||
let mut env = EnvMap::new();
|
||||
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
|
||||
setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
|
||||
assert!(
|
||||
env.get("HISTFILE")
|
||||
.unwrap()
|
||||
@@ -685,7 +698,7 @@ mod tests {
|
||||
// HISTFILE set
|
||||
let mut env = EnvMap::new();
|
||||
env.insert("HISTFILE", "my_history");
|
||||
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
|
||||
setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!("my_history", env.get("HISTFILE").unwrap().to_str().unwrap());
|
||||
assert!(!env.contains_key("GHOSTTY_BASH_UNEXPORT_HISTFILE"));
|
||||
}
|
||||
@@ -696,7 +709,7 @@ mod tests {
|
||||
let mut env = EnvMap::new();
|
||||
env.insert("ENV", "env.sh");
|
||||
|
||||
setup_bash(&sh("bash"), &res.path, &mut env).unwrap();
|
||||
setup_bash(&sh("bash"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!(
|
||||
"env.sh",
|
||||
env.get("GHOSTTY_BASH_ENV").unwrap().to_str().unwrap()
|
||||
@@ -712,13 +725,13 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Bash);
|
||||
let mut env = EnvMap::new();
|
||||
|
||||
let command = setup_bash(&sh("bash - --arg file1 file2"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash - --arg file1 file2"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!(
|
||||
"bash --posix - --arg file1 file2",
|
||||
shell_str(command.as_ref())
|
||||
);
|
||||
|
||||
let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.path, &mut env).unwrap();
|
||||
let command = setup_bash(&sh("bash -- --arg file1 file2"), &res.enc(), &mut env).unwrap();
|
||||
assert_eq!(
|
||||
"bash --posix -- --arg file1 file2",
|
||||
shell_str(command.as_ref())
|
||||
@@ -731,7 +744,7 @@ mod tests {
|
||||
let dir = std::fs::canonicalize(tmp.path()).unwrap();
|
||||
let mut env = EnvMap::new();
|
||||
assert!(
|
||||
setup_bash(&sh("bash"), dir.to_str().unwrap(), &mut env)
|
||||
setup_bash(&sh("bash"), &EncodedOs::from_path(&dir), &mut env)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
@@ -743,7 +756,7 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Fish);
|
||||
let mut env = EnvMap::new();
|
||||
|
||||
assert!(setup_xdg_data_dirs(&res.path, &mut env));
|
||||
assert!(setup_xdg_data_dirs(&res.enc(), &mut env));
|
||||
assert_eq!(
|
||||
format!("{}/shell-integration", res.path),
|
||||
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR")
|
||||
@@ -763,7 +776,7 @@ mod tests {
|
||||
let mut env = EnvMap::new();
|
||||
env.insert("XDG_DATA_DIRS", "/opt/share");
|
||||
|
||||
assert!(setup_xdg_data_dirs(&res.path, &mut env));
|
||||
assert!(setup_xdg_data_dirs(&res.enc(), &mut env));
|
||||
assert_eq!(
|
||||
format!("{}/shell-integration", res.path),
|
||||
env.get("GHOSTTY_SHELL_INTEGRATION_XDG_DIR")
|
||||
@@ -782,7 +795,7 @@ mod tests {
|
||||
let tmp = TempDir::init().unwrap();
|
||||
let dir = std::fs::canonicalize(tmp.path()).unwrap();
|
||||
let mut env = EnvMap::new();
|
||||
assert!(!setup_xdg_data_dirs(dir.to_str().unwrap(), &mut env));
|
||||
assert!(!setup_xdg_data_dirs(&EncodedOs::from_path(&dir), &mut env));
|
||||
assert_eq!(0, env.len());
|
||||
}
|
||||
|
||||
@@ -791,7 +804,7 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Nushell);
|
||||
let mut env = EnvMap::new();
|
||||
|
||||
let command = setup_nushell(&sh("nu"), &res.path, &mut env);
|
||||
let command = setup_nushell(&sh("nu"), &res.enc(), &mut env);
|
||||
assert_eq!("nu --execute 'use ghostty *'", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
format!("{}/shell-integration", res.path),
|
||||
@@ -819,7 +832,7 @@ mod tests {
|
||||
"nu -ic script.sh",
|
||||
] {
|
||||
let mut env = EnvMap::new();
|
||||
assert!(setup_nushell(&sh(cmdline), &res.path, &mut env).is_none());
|
||||
assert!(setup_nushell(&sh(cmdline), &res.enc(), &mut env).is_none());
|
||||
assert!(env.contains_key("XDG_DATA_DIRS"));
|
||||
assert!(env.contains_key("GHOSTTY_SHELL_INTEGRATION_XDG_DIR"));
|
||||
}
|
||||
@@ -830,7 +843,7 @@ mod tests {
|
||||
let tmp = TempDir::init().unwrap();
|
||||
let dir = std::fs::canonicalize(tmp.path()).unwrap();
|
||||
let mut env = EnvMap::new();
|
||||
assert!(setup_nushell(&sh("nu"), dir.to_str().unwrap(), &mut env).is_none());
|
||||
assert!(setup_nushell(&sh("nu"), &EncodedOs::from_path(&dir), &mut env).is_none());
|
||||
assert_eq!(0, env.len());
|
||||
}
|
||||
|
||||
@@ -839,7 +852,7 @@ mod tests {
|
||||
let res = TmpResourcesDir::new(Shell::Zsh);
|
||||
let mut env = EnvMap::new();
|
||||
|
||||
let command = setup_zsh(&sh("zsh"), &res.path, &mut env);
|
||||
let command = setup_zsh(&sh("zsh"), &res.enc(), &mut env);
|
||||
assert_eq!("zsh", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
res.shell_path,
|
||||
@@ -854,7 +867,7 @@ mod tests {
|
||||
let mut env = EnvMap::new();
|
||||
env.insert("ZDOTDIR", "$HOME/.config/zsh");
|
||||
|
||||
let command = setup_zsh(&sh("zsh"), &res.path, &mut env);
|
||||
let command = setup_zsh(&sh("zsh"), &res.enc(), &mut env);
|
||||
assert_eq!("zsh", shell_str(command.as_ref()));
|
||||
assert_eq!(
|
||||
res.shell_path,
|
||||
@@ -871,7 +884,7 @@ mod tests {
|
||||
let tmp = TempDir::init().unwrap();
|
||||
let dir = std::fs::canonicalize(tmp.path()).unwrap();
|
||||
let mut env = EnvMap::new();
|
||||
assert!(setup_zsh(&sh("zsh"), dir.to_str().unwrap(), &mut env).is_none());
|
||||
assert!(setup_zsh(&sh("zsh"), &EncodedOs::from_path(&dir), &mut env).is_none());
|
||||
assert_eq!(0, env.len());
|
||||
}
|
||||
|
||||
@@ -879,9 +892,11 @@ mod tests {
|
||||
fn bundled_resources_exist() {
|
||||
// The shipped scripts that setup() points shells at.
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../resources");
|
||||
let root = root.to_str().unwrap();
|
||||
let root_enc = EncodedOs::from_path(&root);
|
||||
let mut env = EnvMap::new();
|
||||
let r = setup(root, &sh("bash"), &mut env, None).unwrap().unwrap();
|
||||
let r = setup(&root_enc, &sh("bash"), &mut env, None)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(r.shell, Shell::Bash);
|
||||
assert!(
|
||||
env.get("ENV")
|
||||
@@ -891,8 +906,7 @@ mod tests {
|
||||
.ends_with("shell-integration/bash/ghostty.bash")
|
||||
);
|
||||
assert!(
|
||||
Path::new(root)
|
||||
.join("shell-integration/nushell/vendor/autoload/ghostty.nu")
|
||||
root.join("shell-integration/nushell/vendor/autoload/ghostty.nu")
|
||||
.is_file()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1022,7 +1022,7 @@ impl StreamHandler<'_> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn color_operation(&mut self, requests: &osc_color::List, terminator: Terminator) {
|
||||
fn color_operation(&mut self, requests: &osc_color::List<'_>, terminator: Terminator) {
|
||||
// return early if there is nothing to do
|
||||
if requests.is_empty() {
|
||||
return;
|
||||
@@ -1030,8 +1030,8 @@ impl StreamHandler<'_> {
|
||||
|
||||
let mut response: Vec<u8> = Vec::new();
|
||||
|
||||
for req in requests {
|
||||
match *req {
|
||||
for req in requests.iter() {
|
||||
match req {
|
||||
Request::Set(set) => {
|
||||
match set.target {
|
||||
Target::Palette(i) => {
|
||||
@@ -1216,13 +1216,13 @@ impl StreamHandler<'_> {
|
||||
}
|
||||
}
|
||||
|
||||
fn kitty_color_report(&mut self, request: &kitty::color::OSC) {
|
||||
fn kitty_color_report(&mut self, request: &kitty::color::OSC<'_>) {
|
||||
use kitty::color::{Kind, Request as KReq, Special};
|
||||
|
||||
let mut out: Vec<u8> = Vec::new();
|
||||
|
||||
for item in &request.list {
|
||||
match item {
|
||||
for item in request.list.iter() {
|
||||
match &item {
|
||||
KReq::Query(key) => {
|
||||
// If the writer buffer is empty, we need to write our prefix
|
||||
if out.is_empty() {
|
||||
|
||||
@@ -90,6 +90,48 @@ pub fn windows_create_command_line<S: AsRef<str>>(argv: &[S]) -> String {
|
||||
buf
|
||||
}
|
||||
|
||||
/// [`windows_create_command_line`] over UTF-16 arguments (which may hold
|
||||
/// lone surrogates, so they cannot be a `str`). The quoting rules are the
|
||||
/// same: only ASCII characters are significant, so the logic is identical on
|
||||
/// code units.
|
||||
pub fn windows_create_command_line_wide(argv: &[Vec<u16>]) -> Vec<u16> {
|
||||
let (sp, tab, nl, quote, bs) = (
|
||||
u16::from(b' '),
|
||||
u16::from(b'\t'),
|
||||
u16::from(b'\n'),
|
||||
u16::from(b'"'),
|
||||
u16::from(b'\\'),
|
||||
);
|
||||
let mut buf: Vec<u16> = Vec::new();
|
||||
for (arg_i, arg) in argv.iter().enumerate() {
|
||||
if arg_i != 0 {
|
||||
buf.push(sp);
|
||||
}
|
||||
if !arg.iter().any(|c| [sp, tab, nl, quote].contains(c)) {
|
||||
buf.extend_from_slice(arg);
|
||||
continue;
|
||||
}
|
||||
buf.push(quote);
|
||||
let mut backslash_count = 0usize;
|
||||
for &c in arg {
|
||||
if c == bs {
|
||||
backslash_count += 1;
|
||||
} else if c == quote {
|
||||
buf.extend(std::iter::repeat_n(bs, backslash_count * 2 + 1));
|
||||
buf.push(quote);
|
||||
backslash_count = 0;
|
||||
} else {
|
||||
buf.extend(std::iter::repeat_n(bs, backslash_count));
|
||||
buf.push(c);
|
||||
backslash_count = 0;
|
||||
}
|
||||
}
|
||||
buf.extend(std::iter::repeat_n(bs, backslash_count * 2));
|
||||
buf.push(quote);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// The argv for a `shell` command on Windows (`execCommand`): the command is
|
||||
/// run as `%WINDIR%\System32\cmd.exe /C <command>`.
|
||||
pub fn windows_shell_args(windir: &str, command: &str) -> Vec<String> {
|
||||
@@ -115,6 +157,25 @@ mod tests {
|
||||
windows_create_command_line(args)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_command_line_matches_str_version_and_keeps_lone_surrogates() {
|
||||
let w = |s: &str| s.encode_utf16().collect::<Vec<u16>>();
|
||||
for args in [
|
||||
vec!["a", "b c", "d\"e", "f\\", "g h\\"],
|
||||
vec!["cmd.exe", "/C", "echo \"hi\" there"],
|
||||
] {
|
||||
let wide: Vec<Vec<u16>> = args.iter().map(|a| w(a)).collect();
|
||||
assert_eq!(
|
||||
windows_create_command_line_wide(&wide),
|
||||
w(&windows_create_command_line(&args))
|
||||
);
|
||||
}
|
||||
// A lone surrogate inside a quoted argument is carried unchanged.
|
||||
let lone = vec![u16::from(b'a'), u16::from(b' '), 0xD800];
|
||||
let out = windows_create_command_line_wide(&[lone]);
|
||||
assert_eq!(out, [34, 97, 32, 0xD800, 34]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_args_use_windir_cmd() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -6,7 +6,11 @@ use std::{
|
||||
ptr::NonNull,
|
||||
};
|
||||
|
||||
use ghostty_vt::osc::{Command, Parser};
|
||||
use ghostty_vt::osc::{Command, Parser, color::RECORD_LEN};
|
||||
|
||||
/// Records the color request list holds without growing (Zig:
|
||||
/// `SegmentedList(Request, 4)`).
|
||||
const COLOR_LIST_INLINE_RECORDS: usize = 4;
|
||||
|
||||
use crate::{
|
||||
alloc::{Alloc, Allocator, CBlock, CByteStorage},
|
||||
@@ -70,9 +74,16 @@ pub unsafe extern "C" fn ghostty_osc_new(
|
||||
let Some(storage) = CByteStorage::new(alloc, ghostty_vt::osc::MAX_BUF) else {
|
||||
return GhosttyResult::OutOfMemory;
|
||||
};
|
||||
// Request lists of the color OSCs live in a second block from the same
|
||||
// allocator (Zig: a `SegmentedList(Request, 4)` on the parser's
|
||||
// allocator). Four inline records, like Zig, so short color commands
|
||||
// never allocate after creation; longer lists grow via the allocator.
|
||||
let Some(aux) = CByteStorage::new(alloc, COLOR_LIST_INLINE_RECORDS * RECORD_LEN) else {
|
||||
return GhosttyResult::OutOfMemory;
|
||||
};
|
||||
let Some(p) = alloc.create(OscParser {
|
||||
alloc,
|
||||
parser: Parser::with_storage(storage),
|
||||
parser: Parser::with_storage(storage, aux),
|
||||
command: OscCommand {
|
||||
key: 0,
|
||||
title_z: None,
|
||||
@@ -320,6 +331,16 @@ mod tests {
|
||||
("66;s=2;hi", false),
|
||||
("3008;start=abc;type=shell", false),
|
||||
("5522;type=read;data", false),
|
||||
// Color commands: the request lists live in the parser's
|
||||
// storage, which comes from the caller's allocator.
|
||||
("4;1;rgb:ff/00/00;2;?", false),
|
||||
("5;0;rgb:00/00/00", false),
|
||||
("10;rgb:ff/ff/ff;?", false),
|
||||
("11;?", false),
|
||||
("104;1;2;3;4", false),
|
||||
("104", false),
|
||||
("112", false),
|
||||
("21;foreground=?;background=rgb:f0/f8/ff;cursor", false),
|
||||
("0;a title", true),
|
||||
] {
|
||||
ghostty_osc_reset(p);
|
||||
@@ -346,6 +367,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn long_color_lists_grow_via_the_caller_allocator() {
|
||||
let caller_allocs = AtomicUsize::new(0);
|
||||
let a = Allocator {
|
||||
ctx: (&raw const caller_allocs).cast_mut().cast(),
|
||||
vtable: &raw const VT,
|
||||
};
|
||||
let mut p = std::ptr::null_mut();
|
||||
// SAFETY: valid allocator and out pointer.
|
||||
assert_eq!(
|
||||
unsafe { ghostty_osc_new(&raw const a, &mut p) },
|
||||
GhosttyResult::Success
|
||||
);
|
||||
// SAFETY: `p` is live for the whole block.
|
||||
unsafe {
|
||||
for osc in ["4;0;?;1;?;2;?;3;?;4;?;5;?", "21;1=?;2=?;3=?;4=?;5=?;6=?"] {
|
||||
ghostty_osc_reset(p);
|
||||
for b in osc.bytes() {
|
||||
ghostty_osc_next(p, b);
|
||||
}
|
||||
let global_before = global_allocs();
|
||||
let caller_before = caller_allocs.load(Ordering::SeqCst);
|
||||
let cmd = ghostty_osc_end(p, 0x07);
|
||||
assert_ne!(ghostty_osc_command_type(cmd), 0, "{osc}");
|
||||
assert_eq!(global_allocs(), global_before, "global allocation: {osc}");
|
||||
assert!(
|
||||
caller_allocs.load(Ordering::SeqCst) > caller_before,
|
||||
"list did not grow through the caller allocator: {osc}"
|
||||
);
|
||||
}
|
||||
ghostty_osc_free(p);
|
||||
}
|
||||
}
|
||||
|
||||
fn new() -> *mut OscParser {
|
||||
let mut p = std::ptr::null_mut();
|
||||
// SAFETY: valid out pointer, default allocator.
|
||||
|
||||
@@ -71,7 +71,7 @@ int main(void) {
|
||||
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
|
||||
GhosttyOscParser p;
|
||||
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
|
||||
assert(c.allocs >= 2); // the parser object and its buffer
|
||||
assert(c.allocs >= 3); // the parser object, its buffer and request storage
|
||||
assert(c.live >= 2048); // the fixed payload buffer is ours
|
||||
feed(p, "0;hello", 7);
|
||||
GhosttyOscCommand cmd = ghostty_osc_end(p, 0x07);
|
||||
@@ -88,7 +88,7 @@ int main(void) {
|
||||
|
||||
// 2. Creation failure at each allocation point is OUT_OF_MEMORY and leaks
|
||||
// nothing.
|
||||
for (size_t fail_after = 0; fail_after < 2; fail_after++) {
|
||||
for (size_t fail_after = 0; fail_after < 3; fail_after++) {
|
||||
Counter c = counter(0, fail_after);
|
||||
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
|
||||
GhosttyOscParser p = NULL;
|
||||
@@ -175,6 +175,16 @@ int main(void) {
|
||||
{"9;4;1;50", GHOSTTY_OSC_COMMAND_CONEMU_PROGRESS_REPORT},
|
||||
{"22;pointer", GHOSTTY_OSC_COMMAND_MOUSE_SHAPE},
|
||||
{"66;s=2;hi", GHOSTTY_OSC_COMMAND_KITTY_TEXT_SIZING},
|
||||
// Color commands keep their request lists in the parser's storage
|
||||
// (the caller's allocator), up to 4 requests without growing.
|
||||
{"4;1;rgb:ff/00/00;2;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"5;0;rgb:00/00/00", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"10;rgb:ff/ff/ff;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"11;?", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"104;1;2;3;4", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"104", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"112", GHOSTTY_OSC_COMMAND_COLOR_OPERATION},
|
||||
{"21;foreground=?;background=rgb:f0/f8/ff;cursor", GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL},
|
||||
};
|
||||
Counter c = counter(0, SIZE_MAX);
|
||||
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
|
||||
@@ -202,6 +212,35 @@ int main(void) {
|
||||
assert(c.allocs == c.frees);
|
||||
}
|
||||
|
||||
// 7. Longer color request lists grow through the caller's allocator (one
|
||||
// more block here), and fail like Zig's allocation failure (invalid
|
||||
// command) when the allocator refuses: the global allocator is never
|
||||
// used as a fallback.
|
||||
{
|
||||
static const char *const LONG4 = "4;0;?;1;?;2;?;3;?;4;?;5;?";
|
||||
static const char *const LONG21 = "21;1=?;2=?;3=?;4=?;5=?;6=?";
|
||||
for (int k = 0; k < 2; k++) {
|
||||
const char *osc = k ? LONG21 : LONG4;
|
||||
int type = k ? GHOSTTY_OSC_COMMAND_KITTY_COLOR_PROTOCOL : GHOSTTY_OSC_COMMAND_COLOR_OPERATION;
|
||||
Counter c = counter(0, SIZE_MAX);
|
||||
GhosttyAllocator a = {.ctx = &c, .vtable = &VT};
|
||||
GhosttyOscParser p;
|
||||
assert(ghostty_osc_new(&a, &p) == GHOSTTY_SUCCESS);
|
||||
size_t base = c.allocs;
|
||||
feed(p, osc, strlen(osc));
|
||||
assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == type);
|
||||
assert(c.allocs > base); // the list grew via the caller's allocator
|
||||
|
||||
ghostty_osc_reset(p);
|
||||
c.fail_after = c.allocs; // refuse any further allocation
|
||||
feed(p, osc, strlen(osc));
|
||||
assert(ghostty_osc_command_type(ghostty_osc_end(p, 0x07)) == GHOSTTY_OSC_COMMAND_INVALID);
|
||||
ghostty_osc_free(p);
|
||||
assert(c.live == 0);
|
||||
assert(c.allocs == c.frees);
|
||||
}
|
||||
}
|
||||
|
||||
puts("ok");
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Generates the sorted, lowercase X11 color table (`x11_table.rs`) from
|
||||
//! `src/res/rgb.txt`, so name lookups need neither a lazily built map nor a
|
||||
//! lowercased copy of the query (Zig builds the same table at comptime, so
|
||||
//! looking up a color never allocates).
|
||||
|
||||
use std::{collections::HashSet, env, fmt::Write as _, fs, path::PathBuf};
|
||||
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/res/rgb.txt");
|
||||
println!("cargo:rerun-if-changed=build.rs");
|
||||
|
||||
let data = fs::read_to_string("src/res/rgb.txt").expect("read rgb.txt");
|
||||
let mut seen = HashSet::new();
|
||||
let mut entries: Vec<(String, u8, u8, u8)> = Vec::new();
|
||||
for line in data.split('\n').filter(|l| !l.is_empty()) {
|
||||
let comp = |r: std::ops::Range<usize>| -> u8 {
|
||||
line[r].trim_matches(' ').parse().expect("bad rgb.txt")
|
||||
};
|
||||
let (r, g, b) = (comp(0..3), comp(4..7), comp(8..11));
|
||||
let name = line[12..]
|
||||
.trim_matches(|c| c == ' ' || c == '\t' || c == '\n')
|
||||
.to_ascii_lowercase();
|
||||
// The first spelling of a (case-insensitive) name wins.
|
||||
if seen.insert(name.clone()) {
|
||||
entries.push((name, r, g, b));
|
||||
}
|
||||
}
|
||||
entries.sort_by(|a, b| a.0.as_bytes().cmp(b.0.as_bytes()));
|
||||
|
||||
let mut out = String::from("&[\n");
|
||||
for (name, r, g, b) in &entries {
|
||||
writeln!(out, " ({name:?}, {r}, {g}, {b}),").expect("write");
|
||||
}
|
||||
out.push_str("]\n");
|
||||
let dest = PathBuf::from(env::var("OUT_DIR").expect("OUT_DIR")).join("x11_table.rs");
|
||||
fs::write(dest, out).expect("write x11_table.rs");
|
||||
}
|
||||
@@ -1,21 +1,88 @@
|
||||
//! Kitty color protocol (ZIG/src/terminal/kitty/color.zig).
|
||||
|
||||
use std::fmt;
|
||||
use std::{borrow::Cow, fmt};
|
||||
|
||||
use crate::{color::Rgb, osc::Terminator};
|
||||
|
||||
/// OSC 21 payload.
|
||||
///
|
||||
/// The request list is stored encoded (fixed size records) in the OSC
|
||||
/// parser's own storage, so parsing it never touches the global allocator
|
||||
/// when the parser was given a caller-provided allocator (C API).
|
||||
#[derive(Debug, Clone, PartialEq, Default)]
|
||||
pub struct OSC {
|
||||
pub struct OSC<'a> {
|
||||
/// list of requests
|
||||
pub list: Vec<Request>,
|
||||
pub list: List<'a>,
|
||||
|
||||
/// We must reply with the same string terminator (ST) as used in the
|
||||
/// request.
|
||||
pub terminator: Terminator,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
impl OSC<'_> {
|
||||
/// Detach from the parser storage.
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> OSC<'static> {
|
||||
OSC {
|
||||
list: self.list.into_owned(),
|
||||
terminator: self.terminator,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Size of one encoded [`Request`] record.
|
||||
pub const RECORD_LEN: usize = 8;
|
||||
|
||||
/// A list of kitty color [`Request`]s: borrowed (or owned) encoded records.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct List<'a>(Cow<'a, [u8]>);
|
||||
|
||||
impl<'a> List<'a> {
|
||||
/// View records encoded with [`Request::encode`] (`bytes.len()` must be a
|
||||
/// multiple of [`RECORD_LEN`]).
|
||||
#[must_use]
|
||||
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
|
||||
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
|
||||
List(Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len() / RECORD_LEN
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, i: usize) -> Option<Request> {
|
||||
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
|
||||
}
|
||||
|
||||
/// Iterate the requests (by value).
|
||||
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
|
||||
self.0
|
||||
.as_chunks::<RECORD_LEN>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|rec| Request::decode(rec))
|
||||
}
|
||||
|
||||
/// Copy into a `Vec` (allocates; for tests and diagnostics).
|
||||
#[must_use]
|
||||
pub fn to_vec(&self) -> Vec<Request> {
|
||||
self.iter().collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> List<'static> {
|
||||
List(Cow::Owned(self.0.into_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Request {
|
||||
Query(Kind),
|
||||
Set { key: Kind, color: Rgb },
|
||||
@@ -35,6 +102,26 @@ pub enum Special {
|
||||
}
|
||||
|
||||
impl Special {
|
||||
/// All specials in discriminant order.
|
||||
const ALL: [Special; 8] = [
|
||||
Special::Foreground,
|
||||
Special::Background,
|
||||
Special::SelectionForeground,
|
||||
Special::SelectionBackground,
|
||||
Special::Cursor,
|
||||
Special::CursorText,
|
||||
Special::VisualBell,
|
||||
Special::SecondTransparentBackground,
|
||||
];
|
||||
|
||||
fn to_u8(self) -> u8 {
|
||||
Special::ALL.iter().position(|s| *s == self).unwrap_or(0) as u8
|
||||
}
|
||||
|
||||
fn from_u8(v: u8) -> Option<Special> {
|
||||
Special::ALL.get(usize::from(v)).copied()
|
||||
}
|
||||
|
||||
/// The tag name (Zig `@tagName`).
|
||||
pub fn name(self) -> &'static str {
|
||||
match self {
|
||||
@@ -92,6 +179,46 @@ impl Kind {
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Encode as a fixed size record: `[tag, kind class, kind value, r, g, b,
|
||||
/// 0, 0]`.
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> [u8; RECORD_LEN] {
|
||||
let (tag, key, color) = match *self {
|
||||
Request::Query(k) => (0, k, Rgb::default()),
|
||||
Request::Set { key, color } => (1, key, color),
|
||||
Request::Reset(k) => (2, k, Rgb::default()),
|
||||
};
|
||||
let (class, value) = match key {
|
||||
Kind::Palette(p) => (0, p),
|
||||
Kind::Special(s) => (1, s.to_u8()),
|
||||
};
|
||||
[tag, class, value, color.r, color.g, color.b, 0, 0]
|
||||
}
|
||||
|
||||
/// Inverse of [`Request::encode`].
|
||||
#[must_use]
|
||||
pub fn decode(rec: &[u8]) -> Option<Request> {
|
||||
let [tag, class, value, r, g, b, ..] = *rec else {
|
||||
return None;
|
||||
};
|
||||
let key = match class {
|
||||
0 => Kind::Palette(value),
|
||||
1 => Kind::Special(Special::from_u8(value)?),
|
||||
_ => return None,
|
||||
};
|
||||
Some(match tag {
|
||||
0 => Request::Query(key),
|
||||
1 => Request::Set {
|
||||
key,
|
||||
color: Rgb { r, g, b },
|
||||
},
|
||||
2 => Request::Reset(key),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for Kind {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
@@ -106,6 +233,33 @@ mod tests {
|
||||
#![allow(clippy::unwrap_used)]
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn request_records_round_trip() {
|
||||
let mut reqs = vec![];
|
||||
for s in Special::ALL {
|
||||
let k = Kind::Special(s);
|
||||
reqs.push(Request::Query(k));
|
||||
reqs.push(Request::Reset(k));
|
||||
reqs.push(Request::Set {
|
||||
key: k,
|
||||
color: Rgb { r: 1, g: 2, b: 3 },
|
||||
});
|
||||
}
|
||||
reqs.push(Request::Set {
|
||||
key: Kind::Palette(255),
|
||||
color: Rgb { r: 255, g: 0, b: 9 },
|
||||
});
|
||||
reqs.push(Request::Query(Kind::Palette(0)));
|
||||
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
|
||||
let list = List::from_encoded(&bytes);
|
||||
assert_eq!(list.len(), reqs.len());
|
||||
assert_eq!(list.to_vec(), reqs);
|
||||
assert_eq!(list.get(reqs.len()), None);
|
||||
assert_eq!(list.into_owned().to_vec(), reqs);
|
||||
assert_eq!(Request::decode(&[9, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||
assert_eq!(Request::decode(&[0, 1, 200, 0, 0, 0, 0, 0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_kitty_color_protocol_kind_string() {
|
||||
assert_eq!("foreground", Kind::Special(Special::Foreground).to_string());
|
||||
|
||||
@@ -82,13 +82,13 @@ pub enum Command<'a> {
|
||||
/// 4, 5, 10-19, 104, 105, 110-119
|
||||
ColorOperation {
|
||||
op: color::Operation,
|
||||
requests: color::List,
|
||||
requests: color::List<'a>,
|
||||
terminator: Terminator,
|
||||
},
|
||||
|
||||
/// Kitty color protocol, OSC 21
|
||||
/// https://sw.kovidgoyal.net/kitty/color-stack/#id1
|
||||
KittyColorProtocol(kitty_color::OSC),
|
||||
KittyColorProtocol(kitty_color::OSC<'a>),
|
||||
|
||||
/// Show a desktop notification (OSC 9 or OSC 777)
|
||||
ShowDesktopNotification {
|
||||
@@ -228,10 +228,10 @@ impl Command<'_> {
|
||||
terminator,
|
||||
} => Command::ColorOperation {
|
||||
op,
|
||||
requests,
|
||||
requests: requests.into_owned(),
|
||||
terminator,
|
||||
},
|
||||
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v),
|
||||
Command::KittyColorProtocol(v) => Command::KittyColorProtocol(v.into_owned()),
|
||||
Command::ShowDesktopNotification { title, body } => Command::ShowDesktopNotification {
|
||||
title: own(title),
|
||||
body: own(body),
|
||||
@@ -486,6 +486,10 @@ pub struct Parser<S: ByteStorage = Vec<u8>> {
|
||||
|
||||
/// Buffer for temporary storage of OSC data
|
||||
buffer: S,
|
||||
/// Auxiliary storage for the encoded request lists of the color OSCs
|
||||
/// (Zig: a `SegmentedList` on the parser's allocator). Same allocator as
|
||||
/// `buffer`.
|
||||
aux: S,
|
||||
writer: Writer,
|
||||
}
|
||||
|
||||
@@ -501,6 +505,7 @@ impl Parser {
|
||||
alloc,
|
||||
state: State::Start,
|
||||
buffer: Vec::new(),
|
||||
aux: Vec::new(),
|
||||
writer: Writer::None,
|
||||
}
|
||||
}
|
||||
@@ -512,11 +517,16 @@ impl<S: ByteStorage> Parser<S> {
|
||||
|
||||
/// Like [`Parser::init`] with an allocator-backed payload buffer
|
||||
/// (`alloc` is true: an allocator is available).
|
||||
pub fn with_storage(storage: S) -> Parser<S> {
|
||||
///
|
||||
/// `aux` holds the request lists of the color OSCs; give it the same
|
||||
/// allocator as `storage` (a capacity of a few [`color::RECORD_LEN`]
|
||||
/// records avoids allocating for common color commands).
|
||||
pub fn with_storage(storage: S, aux: S) -> Parser<S> {
|
||||
Parser {
|
||||
alloc: true,
|
||||
state: State::Start,
|
||||
buffer: storage,
|
||||
aux,
|
||||
writer: Writer::None,
|
||||
}
|
||||
}
|
||||
@@ -525,6 +535,7 @@ impl<S: ByteStorage> Parser<S> {
|
||||
pub fn reset(&mut self) {
|
||||
self.state = State::Start;
|
||||
self.buffer.reset();
|
||||
self.aux.reset();
|
||||
self.writer = Writer::None;
|
||||
}
|
||||
|
||||
@@ -608,6 +619,18 @@ impl<S: ByteStorage> Parser<S> {
|
||||
self.buffer.bytes()
|
||||
}
|
||||
|
||||
/// The payload and the auxiliary request storage at once (disjoint
|
||||
/// borrows, so a color parser can read the former while filling the
|
||||
/// latter).
|
||||
pub(crate) fn payload_and_aux(&mut self) -> (&[u8], &mut S) {
|
||||
(self.buffer.bytes(), &mut self.aux)
|
||||
}
|
||||
|
||||
/// The encoded records written to the auxiliary storage.
|
||||
pub(crate) fn aux_bytes(&self) -> &[u8] {
|
||||
self.aux.bytes()
|
||||
}
|
||||
|
||||
/// Whether any payload writer has been set up (Zig `parser.writer != null`).
|
||||
pub fn has_writer(&self) -> bool {
|
||||
self.writer != Writer::None
|
||||
@@ -977,10 +1000,16 @@ mod next_slice_tests {
|
||||
|
||||
#[test]
|
||||
fn storage_failure_invalidates_osc() {
|
||||
let mut p = Parser::with_storage(Limited {
|
||||
let mut p = Parser::with_storage(
|
||||
Limited {
|
||||
data: Vec::new(),
|
||||
cap: 8,
|
||||
});
|
||||
},
|
||||
Limited {
|
||||
data: Vec::new(),
|
||||
cap: 64,
|
||||
},
|
||||
);
|
||||
for b in b"0;abcdefghijkl" {
|
||||
p.next(*b);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! OSC color operations: 4, 5, 10-19, 104, 105, 110-119
|
||||
//! (ZIG/src/terminal/osc/parsers/color.zig).
|
||||
|
||||
use std::borrow::Cow;
|
||||
|
||||
use crate::{
|
||||
color::{Dynamic as DynamicColor, Rgb, Special as SpecialColor},
|
||||
osc::{ByteStorage, Command, Parser, State, Terminator},
|
||||
@@ -49,11 +51,7 @@ pub fn parse<S: ByteStorage>(
|
||||
if parser.has_writer() {
|
||||
parser.buffered()?;
|
||||
}
|
||||
let data: &[u8] = if parser.has_writer() {
|
||||
parser.payload()
|
||||
} else {
|
||||
&[]
|
||||
};
|
||||
let has_writer = parser.has_writer();
|
||||
// Check and make sure that we're parsing the correct OSCs
|
||||
let op = match parser.state {
|
||||
State::S4 => Operation::Osc4,
|
||||
@@ -84,13 +82,60 @@ pub fn parse<S: ByteStorage>(
|
||||
return None;
|
||||
}
|
||||
};
|
||||
// The request list is built in the parser's own (allocator-backed)
|
||||
// auxiliary storage, not on the global allocator.
|
||||
let (payload, aux) = parser.payload_and_aux();
|
||||
let data: &[u8] = if has_writer { payload } else { &[] };
|
||||
aux.reset();
|
||||
let mut sink = StorageSink { aux, failed: false };
|
||||
parse_color_into(op, data, &mut sink);
|
||||
if sink.failed {
|
||||
// Allocation failure: the OSC cannot be represented.
|
||||
log::warn!("unable to allocate the color request list");
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(Command::ColorOperation {
|
||||
op,
|
||||
requests: parse_color(op, data),
|
||||
requests: List::from_encoded(parser.aux_bytes()),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
})
|
||||
}
|
||||
|
||||
/// Where parsed requests go: a `Vec` (tests, [`parse_color`]) or the
|
||||
/// parser's storage ([`StorageSink`]).
|
||||
pub trait Sink {
|
||||
fn push(&mut self, request: Request);
|
||||
fn is_empty(&self) -> bool;
|
||||
}
|
||||
|
||||
impl Sink for Vec<Request> {
|
||||
fn push(&mut self, request: Request) {
|
||||
Vec::push(self, request);
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
Vec::is_empty(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// Appends encoded request records to a [`ByteStorage`]. An allocation
|
||||
/// failure is remembered in `failed` (later pushes are dropped).
|
||||
pub struct StorageSink<'s, S: ByteStorage> {
|
||||
aux: &'s mut S,
|
||||
failed: bool,
|
||||
}
|
||||
|
||||
impl<S: ByteStorage> Sink for StorageSink<'_, S> {
|
||||
fn push(&mut self, request: Request) {
|
||||
if !self.failed && self.aux.try_extend(&request.encode()).is_err() {
|
||||
self.failed = true;
|
||||
}
|
||||
}
|
||||
fn is_empty(&self) -> bool {
|
||||
self.aux.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
/// Zig `std.mem.tokenizeScalar(u8, buf, ';')`: skips empty tokens.
|
||||
fn tokenize(buf: &[u8]) -> impl Iterator<Item = &[u8]> {
|
||||
buf.split(|&c| c == b';').filter(|t| !t.is_empty())
|
||||
@@ -132,33 +177,88 @@ fn parse_u9(s: &[u8]) -> Option<u16> {
|
||||
/// request) but grants us an easier to understand and testable implementation.
|
||||
///
|
||||
/// If color changing ends up being a bottleneck we can optimize this later.
|
||||
pub fn parse_color(op: Operation, buf: &[u8]) -> List {
|
||||
pub fn parse_color(op: Operation, buf: &[u8]) -> Vec<Request> {
|
||||
let mut out = Vec::new();
|
||||
parse_color_into(op, buf, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// [`parse_color`] into any [`Sink`].
|
||||
pub fn parse_color_into(op: Operation, buf: &[u8], out: &mut impl Sink) {
|
||||
let mut it = tokenize(buf);
|
||||
match op {
|
||||
Operation::Osc4 => parse_get_set_ansi_color(Operation::Osc4, &mut it),
|
||||
Operation::Osc5 => parse_get_set_ansi_color(Operation::Osc5, &mut it),
|
||||
Operation::Osc104 => parse_reset_ansi_color(Operation::Osc104, &mut it),
|
||||
Operation::Osc105 => parse_reset_ansi_color(Operation::Osc105, &mut it),
|
||||
Operation::Osc10 => parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it),
|
||||
Operation::Osc11 => parse_get_set_dynamic_color(DynamicColor::Background, &mut it),
|
||||
Operation::Osc12 => parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it),
|
||||
Operation::Osc13 => parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it),
|
||||
Operation::Osc14 => parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it),
|
||||
Operation::Osc15 => parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it),
|
||||
Operation::Osc16 => parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it),
|
||||
Operation::Osc17 => parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it),
|
||||
Operation::Osc18 => parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it),
|
||||
Operation::Osc19 => parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it),
|
||||
Operation::Osc110 => parse_reset_dynamic_color(DynamicColor::Foreground, &mut it),
|
||||
Operation::Osc111 => parse_reset_dynamic_color(DynamicColor::Background, &mut it),
|
||||
Operation::Osc112 => parse_reset_dynamic_color(DynamicColor::Cursor, &mut it),
|
||||
Operation::Osc113 => parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it),
|
||||
Operation::Osc114 => parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it),
|
||||
Operation::Osc115 => parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it),
|
||||
Operation::Osc116 => parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it),
|
||||
Operation::Osc117 => parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it),
|
||||
Operation::Osc118 => parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it),
|
||||
Operation::Osc119 => parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it),
|
||||
Operation::Osc4 => {
|
||||
parse_get_set_ansi_color(Operation::Osc4, &mut it, out);
|
||||
}
|
||||
Operation::Osc5 => {
|
||||
parse_get_set_ansi_color(Operation::Osc5, &mut it, out);
|
||||
}
|
||||
Operation::Osc104 => {
|
||||
parse_reset_ansi_color(Operation::Osc104, &mut it, out);
|
||||
}
|
||||
Operation::Osc105 => {
|
||||
parse_reset_ansi_color(Operation::Osc105, &mut it, out);
|
||||
}
|
||||
Operation::Osc10 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::Foreground, &mut it, out);
|
||||
}
|
||||
Operation::Osc11 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::Background, &mut it, out);
|
||||
}
|
||||
Operation::Osc12 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::Cursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc13 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc14 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc15 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc16 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc17 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc18 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc19 => {
|
||||
parse_get_set_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc110 => {
|
||||
parse_reset_dynamic_color(DynamicColor::Foreground, &mut it, out);
|
||||
}
|
||||
Operation::Osc111 => {
|
||||
parse_reset_dynamic_color(DynamicColor::Background, &mut it, out);
|
||||
}
|
||||
Operation::Osc112 => {
|
||||
parse_reset_dynamic_color(DynamicColor::Cursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc113 => {
|
||||
parse_reset_dynamic_color(DynamicColor::PointerForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc114 => {
|
||||
parse_reset_dynamic_color(DynamicColor::PointerBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc115 => {
|
||||
parse_reset_dynamic_color(DynamicColor::TektronixForeground, &mut it, out);
|
||||
}
|
||||
Operation::Osc116 => {
|
||||
parse_reset_dynamic_color(DynamicColor::TektronixBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc117 => {
|
||||
parse_reset_dynamic_color(DynamicColor::HighlightBackground, &mut it, out);
|
||||
}
|
||||
Operation::Osc118 => {
|
||||
parse_reset_dynamic_color(DynamicColor::TektronixCursor, &mut it, out);
|
||||
}
|
||||
Operation::Osc119 => {
|
||||
parse_reset_dynamic_color(DynamicColor::HighlightForeground, &mut it, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,29 +279,32 @@ fn ansi_target(color: u16, special_only: bool) -> Option<Target> {
|
||||
}
|
||||
|
||||
/// OSC 4/5
|
||||
fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
fn parse_get_set_ansi_color<'a>(
|
||||
op: Operation,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
// Note: in ANY error scenario below we return the accumulated results.
|
||||
// This matches the xterm behavior (see misc.c ChangeAnsiColorRequest)
|
||||
|
||||
let mut result = List::new();
|
||||
loop {
|
||||
// We expect a `c; spec` pair. If either doesn't exist then
|
||||
// we return the results up to this point.
|
||||
let Some(color_str) = it.next() else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// Color must be numeric. u9 because that'll fit our palette + special
|
||||
let Some(color) = parse_u9(color_str) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// Parse the color.
|
||||
let Some(target) = ansi_target(color, op == Operation::Osc5) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// "?" always results in a query.
|
||||
@@ -211,20 +314,23 @@ fn parse_get_set_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a
|
||||
}
|
||||
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget { target, color: rgb }));
|
||||
}
|
||||
}
|
||||
|
||||
/// OSC 104/105: Reset ANSI Colors
|
||||
fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [u8]>) -> List {
|
||||
fn parse_reset_ansi_color<'a>(
|
||||
op: Operation,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
// Note: xterm stops parsing the reset list on any error, but we're
|
||||
// more flexible and try the next value. This matches the behavior of
|
||||
// Kitty and I don't see a downside to being more flexible here. Hopefully
|
||||
// no one depends on the exact behavior of xterm.
|
||||
|
||||
let mut result = List::new();
|
||||
loop {
|
||||
let Some(color_str) = it.next() else {
|
||||
// If no parameters are given, we reset the full table.
|
||||
@@ -235,7 +341,7 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
|
||||
_ => unreachable!(),
|
||||
});
|
||||
}
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
// Empty color strings are ignored, not treated as an error.
|
||||
@@ -261,22 +367,22 @@ fn parse_reset_ansi_color<'a>(op: Operation, it: &mut impl Iterator<Item = &'a [
|
||||
fn parse_get_set_dynamic_color<'a>(
|
||||
start: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
// Note: in ANY error scenario below we return the accumulated results.
|
||||
// This matches the xterm behavior (see misc.c ChangeColorsRequest)
|
||||
|
||||
let mut result = List::new();
|
||||
let mut color = start;
|
||||
loop {
|
||||
let Some(spec_str) = it.next() else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
|
||||
if spec_str == b"?" {
|
||||
result.push(Request::Query(Target::Dynamic(color)));
|
||||
} else {
|
||||
let Ok(rgb) = Rgb::parse_bytes(spec_str) else {
|
||||
return result;
|
||||
return;
|
||||
};
|
||||
result.push(Request::Set(ColoredTarget {
|
||||
target: Target::Dynamic(color),
|
||||
@@ -287,7 +393,7 @@ fn parse_get_set_dynamic_color<'a>(
|
||||
// Each successive value uses the next color so long as it exists.
|
||||
match color.next() {
|
||||
Some(n) => color = n,
|
||||
None => return result,
|
||||
None => return,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -296,18 +402,117 @@ fn parse_get_set_dynamic_color<'a>(
|
||||
fn parse_reset_dynamic_color<'a>(
|
||||
color: DynamicColor,
|
||||
it: &mut impl Iterator<Item = &'a [u8]>,
|
||||
) -> List {
|
||||
let mut result = List::new();
|
||||
result: &mut impl Sink,
|
||||
) {
|
||||
if it.next().is_some() {
|
||||
return result;
|
||||
return;
|
||||
}
|
||||
result.push(Request::Reset(Target::Dynamic(color)));
|
||||
result
|
||||
}
|
||||
|
||||
/// The list of requests in a color OSC. (Zig uses a SegmentedList to
|
||||
/// avoid copying; a `Vec` is equivalent.)
|
||||
pub type List = Vec<Request>;
|
||||
/// Size of one encoded [`Request`] record.
|
||||
pub const RECORD_LEN: usize = 8;
|
||||
|
||||
/// The list of requests in a color OSC. (Zig uses a SegmentedList on the
|
||||
/// parser's allocator.) The requests are stored as fixed size encoded
|
||||
/// records ([`Request::encode`]) in the parser's own storage, so with a
|
||||
/// caller-provided allocator (C API) the list is allocated by that allocator
|
||||
/// and borrowed by the command, like Zig's.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct List<'a>(Cow<'a, [u8]>);
|
||||
|
||||
impl<'a> List<'a> {
|
||||
/// View records encoded with [`Request::encode`].
|
||||
#[must_use]
|
||||
pub fn from_encoded(bytes: &'a [u8]) -> List<'a> {
|
||||
debug_assert_eq!(bytes.len() % RECORD_LEN, 0);
|
||||
List(Cow::Borrowed(bytes))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn len(&self) -> usize {
|
||||
self.0.len() / RECORD_LEN
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.0.is_empty()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, i: usize) -> Option<Request> {
|
||||
Request::decode(self.0.get(i * RECORD_LEN..(i + 1) * RECORD_LEN)?)
|
||||
}
|
||||
|
||||
/// Iterate the requests (by value).
|
||||
pub fn iter(&self) -> impl Iterator<Item = Request> + '_ {
|
||||
self.0
|
||||
.as_chunks::<RECORD_LEN>()
|
||||
.0
|
||||
.iter()
|
||||
.filter_map(|rec| Request::decode(rec))
|
||||
}
|
||||
|
||||
/// Copy into a `Vec` (allocates; for tests and diagnostics).
|
||||
#[must_use]
|
||||
pub fn to_vec(&self) -> Vec<Request> {
|
||||
self.iter().collect()
|
||||
}
|
||||
|
||||
/// Detach from the parser storage.
|
||||
#[must_use]
|
||||
pub fn into_owned(self) -> List<'static> {
|
||||
List(Cow::Owned(self.0.into_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Encode as a fixed size record: `[tag, target class, target value, r,
|
||||
/// g, b, 0, 0]`.
|
||||
#[must_use]
|
||||
pub fn encode(&self) -> [u8; RECORD_LEN] {
|
||||
fn target(t: Target) -> (u8, u8) {
|
||||
match t {
|
||||
Target::Palette(p) => (0, p),
|
||||
Target::Special(s) => (1, s as u8),
|
||||
Target::Dynamic(d) => (2, d as u8),
|
||||
}
|
||||
}
|
||||
let (tag, (class, value), c) = match *self {
|
||||
Request::Set(ColoredTarget { target: t, color }) => (0, target(t), color),
|
||||
Request::Query(t) => (1, target(t), Rgb::default()),
|
||||
Request::Reset(t) => (2, target(t), Rgb::default()),
|
||||
Request::ResetPalette => (3, (0, 0), Rgb::default()),
|
||||
Request::ResetSpecial => (4, (0, 0), Rgb::default()),
|
||||
};
|
||||
[tag, class, value, c.r, c.g, c.b, 0, 0]
|
||||
}
|
||||
|
||||
/// Inverse of [`Request::encode`].
|
||||
#[must_use]
|
||||
pub fn decode(rec: &[u8]) -> Option<Request> {
|
||||
let [tag, class, value, r, g, b, ..] = *rec else {
|
||||
return None;
|
||||
};
|
||||
let target = match class {
|
||||
0 => Target::Palette(value),
|
||||
1 => Target::Special(SpecialColor::from_u8(value)?),
|
||||
2 => Target::Dynamic(DynamicColor::from_u8(value)?),
|
||||
_ => return None,
|
||||
};
|
||||
Some(match tag {
|
||||
0 => Request::Set(ColoredTarget {
|
||||
target,
|
||||
color: Rgb { r, g, b },
|
||||
}),
|
||||
1 => Request::Query(target),
|
||||
2 => Request::Reset(target),
|
||||
3 => Request::ResetPalette,
|
||||
4 => Request::ResetSpecial,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// A single operation related to the terminal color palette.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -447,6 +652,32 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_records_round_trip() {
|
||||
let mut reqs = vec![Request::ResetPalette, Request::ResetSpecial];
|
||||
for t in [
|
||||
Target::Palette(0),
|
||||
Target::Palette(255),
|
||||
Target::Special(SpecialColor::Italic),
|
||||
Target::Dynamic(DynamicColor::HighlightForeground),
|
||||
] {
|
||||
reqs.push(Request::Query(t));
|
||||
reqs.push(Request::Reset(t));
|
||||
reqs.push(Request::Set(ColoredTarget {
|
||||
target: t,
|
||||
color: Rgb { r: 9, g: 8, b: 7 },
|
||||
}));
|
||||
}
|
||||
let bytes: Vec<u8> = reqs.iter().flat_map(Request::encode).collect();
|
||||
let list = List::from_encoded(&bytes);
|
||||
assert_eq!(list.len(), reqs.len());
|
||||
assert_eq!(list.to_vec(), reqs);
|
||||
assert_eq!(list.iter().count(), reqs.len());
|
||||
assert_eq!(list.into_owned().to_vec(), reqs);
|
||||
assert_eq!(Request::decode(&[7, 0, 0, 0, 0, 0, 0, 0]), None);
|
||||
assert_eq!(Request::decode(&[1, 2, 99, 0, 0, 0, 0, 0]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc_4_multiple_requests() {
|
||||
let list = parse_color(Operation::Osc4, b"0;red;1;blue");
|
||||
@@ -566,7 +797,7 @@ mod tests {
|
||||
assert_eq!(Operation::Osc112, op);
|
||||
assert_eq!(
|
||||
vec![Request::Reset(Target::Dynamic(DynamicColor::Cursor))],
|
||||
requests
|
||||
requests.to_vec()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
use crate::{
|
||||
color::Rgb,
|
||||
kitty::color::{Kind, OSC, Request},
|
||||
kitty::color::{Kind, List, OSC, Request},
|
||||
osc::{ByteStorage, Command, Parser, State, Terminator},
|
||||
};
|
||||
|
||||
@@ -23,16 +23,18 @@ pub fn parse<S: ByteStorage>(
|
||||
}
|
||||
parser.buffered()?;
|
||||
|
||||
// The parse only reads the payload; failure marks the parser invalid
|
||||
// after the borrow ends.
|
||||
let data = parser.payload();
|
||||
// The parse reads the payload and appends encoded records to the
|
||||
// parser's auxiliary storage (the caller's allocator in the C API); the
|
||||
// command then borrows them.
|
||||
let (data, aux) = parser.payload_and_aux();
|
||||
aux.reset();
|
||||
let mut exceeded = false;
|
||||
let mut osc = OSC {
|
||||
list: Vec::new(),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
let mut out = Pending {
|
||||
failed: false,
|
||||
count: 0,
|
||||
};
|
||||
for kv in data.split(|&c| c == b';') {
|
||||
if osc.list.len() >= Kind::MAX * 2 {
|
||||
if out.count >= Kind::MAX * 2 {
|
||||
log::warn!("exceeded limit for number of keys in kitty color protocol, ignoring");
|
||||
exceeded = true;
|
||||
break;
|
||||
@@ -54,11 +56,11 @@ pub fn parse<S: ByteStorage>(
|
||||
};
|
||||
let value = rest.trim_ascii_matching(b' ');
|
||||
if value.is_empty() {
|
||||
osc.list.push(Request::Reset(key));
|
||||
out.push(aux, Request::Reset(key));
|
||||
} else if value == b"?" {
|
||||
osc.list.push(Request::Query(key));
|
||||
out.push(aux, Request::Query(key));
|
||||
} else if let Ok(color) = Rgb::parse_bytes(value) {
|
||||
osc.list.push(Request::Set { key, color });
|
||||
out.push(aux, Request::Set { key, color });
|
||||
} else {
|
||||
log::warn!(
|
||||
"invalid color format in kitty color protocol: {}",
|
||||
@@ -66,11 +68,31 @@ pub fn parse<S: ByteStorage>(
|
||||
);
|
||||
}
|
||||
}
|
||||
if exceeded {
|
||||
if exceeded || out.failed {
|
||||
parser.state = State::Invalid;
|
||||
return None;
|
||||
}
|
||||
Some(Command::KittyColorProtocol(osc))
|
||||
Some(Command::KittyColorProtocol(OSC {
|
||||
list: List::from_encoded(parser.aux_bytes()),
|
||||
terminator: Terminator::init(terminator_ch),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Bookkeeping while appending records to the parser's storage.
|
||||
struct Pending {
|
||||
/// An allocation failed (the OSC cannot be represented).
|
||||
failed: bool,
|
||||
/// Number of requests so far (for the limit).
|
||||
count: usize,
|
||||
}
|
||||
|
||||
impl Pending {
|
||||
fn push<S: ByteStorage>(&mut self, aux: &mut S, req: Request) {
|
||||
if !self.failed && aux.try_extend(&req.encode()).is_err() {
|
||||
self.failed = true;
|
||||
}
|
||||
self.count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
trait TrimMatching {
|
||||
@@ -123,7 +145,7 @@ mod tests {
|
||||
let Command::KittyColorProtocol(osc) = cmd else {
|
||||
panic!("wrong command");
|
||||
};
|
||||
let list = osc.list;
|
||||
let list = osc.list.to_vec();
|
||||
assert_eq!(9, list.len());
|
||||
assert_eq!(Request::Query(Kind::Special(Special::Foreground)), list[0]);
|
||||
assert_eq!(
|
||||
|
||||
@@ -892,8 +892,10 @@ mod tests {
|
||||
assert_eq!(op, osc::color::Operation::Osc112);
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(
|
||||
requests[0],
|
||||
osc::color::Request::Reset(osc::color::Target::Dynamic(crate::color::Dynamic::Cursor))
|
||||
requests.get(0),
|
||||
Some(osc::color::Request::Reset(osc::color::Target::Dynamic(
|
||||
crate::color::Dynamic::Cursor
|
||||
)))
|
||||
);
|
||||
}
|
||||
|
||||
@@ -921,7 +923,7 @@ mod tests {
|
||||
assert_eq!(terminator, osc::Terminator::Bel);
|
||||
assert_eq!(op, osc::color::Operation::Osc104);
|
||||
assert_eq!(requests.len(), 1);
|
||||
assert_eq!(requests[0], osc::color::Request::ResetPalette);
|
||||
assert_eq!(requests.get(0), Some(osc::color::Request::ResetPalette));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -151,7 +151,7 @@ define_actions! {
|
||||
MouseShape(MouseShape),
|
||||
ConfigureCharset(ConfigureCharset),
|
||||
SetAttribute(sgr::Attribute<'a>),
|
||||
KittyColorReport(&'a kitty::color::OSC),
|
||||
KittyColorReport(&'a kitty::color::OSC<'a>),
|
||||
ColorOperation(ColorOperation<'a>),
|
||||
SemanticPrompt(&'a osc::SemanticPrompt<'a>),
|
||||
}
|
||||
@@ -248,7 +248,7 @@ pub struct ConfigureCharset {
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct ColorOperation<'a> {
|
||||
pub op: osc::color::Operation,
|
||||
pub requests: &'a osc::color::List,
|
||||
pub requests: &'a osc::color::List<'a>,
|
||||
pub terminator: osc::Terminator,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! X11 color names (ZIG/src/terminal/x11_color.zig).
|
||||
|
||||
use std::{collections::HashMap, sync::OnceLock};
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use crate::color::Rgb;
|
||||
|
||||
@@ -23,17 +23,15 @@ fn parse_entries() -> impl Iterator<Item = (&'static str, Rgb)> {
|
||||
})
|
||||
}
|
||||
|
||||
/// The map of all available X11 colors. Keys are lowercase ASCII; lookups
|
||||
/// are ASCII case-insensitive (see [`get`]).
|
||||
fn map() -> &'static HashMap<String, Rgb> {
|
||||
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
|
||||
MAP.get_or_init(|| {
|
||||
let mut m = HashMap::new();
|
||||
for (name, rgb) in parse_entries() {
|
||||
m.entry(name.to_ascii_lowercase()).or_insert(rgb);
|
||||
}
|
||||
m
|
||||
})
|
||||
/// `(lowercase name, r, g, b)` sorted by name (generated by `build.rs` from
|
||||
/// `res/rgb.txt`; the first spelling of a name wins).
|
||||
static TABLE: &[(&str, u8, u8, u8)] = include!(concat!(env!("OUT_DIR"), "/x11_table.rs"));
|
||||
|
||||
/// ASCII case-insensitive comparison of `query` against an already lowercase
|
||||
/// `key`, without allocating.
|
||||
fn cmp_lowercase(query: &[u8], key: &[u8]) -> std::cmp::Ordering {
|
||||
let q = query.iter().map(u8::to_ascii_lowercase);
|
||||
q.cmp(key.iter().copied())
|
||||
}
|
||||
|
||||
/// All X11 color names with their original spelling (e.g. `AliceBlue`,
|
||||
@@ -49,14 +47,18 @@ pub fn names() -> &'static [(&'static str, Rgb)] {
|
||||
})
|
||||
}
|
||||
|
||||
/// Look up an X11 color by name (ASCII case-insensitive).
|
||||
/// Look up an X11 color by name (ASCII case-insensitive). Never allocates.
|
||||
pub fn get(name: &str) -> Option<Rgb> {
|
||||
map().get(&name.to_ascii_lowercase()).copied()
|
||||
get_bytes(name.as_bytes())
|
||||
}
|
||||
|
||||
/// Byte-slice variant of [`get`].
|
||||
pub fn get_bytes(name: &[u8]) -> Option<Rgb> {
|
||||
get(std::str::from_utf8(name).ok()?)
|
||||
let i = TABLE
|
||||
.binary_search_by(|(key, ..)| cmp_lowercase(name, key.as_bytes()).reverse())
|
||||
.ok()?;
|
||||
let (_, r, g, b) = TABLE[i];
|
||||
Some(Rgb { r, g, b })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
# Z7 final pass: typed OS strings + RV5 findings
|
||||
|
||||
Owner: Z7. Fixes the RV5 rows (`review-rv5-codex.md`) structurally: escaped OS
|
||||
strings can no longer reach an OS boundary undecoded, and plain text can no
|
||||
longer be decoded by accident.
|
||||
|
||||
## Structural change: `ghostty_os::osbytes::EncodedOs`
|
||||
|
||||
`EncodedOs` is a newtype over the reversible `U+FDD0` escape encoding (Z1/Z5).
|
||||
It is deliberately **not** a `str`, not `Display`, and not `AsRef<Path/OsStr>`:
|
||||
|
||||
* Producing: `from_os`, `from_path`, `from_bytes`, `from_wide` (Windows WTF-16),
|
||||
`from_plain_text` (escapes a literal `U+FDD0`, i.e. text is never decoded),
|
||||
`from_encoded` (adopt a CLI/config string that is *already* encoded; the only
|
||||
"trust me" constructor).
|
||||
* Consuming (explicit decode at the boundary): `to_os`, `to_path`, `to_bytes`,
|
||||
`to_wide`, `to_wide_nul`. `as_encoded` is for config files/`+show-config`
|
||||
output (unchanged format).
|
||||
|
||||
Typed APIs (compiler-enforced):
|
||||
|
||||
| Area | Before | After |
|
||||
|---|---|---|
|
||||
| `os::env::getenv[_not_empty]`, `homedir::home`, `expand_home[_with_capacity]`, `xdg::{config,cache,state}` (+`Options.home`), `path::expand`, `passwd::Entry` fields, `ResourcesDir` fields/`app()`/`host()` | `String` | `EncodedOs` |
|
||||
| `os::open::open(kind, url)` | `&str` (always decoded) | `&OsStr`: callers decide; `apprt::action::OpenUrl.url` and `Pwd.pwd` are `EncodedOs` |
|
||||
| `os::i18n::init` | `&str` | `&Path` |
|
||||
| `os::flatpak::FlatpakHostCommand.cwd`, termio `Command.cwd`, termio/exec `Config.{working_directory,resources_dir}`, `shell_integration::setup*` resource dir, `darwin_bundle_env` | `String` | `EncodedOs` |
|
||||
| `config::Path` (Optional/Required), `WorkingDirectory::Path`, `file_load::*`, `edit::open_path`, `theme::{open,Location,LocationIterator,OpenedTheme}`, `Config::{load_file,load_optional_file}`, `ReplayStep::Expand` | `String` | `EncodedOs` |
|
||||
| `Surface::pwd()`, `apprt::surface::new_config(prev_pwd)`, GTK surface/window/app `working_directory` overrides, cli `ThemeListElement.path`, list-themes `Effect::Copy` | `String` | `EncodedOs` |
|
||||
| renderer `PathItem` (custom shaders) | `String` | `PathBuf` (decoded once in `DerivedConfig`) |
|
||||
| `RuntimeSurface::set_clipboard` contents | `(String, String)` | `(String, Vec<u8>)` (Zig `[:0]const u8`) |
|
||||
| crash `Report.name`, bench `Args` values/data path | `String` | `OsString`/`PathBuf` |
|
||||
|
||||
Config file output/format is byte-identical (`as_encoded`); no config key
|
||||
changed its syntax.
|
||||
|
||||
Boundaries that still hold encoded `String`s on purpose (each decodes at its
|
||||
own boundary): `Command`/argv items (`config::Command`, termio `Command.args`,
|
||||
`exec_command`) and `RepeatableStringMap` env pairs (decoded in
|
||||
`Subprocess::new`/`prepare`); the GObject `pwd` property of the GTK surface
|
||||
(a GLib string, converted in `get_pwd`/`set_pwd`); `CValue::{String,Path}` (now
|
||||
decoded by `config_capi::cstring_value` when handed to C, previously the
|
||||
escapes leaked to the embedder); `macos_custom_icon` (a string field, goes
|
||||
through that C decode).
|
||||
|
||||
## RV5 findings -> fixes
|
||||
|
||||
| Finding | Fix | Test |
|
||||
|---|---|---|
|
||||
| Surface copy (`binding.rs:635`) | `write_screen_temp_file` returns a `PathBuf`; copy/paste send `os_bytes(path)` (clipboard contents are now bytes), open sends `EncodedOs::from_path` | `screen_temp_file_keeps_non_utf8_path_bytes` (TMPDIR non-UTF-8) |
|
||||
| open.rs regression (`open.rs:33`, `mouse.rs:1663`) | `open` takes `&OsStr`; regex links use `open_link_target` -> `EncodedOs::from_plain_text` (never decoded); resolved relative paths use `from_path`; OSC 8 URIs `from_bytes(raw)` | `open_link_plain_text_is_never_decoded`, `open_link_resolves_against_non_utf8_pwd`, `osc8_uri_bytes_are_carried_unchanged`, `os::open::encoded_paths_decode_but_plain_urls_do_not` |
|
||||
| Windows WTF-8 / lone surrogates in osbytes | `os_bytes`/`os_from_bytes` on Windows use `encode_wide`/`from_wide` through portable `wide_to_wtf8`/`wtf8_to_wide` (lone surrogates are 3-byte WTF-8, escaped by the encoding); `EncodedOs::{from_wide,to_wide}` | `wtf8_round_trips_unpaired_surrogates`, `wtf8_invalid_bytes_become_replacement`, `encoded_os_wide_round_trips_lone_surrogates` (platform independent) |
|
||||
| Windows termio `GHOSTTY_RESOURCES_DIR`/`TERMINFO` (`exec_windows.rs:606,618`) | decoded with `to_os()`; `terminfo_dir` builds the OS string (no lossy) | `terminfo_dir_is_sibling_of_resources_and_lossless` (cfg(windows), compiled by `check-windows`) |
|
||||
| Windows exe dir / PATH (`exec_windows.rs:855`), `%WINDIR%`, `%HOMEDRIVE%%HOMEPATH%`, cwd, argv | exe dir kept as `OsString`; `home_windows` concatenates `OsStr`s; `CreateProcessW` gets `to_wide_nul` for path/cwd/args via new `windows_create_command_line_wide` | `wide_command_line_matches_str_version_and_keeps_lone_surrogates`, `home_windows_joins_drive_and_path` |
|
||||
| OSC color request lists allocate globally (`color.rs:232`, `kitty_color.rs:57`, `capi/osc.rs:144`) | request lists are stored as fixed 8-byte records in a second parser storage (`Parser.aux`, same allocator as the payload); `Command::{ColorOperation,KittyColorProtocol}` borrow them (`color::List<'a>`, `kitty::color::List<'a>`, `into_owned` for the stream). C parser: 4 inline records (Zig `SegmentedList(Request, 4)`), longer lists grow through the caller's allocator, failure -> invalid command. X11 name lookup no longer allocates (build.rs generates a sorted lowercase table; previously a lowercased `String` + lazy global map) | Rust `end_makes_no_global_allocations` extended with OSC 4/5/10/11/104/112/21; `long_color_lists_grow_via_the_caller_allocator`; C `alloc_test.c` section 6 (frozen allocator, color commands) and new section 7 (growth + refusal) |
|
||||
| macOS Mach handler hang (`mach_handler.rs:289,305`) | `SuspendGuard` (platform independent, `suspend.rs`) resumes every suspended thread, the faulting one included, before ports are restored and `KERN_FAILURE` is replied (Breakpad behaviour) | `suspend::tests::*` (mock threads, incl. panic path) |
|
||||
| macOS SIGABRT minidump (`signal.rs:215`) | Breakpad design: SIGABRT handler sends an abort request (`exception::ABORT_MSG_ID`) to the exception-port thread and waits on a reply port (bounded); the thread dumps with `EXC_SOFTWARE/EXC_SOFT_SIGNAL(SIGABRT)` and replies. Falls back to the event-only report if the port is unavailable | `exception::tests::{abort_request_round_trips,sigabrt_is_described_as_a_soft_signal}`; compile via `just check-macos` (both targets). Not run on real macOS. |
|
||||
| 1 Open config (`edit.rs`) | `open_path` returns `EncodedOs`; candidates and create-dir/file use decoded paths | `open_path_uses_decoded_non_utf8_xdg_dir` |
|
||||
| 2 Background image (`generic.rs:1099`) | `load_background_image(&Path)` from `path().to_path()` | `background_image_loads_from_non_utf8_config_path` |
|
||||
| 3 Crash dir (`dir.rs:22`) | `default_dir` uses `to_path()`; sentry `State.dir` stores `os_bytes` and decodes with `os_from_bytes` (was lossy on non-Unix) | `default_dir_decodes_non_utf8_xdg_state`, `non_utf8_report_name_is_kept` |
|
||||
| 4 SSH cache (`disk_cache.rs:62`) | `to_path()` | `default_path_decodes_non_utf8_xdg_state` |
|
||||
| 5 Exe dir / PATH (`exec.rs:1191`) | `OsString` end to end (`add_ghostty_path_for`) | `ghostty_path_keeps_non_utf8_exe_dir_bytes` |
|
||||
| 6 Fontconfig filenames (`fontconfig.rs:226`) | `Pattern::get_os_string`/`add_string_bytes`; `Face::init_file(impl AsRef<Path>)` | `file_name_keeps_non_utf8_bytes` |
|
||||
| 7 File links (`mouse.rs:405,1614`) | see open.rs row; `osc8_uri` returns raw bytes; `copy_url_to_clipboard` writes bytes | as above |
|
||||
| 8 Kitty TMPDIR (`graphics_image.rs:591`) | `OsString` temp dir compared by bytes (`is_path_in_temp_dir_with`) | `temp_dir_check_keeps_non_utf8_tmpdir_bytes` |
|
||||
| 9 `.hushlogin` (`exec.rs:1442`) | `passwd.home.to_path()` | `exec_command_darwin_hushlogin_with_non_utf8_home` |
|
||||
| 10 Benchmarks (`benchmark.rs:65`, `args.rs:82`) | `Args` values are `OsString`, `--data` is a `PathBuf`; C args are split on raw bytes | `data_path_keeps_non_utf8_bytes`, `split_args_keeps_non_utf8_bytes` |
|
||||
|
||||
Other bugs found by the type change (not in RV5): GTK `gtk-custom-css` and
|
||||
bell-audio paths used the escaped string as a path; `HISTFILE` for bash
|
||||
integration was built from the escaped home; OSC 52 clipboard writes were
|
||||
lossily converted to UTF-8 (now bytes); `ghostty_config_get` leaked escapes to
|
||||
C; `ghostty_config_open_path` returned escaped bytes.
|
||||
|
||||
## Lossy-call audit (non-test code)
|
||||
|
||||
Grep: `to_string_lossy|from_utf8_lossy|to_str().unwrap|Path::new(|PathBuf::from(`
|
||||
over `crates/*/src` (non-test modules). Result: all OS-derived values are typed;
|
||||
what remains is justified below.
|
||||
|
||||
* **Display/log only** (never reaches a syscall): `os/open.rs` stderr of the
|
||||
opener, `termio/shell_integration.rs` and `termio/exec.rs` log messages,
|
||||
`terminal/kitty/graphics_image.rs` shm path log, `lib/embedded.rs` `wd_disp`,
|
||||
`apprt-gtk/debug_input.rs`, `cli/crash_report.rs` (listing), inspector widgets
|
||||
(`pwd`, hyperlink ids/uris), `core/surface/mouse.rs:1816` (link-preview
|
||||
status text), `gtk class/command_palette.rs`, `class/surface.rs` clipboard
|
||||
confirmation text (the clipboard itself gets the bytes).
|
||||
* **Text, not OS data**: VT/OSC/tmux parsers (`ghostty-vt`, `terminal`), CLI
|
||||
config parsing (`config/command.rs` splitter works on already-valid encoded
|
||||
text), fonts' name/tag strings (`freetype.rs`, `feature.rs`,
|
||||
`fontconfig.rs::get_string` documents UTF-8-only properties), shadertoy
|
||||
source text, `terminfo/source.rs`, `build-data`, `datastruct`, `os/uri.rs`
|
||||
`to_raw` (documented: host names; paths use `to_raw_bytes`), `os/kernel_info`,
|
||||
`os/locale`, `os/hostname`, `os/desktop` (env values parsed as numbers/lists),
|
||||
`lib/embedded.rs` key text/preedit/IME strings (C API text), `lib/inspector.rs`,
|
||||
`lib/benchmark.rs:60` (action *name*, compared with the ASCII action table),
|
||||
`ssh_cache/disk_cache.rs:280` (cache file of host names), `bench/args.rs`
|
||||
messages and the action word.
|
||||
* **Windows-only fallbacks**: `os/osbytes.rs` non-unix/non-windows branch
|
||||
(wasm: UTF-8 only), `os/env.rs:31` case-insensitive key compare on Windows
|
||||
(comparison only; lone-surrogate keys may collide, never rewritten),
|
||||
`os/windows.rs:75` computer name (ANSI API).
|
||||
* **D-Bus**: `os/flatpak.rs:152` env `a{ss}` values are UTF-8 by D-Bus type
|
||||
(unchanged from Z5). cwd/argv use `ay` bytes.
|
||||
* **Plain text paths**: `core/surface/mouse.rs:402,407` (`Path::new(text)` of
|
||||
the selected link text; the pwd side is `os_from_bytes`),
|
||||
`core/surface/mod.rs` screenshot helpers (developer aid, `var_os`),
|
||||
`os/path.rs`, `os/macos.rs` (`PathBuf::from` of an already-OS value),
|
||||
`os/resourcesdir.rs:152` (compile-time constant), `kitty/graphics_image.rs`
|
||||
(`Path::new(OsStr::from_bytes(path))`: bytes from the protocol).
|
||||
* Sprite `Path::new()` hits are the vector `Path` type, not `std::path`.
|
||||
|
||||
Remaining known gaps: argv items of `config::Command`/termio `Command` and
|
||||
`RepeatableStringMap` stay encoded `String`s (decoded at exec); Windows
|
||||
`CreateProcessW` argv/path use WTF-16 via `EncodedOs::from_encoded` of those
|
||||
items; macOS and Windows behaviour is compile-checked (`check-macos` for both
|
||||
Apple targets, `check-windows`) plus platform independent unit tests, not run
|
||||
natively.
|
||||
Reference in new issue
Block a user