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}