Skip to main content

taskers_ghostty/
backend.rs

1use crate::runtime::{runtime_bridge_path, runtime_resources_dir};
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use taskers_domain::PaneKind;
5use taskers_runtime::ShellLaunchSpec;
6use thiserror::Error;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
9#[serde(rename_all = "snake_case")]
10pub enum BackendChoice {
11    Auto,
12    Ghostty,
13    GhosttyEmbedded,
14    Mock,
15}
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
18#[serde(rename_all = "snake_case")]
19pub enum BackendAvailability {
20    Ready,
21    Fallback,
22    Unavailable,
23}
24
25#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
26pub struct BackendProbe {
27    pub requested: BackendChoice,
28    pub selected: BackendChoice,
29    pub availability: BackendAvailability,
30    pub notes: String,
31}
32
33#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
34pub struct SurfaceDescriptor {
35    pub cols: u16,
36    pub rows: u16,
37    pub kind: PaneKind,
38    pub cwd: Option<String>,
39    pub title: Option<String>,
40    pub url: Option<String>,
41    #[serde(default)]
42    pub command_argv: Vec<String>,
43    #[serde(default)]
44    pub env: BTreeMap<String, String>,
45}
46
47#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
48pub struct GhosttyHostOptions {
49    #[serde(default)]
50    pub command_argv: Vec<String>,
51    #[serde(default)]
52    pub env: BTreeMap<String, String>,
53}
54
55impl GhosttyHostOptions {
56    pub fn from_shell_launch(shell_launch: &ShellLaunchSpec) -> Self {
57        let mut env = BTreeMap::new();
58        env.extend(shell_launch.env.clone());
59        Self {
60            command_argv: shell_launch.program_and_args(),
61            env,
62        }
63    }
64}
65
66#[derive(Debug, Error)]
67pub enum AdapterError {
68    #[error("terminal backend is unavailable: {0}")]
69    Unavailable(String),
70    #[error("terminal backend initialization failed: {0}")]
71    Initialization(String),
72}
73
74pub trait TerminalBackend {
75    fn probe(requested: BackendChoice) -> BackendProbe;
76}
77
78pub struct DefaultBackend;
79
80impl TerminalBackend for DefaultBackend {
81    fn probe(requested: BackendChoice) -> BackendProbe {
82        let env_override = std::env::var("TASKERS_TERMINAL_BACKEND").ok();
83        let requested = match env_override.as_deref() {
84            Some("ghostty") => BackendChoice::Ghostty,
85            Some("ghostty_embedded") | Some("ghostty-embedded") => BackendChoice::GhosttyEmbedded,
86            Some("mock") => BackendChoice::Mock,
87            _ => requested,
88        };
89
90        match requested {
91            BackendChoice::Auto => auto_probe(requested),
92            BackendChoice::Mock => BackendProbe {
93                requested,
94                selected: BackendChoice::Mock,
95                availability: BackendAvailability::Fallback,
96                notes: "Using placeholder terminal surfaces.".into(),
97            },
98            BackendChoice::GhosttyEmbedded => BackendProbe {
99                requested,
100                selected: BackendChoice::GhosttyEmbedded,
101                availability: embedded_ghostty_availability(),
102                notes: embedded_ghostty_notes(),
103            },
104            BackendChoice::Ghostty => BackendProbe {
105                requested,
106                selected: BackendChoice::Ghostty,
107                availability: ghostty_availability(),
108                notes: ghostty_notes(),
109            },
110        }
111    }
112}
113
114fn auto_probe(requested: BackendChoice) -> BackendProbe {
115    let availability = ghostty_availability();
116    if matches!(availability, BackendAvailability::Ready) {
117        BackendProbe {
118            requested,
119            selected: BackendChoice::Ghostty,
120            availability,
121            notes: ghostty_notes(),
122        }
123    } else {
124        BackendProbe {
125            requested,
126            selected: BackendChoice::Mock,
127            availability: BackendAvailability::Fallback,
128            notes: "Ghostty bridge unavailable, using placeholder terminal surfaces.".into(),
129        }
130    }
131}
132
133fn ghostty_availability() -> BackendAvailability {
134    #[cfg(all(target_os = "linux", taskers_ghostty_bridge))]
135    {
136        if runtime_bridge_path().is_some() {
137            BackendAvailability::Ready
138        } else {
139            BackendAvailability::Unavailable
140        }
141    }
142
143    #[cfg(not(all(target_os = "linux", taskers_ghostty_bridge)))]
144    {
145        BackendAvailability::Unavailable
146    }
147}
148
149fn embedded_ghostty_availability() -> BackendAvailability {
150    #[cfg(target_os = "macos")]
151    {
152        BackendAvailability::Ready
153    }
154
155    #[cfg(not(target_os = "macos"))]
156    {
157        BackendAvailability::Unavailable
158    }
159}
160
161fn ghostty_notes() -> String {
162    let mut notes = String::from("Ghostty GTK bridge compiled in.");
163    if let Some(path) = runtime_bridge_path() {
164        notes.push_str(" Bridge: ");
165        notes.push_str(&path.display().to_string());
166    } else {
167        notes.push_str(" Bridge library not found.");
168    }
169    if let Some(path) = runtime_resources_dir() {
170        notes.push_str(" Resources: ");
171        notes.push_str(&path.display().to_string());
172    }
173    notes
174}
175
176fn embedded_ghostty_notes() -> String {
177    String::from("Embedded Ghostty surfaces require the native macOS host.")
178}
179
180#[cfg(test)]
181mod tests {
182    use super::{
183        BackendAvailability, BackendChoice, DefaultBackend, GhosttyHostOptions, TerminalBackend,
184    };
185    use std::{collections::BTreeMap, path::PathBuf, sync::Mutex};
186    use taskers_runtime::ShellLaunchSpec;
187
188    static BACKEND_ENV_LOCK: Mutex<()> = Mutex::new(());
189
190    #[test]
191    fn auto_probe_matches_runtime_availability() {
192        let _guard = BACKEND_ENV_LOCK.lock().expect("env lock");
193        unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") };
194        let probe = DefaultBackend::probe(BackendChoice::Auto);
195        match probe.availability {
196            BackendAvailability::Ready => assert_eq!(probe.selected, BackendChoice::Ghostty),
197            BackendAvailability::Fallback | BackendAvailability::Unavailable => {
198                assert_eq!(probe.selected, BackendChoice::Mock);
199            }
200        }
201    }
202
203    #[test]
204    fn embedded_probe_stays_explicit() {
205        let probe = DefaultBackend::probe(BackendChoice::GhosttyEmbedded);
206        assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded);
207
208        #[cfg(target_os = "macos")]
209        assert_eq!(probe.availability, BackendAvailability::Ready);
210
211        #[cfg(not(target_os = "macos"))]
212        assert_eq!(probe.availability, BackendAvailability::Unavailable);
213    }
214
215    #[test]
216    fn env_override_accepts_hyphenated_embedded_backend() {
217        let _guard = BACKEND_ENV_LOCK.lock().expect("env lock");
218        unsafe { std::env::set_var("TASKERS_TERMINAL_BACKEND", "ghostty-embedded") };
219        let probe = DefaultBackend::probe(BackendChoice::Mock);
220        unsafe { std::env::remove_var("TASKERS_TERMINAL_BACKEND") };
221        assert_eq!(probe.selected, BackendChoice::GhosttyEmbedded);
222    }
223
224    #[test]
225    fn host_options_follow_shell_launch_contract() {
226        let mut env = BTreeMap::new();
227        env.insert("TASKERS_SOCKET".into(), "/tmp/taskers.sock".into());
228        let shell_launch = ShellLaunchSpec {
229            program: PathBuf::from("/bin/zsh"),
230            args: vec!["-i".into()],
231            env,
232        };
233
234        let options = GhosttyHostOptions::from_shell_launch(&shell_launch);
235
236        assert_eq!(options.command_argv, vec!["/bin/zsh", "-i"]);
237        assert_eq!(
238            options.env.get("TASKERS_SOCKET").map(String::as_str),
239            Some("/tmp/taskers.sock")
240        );
241    }
242}