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}