Skip to main content

taskers_ghostty/
runtime.rs

1use std::{
2    env, fs,
3    io::Read,
4    path::{Path, PathBuf},
5};
6
7use tar::Archive;
8use thiserror::Error;
9use xz2::read::XzDecoder;
10
11const BRIDGE_LIBRARY_NAME: &str = "libtaskers_ghostty_bridge.so";
12const RUNTIME_VERSION_FILE: &str = ".taskers-runtime-version";
13const TERMINFO_GHOSTTY_PATH: &str = "g/ghostty";
14const TERMINFO_XTERM_GHOSTTY_PATH: &str = "x/xterm-ghostty";
15const BUNDLE_PATH_ENV: &str = "TASKERS_GHOSTTY_RUNTIME_BUNDLE_PATH";
16const BUNDLE_URL_ENV: &str = "TASKERS_GHOSTTY_RUNTIME_URL";
17const DISABLE_BOOTSTRAP_ENV: &str = "TASKERS_DISABLE_GHOSTTY_RUNTIME_BOOTSTRAP";
18
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub struct RuntimeBootstrap {
21    pub runtime_dir: PathBuf,
22}
23
24#[derive(Debug, Error)]
25pub enum RuntimeBootstrapError {
26    #[error("failed to create Ghostty runtime directory at {path}: {message}")]
27    CreateDir { path: PathBuf, message: String },
28    #[error("failed to remove existing Ghostty runtime path at {path}: {message}")]
29    RemovePath { path: PathBuf, message: String },
30    #[error("failed to rename Ghostty runtime path from {from} to {to}: {message}")]
31    RenamePath {
32        from: PathBuf,
33        to: PathBuf,
34        message: String,
35    },
36    #[error("failed to open Ghostty runtime bundle at {path}: {message}")]
37    OpenBundle { path: PathBuf, message: String },
38    #[error("failed to download Ghostty runtime bundle from {url}: {message}")]
39    DownloadBundle { url: String, message: String },
40    #[error("failed to unpack Ghostty runtime bundle into {path}: {message}")]
41    UnpackBundle { path: PathBuf, message: String },
42    #[error("Ghostty runtime bundle missing required file {path}")]
43    MissingBundlePath { path: &'static str },
44    #[error("failed to write Ghostty runtime version marker at {path}: {message}")]
45    WriteVersion { path: PathBuf, message: String },
46}
47
48pub fn ensure_runtime_installed() -> Result<Option<RuntimeBootstrap>, RuntimeBootstrapError> {
49    if env::var_os(DISABLE_BOOTSTRAP_ENV).is_some() {
50        return Ok(None);
51    }
52
53    let bundle_override =
54        env::var_os(BUNDLE_PATH_ENV).is_some() || env::var_os(BUNDLE_URL_ENV).is_some();
55    if !bundle_override && build_runtime_ready() {
56        return Ok(None);
57    }
58
59    let Some(runtime_dir) = installed_runtime_dir() else {
60        return Ok(None);
61    };
62    if !bundle_override && installed_runtime_is_current(&runtime_dir) {
63        return Ok(None);
64    }
65
66    let taskers_root = runtime_dir
67        .parent()
68        .expect("ghostty runtime dir should have a parent")
69        .to_path_buf();
70    fs::create_dir_all(&taskers_root).map_err(|error| RuntimeBootstrapError::CreateDir {
71        path: taskers_root.clone(),
72        message: error.to_string(),
73    })?;
74
75    let staging_root = taskers_root.join(format!(".ghostty-runtime-stage-{}", std::process::id()));
76    remove_path_if_exists(&staging_root)?;
77    fs::create_dir_all(&staging_root).map_err(|error| RuntimeBootstrapError::CreateDir {
78        path: staging_root.clone(),
79        message: error.to_string(),
80    })?;
81
82    let install_result = if let Some(bundle_path) = env::var_os(BUNDLE_PATH_ENV).map(PathBuf::from)
83    {
84        let file =
85            fs::File::open(&bundle_path).map_err(|error| RuntimeBootstrapError::OpenBundle {
86                path: bundle_path.clone(),
87                message: error.to_string(),
88            })?;
89        unpack_bundle(file, &staging_root)
90    } else {
91        let url = env::var(BUNDLE_URL_ENV).unwrap_or_else(|_| default_runtime_bundle_url());
92        let response =
93            ureq::get(&url)
94                .call()
95                .map_err(|error| RuntimeBootstrapError::DownloadBundle {
96                    url: url.clone(),
97                    message: error.to_string(),
98                })?;
99        unpack_bundle(response.into_reader(), &staging_root).map_err(|error| match error {
100            RuntimeBootstrapError::UnpackBundle { .. }
101            | RuntimeBootstrapError::MissingBundlePath { .. }
102            | RuntimeBootstrapError::WriteVersion { .. }
103            | RuntimeBootstrapError::CreateDir { .. }
104            | RuntimeBootstrapError::RemovePath { .. }
105            | RuntimeBootstrapError::RenamePath { .. }
106            | RuntimeBootstrapError::OpenBundle { .. }
107            | RuntimeBootstrapError::DownloadBundle { .. } => error,
108        })
109    };
110    if let Err(error) = install_result {
111        let _ = remove_path_if_exists(&staging_root);
112        return Err(error);
113    }
114
115    let ghostty_stage = staging_root.join("ghostty");
116    let terminfo_stage = staging_root.join("terminfo");
117    validate_bundle(&ghostty_stage, &terminfo_stage)?;
118
119    let version_marker_path = ghostty_stage.join(RUNTIME_VERSION_FILE);
120    fs::write(&version_marker_path, env!("CARGO_PKG_VERSION")).map_err(|error| {
121        RuntimeBootstrapError::WriteVersion {
122            path: version_marker_path.clone(),
123            message: error.to_string(),
124        }
125    })?;
126
127    let terminfo_dir = taskers_root.join("terminfo");
128    replace_directory(&ghostty_stage, &runtime_dir)?;
129    replace_directory(&terminfo_stage, &terminfo_dir)?;
130    let _ = remove_path_if_exists(&staging_root);
131
132    Ok(Some(RuntimeBootstrap { runtime_dir }))
133}
134
135pub fn configure_runtime_environment() {
136    if env::var_os("GHOSTTY_RESOURCES_DIR").is_some() {
137        return;
138    }
139
140    if let Some(path) = explicit_runtime_dir().filter(|path| path.exists()) {
141        set_runtime_environment_vars(&path);
142        return;
143    }
144
145    if let Some(path) = build_runtime_resources_dir() {
146        set_runtime_environment_vars(&path);
147        return;
148    }
149
150    if let Some(path) = default_installed_runtime_dir().filter(|path| path.exists()) {
151        set_runtime_environment_vars(&path);
152    }
153}
154
155pub fn runtime_resources_dir() -> Option<PathBuf> {
156    if let Some(path) = env::var_os("GHOSTTY_RESOURCES_DIR")
157        .map(PathBuf::from)
158        .filter(|path| path.exists())
159    {
160        return Some(path);
161    }
162
163    if let Some(path) = explicit_runtime_dir().filter(|path| path.exists()) {
164        return Some(path);
165    }
166
167    if let Some(path) = build_runtime_resources_dir() {
168        return Some(path);
169    }
170
171    default_installed_runtime_dir().filter(|path| path.exists())
172}
173
174pub fn runtime_bridge_path() -> Option<PathBuf> {
175    if let Some(path) = env::var_os("TASKERS_GHOSTTY_BRIDGE_PATH")
176        .map(PathBuf::from)
177        .filter(|path| path.exists())
178    {
179        return Some(path);
180    }
181
182    if let Some(path) = explicit_runtime_dir()
183        .map(|root| root.join("lib").join(BRIDGE_LIBRARY_NAME))
184        .filter(|path| path.exists())
185    {
186        return Some(path);
187    }
188
189    if let Some(path) = build_runtime_bridge_path() {
190        return Some(path);
191    }
192
193    default_installed_runtime_dir()
194        .map(|root| root.join("lib").join(BRIDGE_LIBRARY_NAME))
195        .filter(|path| path.exists())
196}
197
198fn unpack_bundle<R: Read>(reader: R, staging_root: &Path) -> Result<(), RuntimeBootstrapError> {
199    let decoder = XzDecoder::new(reader);
200    let mut archive = Archive::new(decoder);
201    archive
202        .unpack(staging_root)
203        .map_err(|error| RuntimeBootstrapError::UnpackBundle {
204            path: staging_root.to_path_buf(),
205            message: error.to_string(),
206        })
207}
208
209fn validate_bundle(ghostty_dir: &Path, terminfo_dir: &Path) -> Result<(), RuntimeBootstrapError> {
210    if !ghostty_dir.join("lib").join(BRIDGE_LIBRARY_NAME).exists() {
211        return Err(RuntimeBootstrapError::MissingBundlePath {
212            path: "ghostty/lib/libtaskers_ghostty_bridge.so",
213        });
214    }
215    if !terminfo_dir.join(TERMINFO_GHOSTTY_PATH).exists()
216        && !terminfo_dir.join(TERMINFO_XTERM_GHOSTTY_PATH).exists()
217    {
218        return Err(RuntimeBootstrapError::MissingBundlePath {
219            path: "terminfo/g/ghostty or terminfo/x/xterm-ghostty",
220        });
221    }
222    Ok(())
223}
224
225fn installed_runtime_is_current(runtime_dir: &Path) -> bool {
226    if !runtime_dir.join("lib").join(BRIDGE_LIBRARY_NAME).exists() {
227        return false;
228    }
229
230    let Some(taskers_root) = runtime_dir.parent() else {
231        return false;
232    };
233    let terminfo_dir = taskers_root.join("terminfo");
234    if !terminfo_dir.join(TERMINFO_GHOSTTY_PATH).exists()
235        && !terminfo_dir.join(TERMINFO_XTERM_GHOSTTY_PATH).exists()
236    {
237        return false;
238    }
239
240    match fs::read_to_string(runtime_dir.join(RUNTIME_VERSION_FILE)) {
241        Ok(version) => version.trim() == env!("CARGO_PKG_VERSION"),
242        Err(_) => true,
243    }
244}
245
246fn build_runtime_ready() -> bool {
247    build_runtime_bridge_path().is_some() && build_runtime_resources_dir().is_some()
248}
249
250fn build_runtime_bridge_path() -> Option<PathBuf> {
251    option_env!("TASKERS_GHOSTTY_BUILD_BRIDGE_PATH")
252        .map(PathBuf::from)
253        .filter(|path| path.exists())
254}
255
256fn build_runtime_resources_dir() -> Option<PathBuf> {
257    option_env!("TASKERS_GHOSTTY_BUILD_RESOURCES_DIR")
258        .map(PathBuf::from)
259        .filter(|path| path.exists())
260}
261
262fn set_runtime_environment_vars(path: &Path) {
263    unsafe {
264        env::set_var("GHOSTTY_RESOURCES_DIR", path);
265        env::set_var("TASKERS_GHOSTTY_RUNTIME_DIR", path);
266    }
267}
268
269fn installed_runtime_dir() -> Option<PathBuf> {
270    explicit_runtime_dir().or_else(default_installed_runtime_dir)
271}
272
273fn explicit_runtime_dir() -> Option<PathBuf> {
274    env::var_os("TASKERS_GHOSTTY_RUNTIME_DIR").map(PathBuf::from)
275}
276
277fn default_installed_runtime_dir() -> Option<PathBuf> {
278    Some(taskers_paths::default_ghostty_runtime_dir())
279}
280
281fn default_runtime_bundle_url() -> String {
282    format!(
283        "https://github.com/OneNoted/taskers/releases/download/v{version}/taskers-ghostty-runtime-v{version}-{target}.tar.xz",
284        version = env!("CARGO_PKG_VERSION"),
285        target = option_env!("TASKERS_BUILD_TARGET").unwrap_or("x86_64-unknown-linux-gnu"),
286    )
287}
288
289fn replace_directory(source: &Path, destination: &Path) -> Result<(), RuntimeBootstrapError> {
290    if let Some(parent) = destination.parent() {
291        fs::create_dir_all(parent).map_err(|error| RuntimeBootstrapError::CreateDir {
292            path: parent.to_path_buf(),
293            message: error.to_string(),
294        })?;
295    }
296    remove_path_if_exists(destination)?;
297    fs::rename(source, destination).map_err(|error| RuntimeBootstrapError::RenamePath {
298        from: source.to_path_buf(),
299        to: destination.to_path_buf(),
300        message: error.to_string(),
301    })
302}
303
304fn remove_path_if_exists(path: &Path) -> Result<(), RuntimeBootstrapError> {
305    let Ok(metadata) = fs::symlink_metadata(path) else {
306        return Ok(());
307    };
308    if metadata.is_dir() {
309        fs::remove_dir_all(path).map_err(|error| RuntimeBootstrapError::RemovePath {
310            path: path.to_path_buf(),
311            message: error.to_string(),
312        })
313    } else {
314        fs::remove_file(path).map_err(|error| RuntimeBootstrapError::RemovePath {
315            path: path.to_path_buf(),
316            message: error.to_string(),
317        })
318    }
319}
320
321#[cfg(test)]
322mod tests {
323    use super::{
324        RUNTIME_VERSION_FILE, RuntimeBootstrap, ensure_runtime_installed, runtime_bridge_path,
325        runtime_resources_dir,
326    };
327    use std::{env, fs, path::Path};
328    use tar::Builder;
329    use tempfile::tempdir;
330    use xz2::write::XzEncoder;
331
332    #[test]
333    fn local_bundle_bootstrap_installs_runtime_layout() {
334        let temp = tempdir().expect("tempdir");
335        let bundle_path = temp.path().join("ghostty-runtime.tar.xz");
336        let runtime_dir = temp.path().join("taskers").join("ghostty");
337        let terminfo_dir = temp.path().join("taskers").join("terminfo");
338
339        let bundle_source = temp.path().join("bundle-source");
340        fs::create_dir_all(bundle_source.join("ghostty/lib")).expect("ghostty lib dir");
341        fs::create_dir_all(bundle_source.join("ghostty/shell-integration/bash"))
342            .expect("shell integration dir");
343        fs::create_dir_all(bundle_source.join("terminfo/x")).expect("terminfo dir");
344        fs::write(
345            bundle_source
346                .join("ghostty")
347                .join("lib")
348                .join("libtaskers_ghostty_bridge.so"),
349            b"fake bridge",
350        )
351        .expect("write fake bridge");
352        fs::write(
353            bundle_source
354                .join("ghostty")
355                .join("shell-integration")
356                .join("bash")
357                .join("ghostty.bash"),
358            b"echo ghostty",
359        )
360        .expect("write fake shell integration");
361        fs::write(
362            bundle_source
363                .join("terminfo")
364                .join("x")
365                .join("xterm-ghostty"),
366            b"fake terminfo",
367        )
368        .expect("write fake terminfo");
369        write_bundle(&bundle_source, &bundle_path);
370
371        let _guard = EnvGuard::set([
372            (
373                "TASKERS_GHOSTTY_RUNTIME_BUNDLE_PATH",
374                Some(bundle_path.as_os_str()),
375            ),
376            ("TASKERS_DISABLE_GHOSTTY_RUNTIME_BOOTSTRAP", None),
377            ("TASKERS_GHOSTTY_RUNTIME_DIR", Some(runtime_dir.as_os_str())),
378            ("TASKERS_GHOSTTY_BRIDGE_PATH", None),
379            ("GHOSTTY_RESOURCES_DIR", None),
380            ("XDG_DATA_HOME", None),
381        ]);
382
383        let result = ensure_runtime_installed().expect("runtime install");
384        assert_eq!(
385            result,
386            Some(RuntimeBootstrap {
387                runtime_dir: runtime_dir.clone(),
388            })
389        );
390        assert!(
391            runtime_dir
392                .join("lib")
393                .join("libtaskers_ghostty_bridge.so")
394                .exists()
395        );
396        assert!(
397            runtime_dir
398                .join("shell-integration")
399                .join("bash")
400                .join("ghostty.bash")
401                .exists()
402        );
403        assert!(terminfo_dir.join("x").join("xterm-ghostty").exists());
404        assert_eq!(
405            fs::read_to_string(runtime_dir.join(RUNTIME_VERSION_FILE))
406                .expect("runtime version marker")
407                .trim(),
408            env!("CARGO_PKG_VERSION")
409        );
410        assert_eq!(
411            runtime_bridge_path(),
412            Some(runtime_dir.join("lib").join("libtaskers_ghostty_bridge.so"))
413        );
414        assert_eq!(runtime_resources_dir(), Some(runtime_dir));
415    }
416
417    #[test]
418    fn configure_runtime_environment_uses_explicit_runtime_dir() {
419        let temp = tempdir().expect("tempdir");
420        let runtime_dir = temp.path().join("taskers").join("ghostty");
421        fs::create_dir_all(&runtime_dir).expect("runtime dir");
422
423        let _guard = EnvGuard::set([
424            ("TASKERS_GHOSTTY_RUNTIME_DIR", Some(runtime_dir.as_os_str())),
425            ("GHOSTTY_RESOURCES_DIR", None),
426        ]);
427
428        super::configure_runtime_environment();
429
430        assert_eq!(
431            env::var_os("GHOSTTY_RESOURCES_DIR").map(std::path::PathBuf::from),
432            Some(runtime_dir.clone())
433        );
434        assert_eq!(
435            env::var_os("TASKERS_GHOSTTY_RUNTIME_DIR").map(std::path::PathBuf::from),
436            Some(runtime_dir)
437        );
438    }
439
440    fn write_bundle(source_dir: &Path, bundle_path: &Path) {
441        let file = fs::File::create(bundle_path).expect("create bundle");
442        let encoder = XzEncoder::new(file, 9);
443        let mut builder = Builder::new(encoder);
444        builder
445            .append_dir_all("ghostty", source_dir.join("ghostty"))
446            .expect("append ghostty");
447        builder
448            .append_dir_all("terminfo", source_dir.join("terminfo"))
449            .expect("append terminfo");
450        let encoder = builder.into_inner().expect("finish tar");
451        encoder.finish().expect("finish xz");
452    }
453
454    struct EnvGuard {
455        saved: Vec<(String, Option<std::ffi::OsString>)>,
456    }
457
458    impl EnvGuard {
459        fn set<const N: usize>(entries: [(&str, Option<&std::ffi::OsStr>); N]) -> Self {
460            let mut saved = Vec::with_capacity(N);
461            for (key, value) in entries {
462                saved.push((key.to_string(), env::var_os(key)));
463                unsafe {
464                    match value {
465                        Some(value) => env::set_var(key, value),
466                        None => env::remove_var(key),
467                    }
468                }
469            }
470            Self { saved }
471        }
472    }
473
474    impl Drop for EnvGuard {
475        fn drop(&mut self) {
476            for (key, value) in self.saved.drain(..).rev() {
477                unsafe {
478                    match value {
479                        Some(value) => env::set_var(&key, value),
480                        None => env::remove_var(&key),
481                    }
482                }
483            }
484        }
485    }
486}