feat-deviant(os): embed a configurable resource directory

Relocating a Cargo-installed binary loses executable-relative resource
lookup even when themes and shell integration exist under /usr/share.
Allow packagers to set GHOSTTY_BUILD_RESOURCES_DIR when compiling.

Absolute paths are used directly; relative paths resolve against the
running executable's directory for relocatable installations. Explicit
paths do not require terminfo sentinels. A nonempty runtime override wins
in every profile when the build option is configured. Unset or empty
build options preserve existing discovery, including debug precedence.
Cargo watches the build option so changing or unsetting it recompiles
lookup. Resource files still need to be installed separately.

Document this requested upstream deviation and the build option in the
README, architecture overview, and deviation register.

Test Plan:
- Eight resource lookup unit tests pass.
- Nightly formatting and ghostty-os Clippy with -D warnings pass.
- Production build with /usr/share/ghostty succeeds; a copied executable
  lists all 463 themes with no runtime override.
- Compiled probes verify absolute, relative, unset, and empty options,
  Cargo rebuilds, relocation, runtime overrides, and unrelated cwd.

Deviation-Register: docs/rewrite/DEVIANT_FIXES.md
This commit is contained in:
ddidderr committed 2026-10-13 00:07:15 +02:00
1 parent 4cfeee71cc
commit 85489a164e
5 files changed
+142 -15

No files matched your search

+26
View File
@@ -61,6 +61,32 @@ just install /usr/local # binary, share/ghostty, terminfo, desktop file
just install /usr false true # system-package layout (lib/systemd/user) just install /usr false true # system-package layout (lib/systemd/user)
``` ```
### Build-time resource directory
Set `GHOSTTY_BUILD_RESOURCES_DIR` while building or installing to embed an
explicit resource directory:
```sh
GHOSTTY_BUILD_RESOURCES_DIR=/usr/share/ghostty cargo build --profile production
GHOSTTY_BUILD_RESOURCES_DIR=/usr/share/ghostty cargo install --path . --profile production
GHOSTTY_BUILD_RESOURCES_DIR=../share/ghostty cargo build --profile production
```
Absolute paths are used directly. Relative paths resolve from the running
executable's directory, not the current working directory; `../share/ghostty`
allows moving an installed `bin/` and `share/` tree together. The directory
contains `themes/` and `shell-integration/`; these files still need to be installed
separately (`cargo install` only installs the executable).
A nonempty runtime `GHOSTTY_RESOURCES_DIR` overrides the embedded path in every
profile. The embedded path takes precedence over automatic resource discovery
and does not require a terminfo sentinel. An unset or empty build option keeps
the existing discovery rules, including the debug profile's preference for
installed resources over the runtime override. Changing or unsetting the build
option causes Cargo to rebuild resource lookup. Paths are literal: `~` and shell
variables are not expanded by Ghostty. Flatpak's sandbox resource layout remains
managed by its runtime integration.
## Testing ## Testing
```sh ```sh
+2
View File
@@ -5,6 +5,8 @@
fn main() { fn main() {
println!("cargo:rerun-if-changed=build.rs"); println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-env-changed=PROFILE"); println!("cargo:rerun-if-env-changed=PROFILE");
// Recompile resource lookup when the packager changes the embedded path.
println!("cargo:rerun-if-env-changed=GHOSTTY_BUILD_RESOURCES_DIR");
println!("cargo:rustc-check-cfg=cfg(ghostty_debug_build)"); println!("cargo:rustc-check-cfg=cfg(ghostty_debug_build)");
if std::env::var("PROFILE").is_ok_and(|p| p == "debug") { if std::env::var("PROFILE").is_ok_and(|p| p == "debug") {
println!("cargo:rustc-cfg=ghostty_debug_build"); println!("cargo:rustc-cfg=ghostty_debug_build");
+103 -13
View File
@@ -73,6 +73,11 @@ const DEV_SENTINELS: [&str; 1] = ["terminfo/x/xterm-ghostty"];
/// Zig's `builtin.mode == .Debug` is mapped to the Cargo `dev` profile /// Zig's `builtin.mode == .Debug` is mapped to the Cargo `dev` profile
/// (`cfg(ghostty_debug_build)`, set by `build.rs`), not to `debug_assertions`: /// (`cfg(ghostty_debug_build)`, set by `build.rs`), not to `debug_assertions`:
/// the repository's `release` profile enables assertions like ReleaseSafe. /// the repository's `release` profile enables assertions like ReleaseSafe.
///
/// `GHOSTTY_BUILD_RESOURCES_DIR` optionally embeds an absolute path or a path
/// relative to the executable directory. When configured, a nonempty runtime
/// `GHOSTTY_RESOURCES_DIR` overrides it in every profile; automatic discovery
/// applies only when the build option is unset or empty.
pub fn resources_dir() -> io::Result<ResourcesDir> { pub fn resources_dir() -> io::Result<ResourcesDir> {
let exe = std::env::current_exe().ok(); let exe = std::env::current_exe().ok();
let env = std::env::var_os("GHOSTTY_RESOURCES_DIR"); let env = std::env::var_os("GHOSTTY_RESOURCES_DIR");
@@ -80,23 +85,50 @@ pub fn resources_dir() -> io::Result<ResourcesDir> {
cfg!(ghostty_debug_build), cfg!(ghostty_debug_build),
exe.as_deref(), exe.as_deref(),
env, env,
option_env!("GHOSTTY_BUILD_RESOURCES_DIR").map(Path::new),
)) ))
} }
fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<OsString>) -> ResourcesDir { /// An explicit build path takes precedence over automatic discovery. Relative
/// build paths are anchored to the executable, never the working directory.
fn resources_dir_impl(
debug: bool,
exe: Option<&Path>,
env: Option<OsString>,
build_dir: Option<&Path>,
) -> ResourcesDir {
let build_dir = build_dir.filter(|dir| !dir.as_os_str().is_empty());
// Use the GHOSTTY_RESOURCES_DIR environment variable in release builds. // Use the GHOSTTY_RESOURCES_DIR environment variable in release builds.
// //
// In debug builds we try using terminfo detection first instead, since // In debug builds we try using terminfo detection first instead, since
// if debug Ghostty is launched by an older version of Ghostty, it // if debug Ghostty is launched by an older version of Ghostty, it
// would inherit the old, stale resources of older Ghostty instead of the // would inherit the old, stale resources of older Ghostty instead of the
// freshly built ones under zig-out/share/ghostty. // freshly built ones under zig-out/share/ghostty.
if !debug && let Some(dir) = env.as_ref().filter(|d| !d.is_empty()) { // Configured builds allow the runtime override in every profile. Without a
// build path, retain upstream's debug preference for automatic discovery.
if (!debug || build_dir.is_some())
&& let Some(dir) = env.as_ref().filter(|d| !d.is_empty())
{
return ResourcesDir { return ResourcesDir {
app_path: Some(EncodedOs::from_os(dir)), app_path: Some(EncodedOs::from_os(dir)),
host_path: None, host_path: None,
}; };
} }
if let Some(dir) = build_dir {
let path = if dir.is_absolute() {
Some(dir.to_path_buf())
} else {
exe.and_then(Path::parent).map(|base| base.join(dir))
};
if let Some(path) = path {
return ResourcesDir {
app_path: Some(enc(&path)),
host_path: None,
};
}
}
// Get the path to our running binary // Get the path to our running binary
let Some(mut exe) = exe else { let Some(mut exe) = exe else {
return ResourcesDir::default(); return ResourcesDir::default();
@@ -215,7 +247,7 @@ mod tests {
fn detects_share_dir_from_exe() { fn detects_share_dir_from_exe() {
let td = make_tree(); let td = make_tree();
let exe = td.path().join("bin/ghostty"); let exe = td.path().join("bin/ghostty");
let r = resources_dir_impl(true, Some(&exe), None); let r = resources_dir_impl(true, Some(&exe), None, None);
let expected = format!("{}/share/ghostty", td.path().display()); let expected = format!("{}/share/ghostty", td.path().display());
assert_eq!(r.app().map(EncodedOs::as_encoded), Some(expected.as_str())); assert_eq!(r.app().map(EncodedOs::as_encoded), Some(expected.as_str()));
} }
@@ -226,27 +258,27 @@ mod tests {
let exe = td.path().join("bin/ghostty"); let exe = td.path().join("bin/ghostty");
let env = Some(OsString::from("/env/res")); let env = Some(OsString::from("/env/res"));
assert_eq!( assert_eq!(
resources_dir_impl(false, Some(&exe), env.clone()) resources_dir_impl(false, Some(&exe), env.clone(), None)
.app() .app()
.map(EncodedOs::as_encoded), .map(EncodedOs::as_encoded),
Some("/env/res") Some("/env/res")
); );
assert_ne!( assert_ne!(
resources_dir_impl(true, Some(&exe), env.clone()) resources_dir_impl(true, Some(&exe), env.clone(), None)
.app() .app()
.map(EncodedOs::as_encoded), .map(EncodedOs::as_encoded),
Some("/env/res") Some("/env/res")
); );
// Debug fallback when detection fails. // Debug fallback when detection fails.
assert_eq!( assert_eq!(
resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env) resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), env, None)
.app() .app()
.map(EncodedOs::as_encoded), .map(EncodedOs::as_encoded),
Some("/env/res") Some("/env/res")
); );
// Empty env is ignored. // Empty env is ignored.
assert_eq!( assert_eq!(
resources_dir_impl(false, None, Some(OsString::new())) resources_dir_impl(false, None, Some(OsString::new()), None)
.app() .app()
.map(EncodedOs::as_encoded), .map(EncodedOs::as_encoded),
None None
@@ -256,25 +288,83 @@ mod tests {
#[test] #[test]
fn dev_fallback_finds_workspace_resources() { fn dev_fallback_finds_workspace_resources() {
let exe = workspace_root().join("target/debug/ghostty"); let exe = workspace_root().join("target/debug/ghostty");
let r = resources_dir_impl(true, Some(&exe), None); let r = resources_dir_impl(true, Some(&exe), None, None);
assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources"))); assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources")));
// Outside the workspace there is no fallback. // Outside the workspace there is no fallback.
let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None); let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None, None);
assert_eq!(r.app().map(EncodedOs::as_encoded), None); assert_eq!(r.app().map(EncodedOs::as_encoded), None);
// Release builds run from the workspace use it too... // Release builds run from the workspace use it too...
let exe = workspace_root().join("target/production/ghostty"); let exe = workspace_root().join("target/production/ghostty");
let r = resources_dir_impl(false, Some(&exe), None); let r = resources_dir_impl(false, Some(&exe), None, None);
assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources"))); assert!(r.app().is_some_and(|d| d.ends_with_plain("/resources")));
// ...but GHOSTTY_RESOURCES_DIR still takes precedence there. // ...but GHOSTTY_RESOURCES_DIR still takes precedence there.
let r = resources_dir_impl(false, Some(&exe), Some("/env/res".into())); let r = resources_dir_impl(false, Some(&exe), Some("/env/res".into()), None);
assert_eq!(r.app_os().unwrap(), Path::new("/env/res")); assert_eq!(r.app_os().unwrap(), Path::new("/env/res"));
} }
#[test]
fn configured_absolute_dir_overrides_discovery_without_requiring_exe() {
let td = make_tree();
let exe = td.path().join("bin/ghostty");
let configured = Path::new("/configured/resources");
for debug in [false, true] {
for exe in [Some(exe.as_path()), None] {
let r = resources_dir_impl(debug, exe, None, Some(configured));
assert_eq!(r.app_os().as_deref(), Some(configured));
assert_eq!(r.host_os(), r.app_os());
}
}
}
#[test]
fn configured_relative_dir_is_relative_to_executable() {
let td = make_tree();
for name in ["bin/ghostty", "relocated/bin/ghostty"] {
let exe = td.path().join(name);
let expected = exe.parent().unwrap().join("../share/ghostty");
for debug in [false, true] {
let r = resources_dir_impl(
debug,
Some(&exe),
None,
Some(Path::new("../share/ghostty")),
);
assert_eq!(r.app_os(), Some(expected.clone()));
}
}
// Without an executable path a relative directory cannot be resolved.
assert_eq!(
resources_dir_impl(false, None, None, Some(Path::new("relative"))),
ResourcesDir::default()
);
}
#[test]
fn runtime_env_overrides_configured_dir_in_every_profile() {
let td = make_tree();
let exe = td.path().join("bin/ghostty");
let configured = Path::new("/configured/resources");
for debug in [false, true] {
let r = resources_dir_impl(
debug,
Some(&exe),
Some("/runtime/resources".into()),
Some(configured),
);
assert_eq!(r.app_os().unwrap(), Path::new("/runtime/resources"));
let r = resources_dir_impl(debug, Some(&exe), Some(OsString::new()), Some(configured));
assert_eq!(r.app_os().as_deref(), Some(configured));
// An empty build option preserves automatic discovery.
let r = resources_dir_impl(debug, Some(&exe), None, Some(Path::new("")));
assert_eq!(r.app_os().unwrap(), td.path().join("share/ghostty"));
}
}
#[test] #[test]
fn non_utf8_env_dir_is_honored_and_exe_dir_detected() { fn non_utf8_env_dir_is_honored_and_exe_dir_detected() {
use std::os::unix::ffi::{OsStrExt, OsStringExt}; use std::os::unix::ffi::{OsStrExt, OsStringExt};
let env = OsString::from_vec(b"/env/\xffres".to_vec()); let env = OsString::from_vec(b"/env/\xffres".to_vec());
let r = resources_dir_impl(false, None, Some(env.clone())); let r = resources_dir_impl(false, None, Some(env.clone()), None);
assert_eq!(r.app_os().unwrap().as_os_str(), env.as_os_str()); assert_eq!(r.app_os().unwrap().as_os_str(), env.as_os_str());
// Executable lives under a directory with a non-UTF-8 name. // Executable lives under a directory with a non-UTF-8 name.
@@ -283,7 +373,7 @@ mod tests {
std::fs::create_dir_all(root.join("share/terminfo/x")).unwrap(); std::fs::create_dir_all(root.join("share/terminfo/x")).unwrap();
std::fs::write(root.join("share/terminfo/x/xterm-ghostty"), b"").unwrap(); std::fs::write(root.join("share/terminfo/x/xterm-ghostty"), b"").unwrap();
std::fs::create_dir_all(root.join("bin")).unwrap(); std::fs::create_dir_all(root.join("bin")).unwrap();
let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None); let r = resources_dir_impl(true, Some(&root.join("bin/ghostty")), None, None);
assert_eq!(r.app_os().unwrap(), root.join("share/ghostty")); assert_eq!(r.app_os().unwrap(), root.join("share/ghostty"));
} }
} }
+8
View File
@@ -70,6 +70,14 @@ ghostty-bench (benchmarks), ghostty-build-data (completions, man pages, syntax).
Non-code: `resources/` (shell-integration, themes, terminfo), `dist/` (desktop Non-code: `resources/` (shell-integration, themes, terminfo), `dist/` (desktop
integration), `po/` (translations), crate-local `res/`/`shaders/`/`resources/`. integration), `po/` (translations), crate-local `res/`/`shaders/`/`resources/`.
Resource lookup: `ghostty-os::resourcesdir` accepts the optional build-time
`GHOSTTY_BUILD_RESOURCES_DIR` path (watched by `ghostty-os/build.rs`). Runtime
`GHOSTTY_RESOURCES_DIR` overrides a configured build path; otherwise that path
precedes automatic discovery. Relative build paths resolve from the executable's
directory, allowing relocatable installations. Without a build path, upstream's
environment/terminfo lookup and the checkout resource fallback apply. See
`README.md` (Build-time resource directory) and `DEVIANT_FIXES.md`.
Build inputs: `ghostty-build-info/build.rs` derives version metadata from the Build inputs: `ghostty-build-info/build.rs` derives version metadata from the
workspace manifest and Git HEAD/refs, watching `packed-refs` only when it exists workspace manifest and Git HEAD/refs, watching `packed-refs` only when it exists
(packing loose refs also changes the watched `refs` directory). The GTK build (packing loose refs also changes the watched `refs` directory). The GTK build
+3 -2
View File
@@ -83,7 +83,7 @@ the port matches Ghostty.
- Windows: no file locking in the ssh-cache `DiskCache` (Zig does not lock on Windows either). - Windows: no file locking in the ssh-cache `DiskCache` (Zig does not lock on Windows either).
- Config theme-name basename and numeric parsing follow Zig 0.15.2, not 0.16 (`fix(config)` commits 3068e3a, a57ff41). - Config theme-name basename and numeric parsing follow Zig 0.15.2, not 0.16 (`fix(config)` commits 3068e3a, a57ff41).
## Other deliberate deviations (platform/Rust-imposed or out of scope) ## Other deliberate deviations (platform/Rust-imposed or explicitly requested)
Only entries with an observable behavioural difference are listed. Only entries with an observable behavioural difference are listed.
@@ -105,7 +105,8 @@ Only entries with an observable behavioural difference are listed.
| OSC 52 base64 acceptance | Surface.zig:2149,2163 rules | Was different in both directions (Codex core audit); fixed to match Zig | `notes/review-core-codex.md` | 2f58f02 | | OSC 52 base64 acceptance | Surface.zig:2149,2163 rules | Was different in both directions (Codex core audit); fixed to match Zig | `notes/review-core-codex.md` | 2f58f02 |
| Benchmark / synthetic tooling extensions | `ghostty-bench` has no `--time` option and only the simplified terminal handler; `ghostty-synthetic` has the actions `ascii`, `osc`, `utf8` | `ghostty-bench --time` prints the measured step time to stderr; `ghostty-bench --handler=full` runs the full `stream_readonly` handler instead of the print-only one; `ghostty-synthetic +sgr` generates SGR-heavy output. Dev/benchmark tooling only; otherwise upstream behaviour is unchanged | `crates/ghostty-bench/src/main.rs`, `src/benches.rs`, `src/bin/ghostty-synthetic.rs`; `notes/review-rv16-codex.md` | 0c4f0ee | | Benchmark / synthetic tooling extensions | `ghostty-bench` has no `--time` option and only the simplified terminal handler; `ghostty-synthetic` has the actions `ascii`, `osc`, `utf8` | `ghostty-bench --time` prints the measured step time to stderr; `ghostty-bench --handler=full` runs the full `stream_readonly` handler instead of the print-only one; `ghostty-synthetic +sgr` generates SGR-heavy output. Dev/benchmark tooling only; otherwise upstream behaviour is unchanged | `crates/ghostty-bench/src/main.rs`, `src/benches.rs`, `src/bin/ghostty-synthetic.rs`; `notes/review-rv16-codex.md` | 0c4f0ee |
| Inspector font rasterizer (Windows, Apple, wasm) | Dear ImGui is built with `IMGUI_ENABLE_FREETYPE` (`pkg/dcimgui`), so the 32 px inspector font is rasterized by FreeType | Linux and other Unix targets: FreeType (`dear-imgui-rs` feature `freetype`, system FreeType through pkg-config), 32 px. Windows, Apple and wasm32 keep Dear ImGui's stb_truetype loader at 32 px, because `dear-imgui-sys` can only locate FreeType through pkg-config/vcpkg and those targets cannot be built or run here. Glyph anti-aliasing differs slightly there. | `crates/ghostty-inspector/Cargo.toml`, `src/inspector.rs` (`embedded_font_source`) | Z20 (uncommitted) | | Inspector font rasterizer (Windows, Apple, wasm) | Dear ImGui is built with `IMGUI_ENABLE_FREETYPE` (`pkg/dcimgui`), so the 32 px inspector font is rasterized by FreeType | Linux and other Unix targets: FreeType (`dear-imgui-rs` feature `freetype`, system FreeType through pkg-config), 32 px. Windows, Apple and wasm32 keep Dear ImGui's stb_truetype loader at 32 px, because `dear-imgui-sys` can only locate FreeType through pkg-config/vcpkg and those targets cannot be built or run here. Glyph anti-aliasing differs slightly there. | `crates/ghostty-inspector/Cargo.toml`, `src/inspector.rs` (`embedded_font_source`) | Z20 (uncommitted) |
| Development resources fallback | `resourcesDir` returns nothing when no terminfo bundle is found next to the binary | In the Cargo `dev` profile, if neither installed resources nor `GHOSTTY_RESOURCES_DIR` apply, the in-repo `<workspace>/resources` directory is used when the binary lives inside this workspace | `crates/ghostty-os/src/resourcesdir.rs` (`dev_resources_dir`) | 967e3a1 | | Build-time resource directory | No build-time resource path; relocated binaries need executable-relative terminfo detection or the runtime environment override | Optional `GHOSTTY_BUILD_RESOURCES_DIR` embeds an absolute path or a path relative to the executable directory. A nonempty runtime override wins in every profile when configured, then the build path; unset/empty retains existing discovery. No terminfo sentinel is required for an explicit path. User-requested packaging extension. | `crates/ghostty-os/src/resourcesdir.rs`, `crates/ghostty-os/build.rs`; tests `configured_absolute_dir_overrides_discovery_without_requiring_exe`, `configured_relative_dir_is_relative_to_executable`, `runtime_env_overrides_configured_dir_in_every_profile` | This commit (feat-deviant(os): embed a configurable resource directory) |
| Development resources fallback | `resourcesDir` returns nothing when no terminfo bundle is found next to the binary | In any Cargo profile, if neither installed resources nor `GHOSTTY_RESOURCES_DIR` apply, the in-repo `<workspace>/resources` directory is used when the binary lives inside this workspace | `crates/ghostty-os/src/resourcesdir.rs` (`dev_resources_dir`) | 967e3a1 |
## Maintenance ## Maintenance