1use crate::util::paths;
6use crate::util::{Error, Result};
7use std::fs;
8use std::path::PathBuf;
9
10fn cosmic_shortcuts_path() -> Result<PathBuf> {
14 paths::cosmic_shortcuts_path()
15}
16
17fn 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
40fn 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
57fn 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 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
75fn 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
86fn write_shortcuts(content: &str) -> Result<()> {
88 let path = cosmic_shortcuts_path()?;
89
90 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 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 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
122fn has_sesame_binding(content: &str) -> bool {
124 content.contains("sesame")
125}
126
127fn 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
137fn add_binding(content: &str, binding: &str) -> String {
139 let trimmed = content.trim();
140
141 if trimmed.is_empty() || trimmed == "{}" || trimmed == "{\n}" {
143 return format!("{{\n{}\n}}", binding);
144 }
145
146 if let Some(close_pos) = trimmed.rfind('}') {
148 let before = &trimmed[..close_pos].trim_end();
149 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
158pub fn setup_keybinding(launcher_key_combo: &str) -> Result<()> {
164 let (launcher_mods, launcher_key) = parse_key_combo(launcher_key_combo)?;
165
166 let launcher_binding = format_keybinding(&launcher_mods, &launcher_key, "sesame --launcher");
168
169 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 if has_sesame_binding(&content) {
181 tracing::info!("Removing existing sesame keybindings");
182 content = remove_sesame_bindings(&content);
183 }
184
185 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
208pub 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
226pub 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 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 assert_eq!(escape_ron_string("sesame"), "sesame");
309 assert_eq!(escape_ron_string("simple"), "simple");
310
311 assert_eq!(escape_ron_string(r#"test"quote"#), r#"test\"quote"#);
313
314 assert_eq!(escape_ron_string(r"path\to\file"), r"path\\to\\file");
316
317 assert_eq!(escape_ron_string(r#"a\"b"#), r#"a\\\"b"#);
319 }
320
321 #[test]
322 fn test_format_keybinding_escapes_injection() {
323 let result = format_keybinding(
325 &["Super".to_string()],
326 "space",
327 r#"malicious"), Other("injected"#,
328 );
329 assert!(
334 result.contains(r#"Spawn("malicious\"), Other(\"injected")"#),
335 "Result was: {}",
336 result
337 );
338 assert!(result.contains("modifiers: [Super]"));
340 assert!(result.ends_with(","));
341 }
342}