open_sesame/platform/
cosmic_keys.rs

1//! COSMIC keybinding integration
2//!
3//! Manages keybindings in COSMIC desktop's shortcut configuration.
4
5use crate::util::paths;
6use crate::util::{Error, Result};
7use std::fs;
8use std::path::PathBuf;
9
10/// Path to COSMIC custom shortcuts config
11///
12/// Uses the centralized paths module which properly handles missing HOME.
13fn cosmic_shortcuts_path() -> Result<PathBuf> {
14    paths::cosmic_shortcuts_path()
15}
16
17/// Parse a key combo string like "super+space" or "alt+tab" into COSMIC Ron format
18fn parse_key_combo(combo: &str) -> Result<(Vec<String>, String)> {
19    let parts: Vec<&str> = combo.split('+').map(|s| s.trim()).collect();
20
21    if parts.is_empty() {
22        return Err(Error::Other("Empty key combo".to_string()));
23    }
24
25    let key = parts.last().unwrap().to_string();
26    let modifiers: Vec<String> = parts[..parts.len() - 1]
27        .iter()
28        .map(|m| match m.to_lowercase().as_str() {
29            "super" | "mod" | "logo" | "win" => "Super".to_string(),
30            "shift" => "Shift".to_string(),
31            "ctrl" | "control" => "Ctrl".to_string(),
32            "alt" => "Alt".to_string(),
33            other => other.to_string(),
34        })
35        .collect();
36
37    Ok((modifiers, key))
38}
39
40/// Escape a string for RON format (handles quotes and backslashes)
41///
42/// RON uses the same escaping rules as Rust strings:
43/// - `\` becomes `\\`
44/// - `"` becomes `\"`
45fn escape_ron_string(s: &str) -> String {
46    let mut escaped = String::with_capacity(s.len());
47    for c in s.chars() {
48        match c {
49            '\\' => escaped.push_str("\\\\"),
50            '"' => escaped.push_str("\\\""),
51            _ => escaped.push(c),
52        }
53    }
54    escaped
55}
56
57/// Format a keybinding entry in COSMIC Ron format
58///
59/// All string values are properly escaped to prevent RON injection.
60fn format_keybinding(modifiers: &[String], key: &str, command: &str) -> String {
61    let mods = if modifiers.is_empty() {
62        "[]".to_string()
63    } else {
64        format!("[{}]", modifiers.join(", "))
65    };
66    // Escape key and command to prevent RON injection
67    let escaped_key = escape_ron_string(key);
68    let escaped_command = escape_ron_string(command);
69    format!(
70        "    (modifiers: {}, key: \"{}\"): Spawn(\"{}\"),",
71        mods, escaped_key, escaped_command
72    )
73}
74
75/// Read the current custom shortcuts file
76fn read_shortcuts() -> Result<String> {
77    let path = cosmic_shortcuts_path()?;
78    if path.exists() {
79        fs::read_to_string(&path)
80            .map_err(|e| Error::Other(format!("Failed to read {}: {}", path.display(), e)))
81    } else {
82        Ok("{\n}".to_string())
83    }
84}
85
86/// Write the custom shortcuts file with backup
87fn write_shortcuts(content: &str) -> Result<()> {
88    let path = cosmic_shortcuts_path()?;
89
90    // Ensure parent directory exists
91    if let Some(parent) = path.parent() {
92        fs::create_dir_all(parent)
93            .map_err(|e| Error::Other(format!("Failed to create {}: {}", parent.display(), e)))?;
94    }
95
96    // Create backup of existing file
97    if path.exists() {
98        let backup_path = path.with_extension("bak");
99        if let Err(e) = fs::copy(&path, &backup_path) {
100            tracing::warn!(
101                "Failed to create backup at {}: {}. Proceeding without backup.",
102                backup_path.display(),
103                e
104            );
105        } else {
106            tracing::info!("Created backup at {}", backup_path.display());
107        }
108    }
109
110    // Basic validation: check if content looks like valid RON
111    let trimmed = content.trim();
112    if !trimmed.starts_with('{') || !trimmed.ends_with('}') {
113        tracing::warn!(
114            "Shortcuts content does not look like valid RON (should start with '{{' and end with '}}'). Writing anyway but format may be incorrect."
115        );
116    }
117
118    fs::write(&path, content)
119        .map_err(|e| Error::Other(format!("Failed to write {}: {}", path.display(), e)))
120}
121
122/// Check if sesame keybinding already exists
123fn has_sesame_binding(content: &str) -> bool {
124    content.contains("sesame")
125}
126
127/// Remove existing sesame bindings from content
128fn remove_sesame_bindings(content: &str) -> String {
129    let lines: Vec<&str> = content.lines().collect();
130    let filtered: Vec<&str> = lines
131        .into_iter()
132        .filter(|line| !line.contains("sesame"))
133        .collect();
134    filtered.join("\n")
135}
136
137/// Add a keybinding entry to the shortcuts content
138fn add_binding(content: &str, binding: &str) -> String {
139    let trimmed = content.trim();
140
141    // Handle empty or minimal content
142    if trimmed.is_empty() || trimmed == "{}" || trimmed == "{\n}" {
143        return format!("{{\n{}\n}}", binding);
144    }
145
146    // Insert before the closing brace
147    if let Some(close_pos) = trimmed.rfind('}') {
148        let before = &trimmed[..close_pos].trim_end();
149        // Determine if comma separator is needed
150        let needs_comma = !before.ends_with('{') && !before.ends_with(',');
151        let comma = if needs_comma { "," } else { "" };
152        format!("{}{}\n{}\n}}", before, comma, binding)
153    } else {
154        format!("{{\n{}\n}}", binding)
155    }
156}
157
158/// Setup all sesame keybindings in COSMIC
159/// Configures:
160/// - Alt+Tab: Window switcher (quick cycling)
161/// - Alt+Shift+Tab: Window switcher backward
162/// - Alt+Space (or custom): Launcher mode with hints
163pub fn setup_keybinding(launcher_key_combo: &str) -> Result<()> {
164    let (launcher_mods, launcher_key) = parse_key_combo(launcher_key_combo)?;
165
166    // Launcher binding (Alt+Space by default) - shows full overlay with hints
167    let launcher_binding = format_keybinding(&launcher_mods, &launcher_key, "sesame --launcher");
168
169    // Switcher bindings (always Alt+Tab/Alt+Shift+Tab for standard window switching)
170    let switcher_forward = format_keybinding(&["Alt".to_string()], "tab", "sesame");
171    let switcher_backward = format_keybinding(
172        &["Alt".to_string(), "Shift".to_string()],
173        "tab",
174        "sesame --backward",
175    );
176
177    let mut content = read_shortcuts()?;
178
179    // Remove existing sesame bindings if present
180    if has_sesame_binding(&content) {
181        tracing::info!("Removing existing sesame keybindings");
182        content = remove_sesame_bindings(&content);
183    }
184
185    // Insert configured bindings
186    let content = add_binding(&content, &switcher_forward);
187    let content = add_binding(&content, &switcher_backward);
188    let new_content = add_binding(&content, &launcher_binding);
189    write_shortcuts(&new_content)?;
190
191    tracing::info!(
192        "Configured COSMIC keybindings: alt+tab (switcher), alt+shift+tab (backward), {} (launcher)",
193        launcher_key_combo
194    );
195    println!("✓ Keybindings configured:");
196    println!("    alt+tab       -> sesame (window switcher)");
197    println!("    alt+shift+tab -> sesame --backward");
198    println!(
199        "    {}     -> sesame --launcher (hint-based)",
200        launcher_key_combo
201    );
202    println!("  Config: {}", cosmic_shortcuts_path()?.display());
203    println!("  Note: You may need to log out and back in for changes to take effect.");
204
205    Ok(())
206}
207
208/// Remove the sesame keybinding from COSMIC
209pub fn remove_keybinding() -> Result<()> {
210    let content = read_shortcuts()?;
211
212    if !has_sesame_binding(&content) {
213        println!("No sesame keybinding found");
214        return Ok(());
215    }
216
217    let new_content = remove_sesame_bindings(&content);
218    write_shortcuts(&new_content)?;
219
220    println!("✓ Removed sesame keybinding");
221    println!("  Note: You may need to log out and back in for changes to take effect.");
222
223    Ok(())
224}
225
226/// Check current keybinding status
227pub fn keybinding_status() -> Result<()> {
228    let path = cosmic_shortcuts_path()?;
229
230    if !path.exists() {
231        println!("COSMIC shortcuts file not found: {}", path.display());
232        println!("Run 'sesame --setup-keybinding' to configure.");
233        return Ok(());
234    }
235
236    let content = read_shortcuts()?;
237
238    if has_sesame_binding(&content) {
239        // Find and display the binding
240        for line in content.lines() {
241            if line.contains("sesame") {
242                println!("✓ Keybinding active: {}", line.trim());
243            }
244        }
245    } else {
246        println!("✗ No sesame keybinding configured");
247        println!("  Run 'sesame --setup-keybinding' to configure.");
248    }
249
250    Ok(())
251}
252
253#[cfg(test)]
254mod tests {
255    use super::*;
256
257    #[test]
258    fn test_parse_key_combo() {
259        let (mods, key) = parse_key_combo("super+space").unwrap();
260        assert_eq!(mods, vec!["Super"]);
261        assert_eq!(key, "space");
262
263        let (mods, key) = parse_key_combo("alt+tab").unwrap();
264        assert_eq!(mods, vec!["Alt"]);
265        assert_eq!(key, "tab");
266
267        let (mods, key) = parse_key_combo("ctrl+shift+a").unwrap();
268        assert_eq!(mods, vec!["Ctrl", "Shift"]);
269        assert_eq!(key, "a");
270
271        let (mods, key) = parse_key_combo("super+shift+g").unwrap();
272        assert_eq!(mods, vec!["Super", "Shift"]);
273        assert_eq!(key, "g");
274    }
275
276    #[test]
277    fn test_format_keybinding() {
278        let result = format_keybinding(&["Super".to_string()], "space", "sesame");
279        assert!(result.contains("modifiers: [Super]"));
280        assert!(result.contains("key: \"space\""));
281        assert!(result.contains("Spawn(\"sesame\")"));
282    }
283
284    #[test]
285    fn test_add_binding() {
286        let content = "{\n}";
287        let binding = "    (modifiers: [Super], key: \"space\"): Spawn(\"test\"),";
288        let result = add_binding(content, binding);
289        assert!(result.contains(binding));
290        assert!(result.starts_with('{'));
291        assert!(result.ends_with('}'));
292    }
293
294    #[test]
295    fn test_remove_bindings() {
296        let content = r#"{
297    (modifiers: [Super], key: "space"): Spawn("sesame"),
298    (modifiers: [Alt], key: "tab"): Spawn("other-app"),
299}"#;
300        let result = remove_sesame_bindings(content);
301        assert!(!result.contains("sesame"));
302        assert!(result.contains("other-app"));
303    }
304
305    #[test]
306    fn test_escape_ron_string() {
307        // Normal strings pass through unchanged
308        assert_eq!(escape_ron_string("sesame"), "sesame");
309        assert_eq!(escape_ron_string("simple"), "simple");
310
311        // Quotes are escaped
312        assert_eq!(escape_ron_string(r#"test"quote"#), r#"test\"quote"#);
313
314        // Backslashes are escaped
315        assert_eq!(escape_ron_string(r"path\to\file"), r"path\\to\\file");
316
317        // Combined
318        assert_eq!(escape_ron_string(r#"a\"b"#), r#"a\\\"b"#);
319    }
320
321    #[test]
322    fn test_format_keybinding_escapes_injection() {
323        // Attempt to inject RON - should be safely escaped
324        let result = format_keybinding(
325            &["Super".to_string()],
326            "space",
327            r#"malicious"), Other("injected"#,
328        );
329        // The result should contain escaped quotes within the Spawn string
330        // Input: malicious"), Other("injected
331        // Escaped: malicious\"), Other(\"injected
332        // Full output: Spawn("malicious\"), Other(\"injected")
333        assert!(
334            result.contains(r#"Spawn("malicious\"), Other(\"injected")"#),
335            "Result was: {}",
336            result
337        );
338        // Should still have proper RON structure
339        assert!(result.contains("modifiers: [Super]"));
340        assert!(result.ends_with(","));
341    }
342}