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}