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        unsafe {
142            env::set_var("GHOSTTY_RESOURCES_DIR", &path);
143        }
144        return;
145    }
146
147    if let Some(path) = build_runtime_resources_dir() {
148        unsafe {
149            env::set_var("GHOSTTY_RESOURCES_DIR", &path);
150        }
151        return;
152    }
153
154    if let Some(path) = default_installed_runtime_dir().filter(|path| path.exists()) {
155        unsafe {
156            env::set_var("GHOSTTY_RESOURCES_DIR", &path);
157        }
158    }
159}
160
161pub fn runtime_resources_dir() -> Option<PathBuf> {
162    if let Some(path) = env::var_os("GHOSTTY_RESOURCES_DIR")
163        .map(PathBuf::from)
164        .filter(|path| path.exists())
165    {
166        return Some(path);
167    }
168
169    if let Some(path) = explicit_runtime_dir().filter(|path| path.exists()) {
170        return Some(path);
171    }
172
173    if let Some(path) = build_runtime_resources_dir() {
174        return Some(path);
175    }
176
177    default_installed_runtime_dir().filter(|path| path.exists())
178}
179
180pub fn runtime_bridge_path() -> Option<PathBuf> {
181    if let Some(path) = env::var_os("TASKERS_GHOSTTY_BRIDGE_PATH")
182        .map(PathBuf::from)
183        .filter(|path| path.exists())
184    {
185        return Some(path);
186    }
187
188    if let Some(path) = explicit_runtime_dir()
189        .map(|root| root.join("lib").join(BRIDGE_LIBRARY_NAME))
190        .filter(|path| path.exists())
191    {
192        return Some(path);
193    }
194
195    if let Some(path) = build_runtime_bridge_path() {
196        return Some(path);
197    }
198
199    default_installed_runtime_dir()
200        .map(|root| root.join("lib").join(BRIDGE_LIBRARY_NAME))
201        .filter(|path| path.exists())
202}
203
204fn unpack_bundle<R: Read>(reader: R, staging_root: &Path) -> Result<(), RuntimeBootstrapError> {
205    let decoder = XzDecoder::new(reader);
206    let mut archive = Archive::new(decoder);
207    archive
208        .unpack(staging_root)
209        .map_err(|error| RuntimeBootstrapError::UnpackBundle {
210            path: staging_root.to_path_buf(),
211            message: error.to_string(),
212        })
213}
214
215fn validate_bundle(ghostty_dir: &Path, terminfo_dir: &Path) -> Result<(), RuntimeBootstrapError> {
216    if !ghostty_dir.join("lib").join(BRIDGE_LIBRARY_NAME).exists() {
217        return Err(RuntimeBootstrapError::MissingBundlePath {
218            path: "ghostty/lib/libtaskers_ghostty_bridge.so",
219        });
220    }
221    if !terminfo_dir.join(TERMINFO_GHOSTTY_PATH).exists()
222        && !terminfo_dir.join(TERMINFO_XTERM_GHOSTTY_PATH).exists()
223    {
224        return Err(RuntimeBootstrapError::MissingBundlePath {
225            path: "terminfo/g/ghostty or terminfo/x/xterm-ghostty",
226        });
227    }
228    Ok(())
229}
230
231fn installed_runtime_is_current(runtime_dir: &Path) -> bool {
232    if !runtime_dir.join("lib").join(BRIDGE_LIBRARY_NAME).exists() {
233        return false;
234    }
235
236    let Some(taskers_root) = runtime_dir.parent() else {
237        return false;
238    };
239    let terminfo_dir = taskers_root.join("terminfo");
240    if !terminfo_dir.join(TERMINFO_GHOSTTY_PATH).exists()
241        && !terminfo_dir.join(TERMINFO_XTERM_GHOSTTY_PATH).exists()
242    {
243        return false;
244    }
245
246    match fs::read_to_string(runtime_dir.join(RUNTIME_VERSION_FILE)) {
247        Ok(version) => version.trim() == env!("CARGO_PKG_VERSION"),
248        Err(_) => true,
249    }
250}
251
252fn build_runtime_ready() -> bool {
253    build_runtime_bridge_path().is_some() && build_runtime_resources_dir().is_some()
254}
255
256fn build_runtime_bridge_path() -> Option<PathBuf> {
257    option_env!("TASKERS_GHOSTTY_BUILD_BRIDGE_PATH")
258        .map(PathBuf::from)
259        .filter(|path| path.exists())
260}
261
262fn build_runtime_resources_dir() -> Option<PathBuf> {
263    option_env!("TASKERS_GHOSTTY_BUILD_RESOURCES_DIR")
264        .map(PathBuf::from)
265        .filter(|path| path.exists())
266}
267
268fn installed_runtime_dir() -> Option<PathBuf> {
269    explicit_runtime_dir().or_else(default_installed_runtime_dir)
270}
271
272fn explicit_runtime_dir() -> Option<PathBuf> {
273    env::var_os("TASKERS_GHOSTTY_RUNTIME_DIR").map(PathBuf::from)
274}
275
276fn default_installed_runtime_dir() -> Option<PathBuf> {
277    if let Some(path) = env::var_os("XDG_DATA_HOME")
278        .map(PathBuf::from)
279        .map(|path| path.join("taskers").join("ghostty"))
280    {
281        return Some(path);
282    }
283
284    env::var_os("HOME").map(PathBuf::from).map(|path| {
285        path.join(".local")
286            .join("share")
287            .join("taskers")
288            .join("ghostty")
289    })
290}
291
292fn default_runtime_bundle_url() -> String {
293    format!(
294        "https://github.com/OneNoted/taskers/releases/download/v{version}/taskers-ghostty-runtime-v{version}-{target}.tar.xz",
295        version = env!("CARGO_PKG_VERSION"),
296        target = option_env!("TASKERS_BUILD_TARGET").unwrap_or("x86_64-unknown-linux-gnu"),
297    )
298}
299
300fn replace_directory(source: &Path, destination: &Path) -> Result<(), RuntimeBootstrapError> {
301    if let Some(parent) = destination.parent() {
302        fs::create_dir_all(parent).map_err(|error| RuntimeBootstrapError::CreateDir {
303            path: parent.to_path_buf(),
304            message: error.to_string(),
305        })?;
306    }
307    remove_path_if_exists(destination)?;
308    fs::rename(source, destination).map_err(|error| RuntimeBootstrapError::RenamePath {
309        from: source.to_path_buf(),
310        to: destination.to_path_buf(),
311        message: error.to_string(),
312    })
313}
314
315fn remove_path_if_exists(path: &Path) -> Result<(), RuntimeBootstrapError> {
316    let Ok(metadata) = fs::symlink_metadata(path) else {
317        return Ok(());
318    };
319    if metadata.is_dir() {
320        fs::remove_dir_all(path).map_err(|error| RuntimeBootstrapError::RemovePath {
321            path: path.to_path_buf(),
322            message: error.to_string(),
323        })
324    } else {
325        fs::remove_file(path).map_err(|error| RuntimeBootstrapError::RemovePath {
326            path: path.to_path_buf(),
327            message: error.to_string(),
328        })
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::{
335        RUNTIME_VERSION_FILE, RuntimeBootstrap, ensure_runtime_installed, runtime_bridge_path,
336        runtime_resources_dir,
337    };
338    use std::{env, fs, path::Path};
339    use tar::Builder;
340    use tempfile::tempdir;
341    use xz2::write::XzEncoder;
342
343    #[test]
344    fn local_bundle_bootstrap_installs_runtime_layout() {
345        let temp = tempdir().expect("tempdir");
346        let bundle_path = temp.path().join("ghostty-runtime.tar.xz");
347        let runtime_dir = temp.path().join("taskers").join("ghostty");
348        let terminfo_dir = temp.path().join("taskers").join("terminfo");
349
350        let bundle_source = temp.path().join("bundle-source");
351        fs::create_dir_all(bundle_source.join("ghostty/lib")).expect("ghostty lib dir");
352        fs::create_dir_all(bundle_source.join("ghostty/shell-integration/bash"))
353            .expect("shell integration dir");
354        fs::create_dir_all(bundle_source.join("terminfo/x")).expect("terminfo dir");
355        fs::write(
356            bundle_source
357                .join("ghostty")
358                .join("lib")
359                .join("libtaskers_ghostty_bridge.so"),
360            b"fake bridge",
361        )
362        .expect("write fake bridge");
363        fs::write(
364            bundle_source
365                .join("ghostty")
366                .join("shell-integration")
367                .join("bash")
368                .join("ghostty.bash"),
369            b"echo ghostty",
370        )
371        .expect("write fake shell integration");
372        fs::write(
373            bundle_source
374                .join("terminfo")
375                .join("x")
376                .join("xterm-ghostty"),
377            b"fake terminfo",
378        )
379        .expect("write fake terminfo");
380        write_bundle(&bundle_source, &bundle_path);
381
382        let _guard = EnvGuard::set([
383            (
384                "TASKERS_GHOSTTY_RUNTIME_BUNDLE_PATH",
385                Some(bundle_path.as_os_str()),
386            ),
387            ("TASKERS_GHOSTTY_RUNTIME_DIR", Some(runtime_dir.as_os_str())),
388            ("TASKERS_GHOSTTY_BRIDGE_PATH", None),
389            ("GHOSTTY_RESOURCES_DIR", None),
390            ("XDG_DATA_HOME", None),
391        ]);
392
393        let result = ensure_runtime_installed().expect("runtime install");
394        assert_eq!(
395            result,
396            Some(RuntimeBootstrap {
397                runtime_dir: runtime_dir.clone(),
398            })
399        );
400        assert!(
401            runtime_dir
402                .join("lib")
403                .join("libtaskers_ghostty_bridge.so")
404                .exists()
405        );
406        assert!(
407            runtime_dir
408                .join("shell-integration")
409                .join("bash")
410                .join("ghostty.bash")
411                .exists()
412        );
413        assert!(terminfo_dir.join("x").join("xterm-ghostty").exists());
414        assert_eq!(
415            fs::read_to_string(runtime_dir.join(RUNTIME_VERSION_FILE))
416                .expect("runtime version marker")
417                .trim(),
418            env!("CARGO_PKG_VERSION")
419        );
420        assert_eq!(
421            runtime_bridge_path(),
422            Some(runtime_dir.join("lib").join("libtaskers_ghostty_bridge.so"))
423        );
424        assert_eq!(runtime_resources_dir(), Some(runtime_dir));
425    }
426
427    fn write_bundle(source_dir: &Path, bundle_path: &Path) {
428        let file = fs::File::create(bundle_path).expect("create bundle");
429        let encoder = XzEncoder::new(file, 9);
430        let mut builder = Builder::new(encoder);
431        builder
432            .append_dir_all("ghostty", source_dir.join("ghostty"))
433            .expect("append ghostty");
434        builder
435            .append_dir_all("terminfo", source_dir.join("terminfo"))
436            .expect("append terminfo");
437        let encoder = builder.into_inner().expect("finish tar");
438        encoder.finish().expect("finish xz");
439    }
440
441    struct EnvGuard {
442        saved: Vec<(String, Option<std::ffi::OsString>)>,
443    }
444
445    impl EnvGuard {
446        fn set<const N: usize>(entries: [(&str, Option<&std::ffi::OsStr>); N]) -> Self {
447            let mut saved = Vec::with_capacity(N);
448            for (key, value) in entries {
449                saved.push((key.to_string(), env::var_os(key)));
450                unsafe {
451                    match value {
452                        Some(value) => env::set_var(key, value),
453                        None => env::remove_var(key),
454                    }
455                }
456            }
457            Self { saved }
458        }
459    }
460
461    impl Drop for EnvGuard {
462        fn drop(&mut self) {
463            for (key, value) in self.saved.drain(..).rev() {
464                unsafe {
465                    match value {
466                        Some(value) => env::set_var(&key, value),
467                        None => env::remove_var(&key),
468                    }
469                }
470            }
471        }
472    }
473}