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    Some(taskers_paths::default_ghostty_runtime_dir())
278}
279
280fn default_runtime_bundle_url() -> String {
281    format!(
282        "https://github.com/OneNoted/taskers/releases/download/v{version}/taskers-ghostty-runtime-v{version}-{target}.tar.xz",
283        version = env!("CARGO_PKG_VERSION"),
284        target = option_env!("TASKERS_BUILD_TARGET").unwrap_or("x86_64-unknown-linux-gnu"),
285    )
286}
287
288fn replace_directory(source: &Path, destination: &Path) -> Result<(), RuntimeBootstrapError> {
289    if let Some(parent) = destination.parent() {
290        fs::create_dir_all(parent).map_err(|error| RuntimeBootstrapError::CreateDir {
291            path: parent.to_path_buf(),
292            message: error.to_string(),
293        })?;
294    }
295    remove_path_if_exists(destination)?;
296    fs::rename(source, destination).map_err(|error| RuntimeBootstrapError::RenamePath {
297        from: source.to_path_buf(),
298        to: destination.to_path_buf(),
299        message: error.to_string(),
300    })
301}
302
303fn remove_path_if_exists(path: &Path) -> Result<(), RuntimeBootstrapError> {
304    let Ok(metadata) = fs::symlink_metadata(path) else {
305        return Ok(());
306    };
307    if metadata.is_dir() {
308        fs::remove_dir_all(path).map_err(|error| RuntimeBootstrapError::RemovePath {
309            path: path.to_path_buf(),
310            message: error.to_string(),
311        })
312    } else {
313        fs::remove_file(path).map_err(|error| RuntimeBootstrapError::RemovePath {
314            path: path.to_path_buf(),
315            message: error.to_string(),
316        })
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::{
323        RUNTIME_VERSION_FILE, RuntimeBootstrap, ensure_runtime_installed, runtime_bridge_path,
324        runtime_resources_dir,
325    };
326    use std::{env, fs, path::Path};
327    use tar::Builder;
328    use tempfile::tempdir;
329    use xz2::write::XzEncoder;
330
331    #[test]
332    fn local_bundle_bootstrap_installs_runtime_layout() {
333        let temp = tempdir().expect("tempdir");
334        let bundle_path = temp.path().join("ghostty-runtime.tar.xz");
335        let runtime_dir = temp.path().join("taskers").join("ghostty");
336        let terminfo_dir = temp.path().join("taskers").join("terminfo");
337
338        let bundle_source = temp.path().join("bundle-source");
339        fs::create_dir_all(bundle_source.join("ghostty/lib")).expect("ghostty lib dir");
340        fs::create_dir_all(bundle_source.join("ghostty/shell-integration/bash"))
341            .expect("shell integration dir");
342        fs::create_dir_all(bundle_source.join("terminfo/x")).expect("terminfo dir");
343        fs::write(
344            bundle_source
345                .join("ghostty")
346                .join("lib")
347                .join("libtaskers_ghostty_bridge.so"),
348            b"fake bridge",
349        )
350        .expect("write fake bridge");
351        fs::write(
352            bundle_source
353                .join("ghostty")
354                .join("shell-integration")
355                .join("bash")
356                .join("ghostty.bash"),
357            b"echo ghostty",
358        )
359        .expect("write fake shell integration");
360        fs::write(
361            bundle_source
362                .join("terminfo")
363                .join("x")
364                .join("xterm-ghostty"),
365            b"fake terminfo",
366        )
367        .expect("write fake terminfo");
368        write_bundle(&bundle_source, &bundle_path);
369
370        let _guard = EnvGuard::set([
371            (
372                "TASKERS_GHOSTTY_RUNTIME_BUNDLE_PATH",
373                Some(bundle_path.as_os_str()),
374            ),
375            ("TASKERS_GHOSTTY_RUNTIME_DIR", Some(runtime_dir.as_os_str())),
376            ("TASKERS_GHOSTTY_BRIDGE_PATH", None),
377            ("GHOSTTY_RESOURCES_DIR", None),
378            ("XDG_DATA_HOME", None),
379        ]);
380
381        let result = ensure_runtime_installed().expect("runtime install");
382        assert_eq!(
383            result,
384            Some(RuntimeBootstrap {
385                runtime_dir: runtime_dir.clone(),
386            })
387        );
388        assert!(
389            runtime_dir
390                .join("lib")
391                .join("libtaskers_ghostty_bridge.so")
392                .exists()
393        );
394        assert!(
395            runtime_dir
396                .join("shell-integration")
397                .join("bash")
398                .join("ghostty.bash")
399                .exists()
400        );
401        assert!(terminfo_dir.join("x").join("xterm-ghostty").exists());
402        assert_eq!(
403            fs::read_to_string(runtime_dir.join(RUNTIME_VERSION_FILE))
404                .expect("runtime version marker")
405                .trim(),
406            env!("CARGO_PKG_VERSION")
407        );
408        assert_eq!(
409            runtime_bridge_path(),
410            Some(runtime_dir.join("lib").join("libtaskers_ghostty_bridge.so"))
411        );
412        assert_eq!(runtime_resources_dir(), Some(runtime_dir));
413    }
414
415    fn write_bundle(source_dir: &Path, bundle_path: &Path) {
416        let file = fs::File::create(bundle_path).expect("create bundle");
417        let encoder = XzEncoder::new(file, 9);
418        let mut builder = Builder::new(encoder);
419        builder
420            .append_dir_all("ghostty", source_dir.join("ghostty"))
421            .expect("append ghostty");
422        builder
423            .append_dir_all("terminfo", source_dir.join("terminfo"))
424            .expect("append terminfo");
425        let encoder = builder.into_inner().expect("finish tar");
426        encoder.finish().expect("finish xz");
427    }
428
429    struct EnvGuard {
430        saved: Vec<(String, Option<std::ffi::OsString>)>,
431    }
432
433    impl EnvGuard {
434        fn set<const N: usize>(entries: [(&str, Option<&std::ffi::OsStr>); N]) -> Self {
435            let mut saved = Vec::with_capacity(N);
436            for (key, value) in entries {
437                saved.push((key.to_string(), env::var_os(key)));
438                unsafe {
439                    match value {
440                        Some(value) => env::set_var(key, value),
441                        None => env::remove_var(key),
442                    }
443                }
444            }
445            Self { saved }
446        }
447    }
448
449    impl Drop for EnvGuard {
450        fn drop(&mut self) {
451            for (key, value) in self.saved.drain(..).rev() {
452                unsafe {
453                    match value {
454                        Some(value) => env::set_var(&key, value),
455                        None => env::remove_var(&key),
456                    }
457                }
458            }
459        }
460    }
461}