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