git.lucas.co / cce-notes
notes editor over the vault (Obsidian-compatible)
git clone https://git.lucas.co/cce-notes.git

src/instance.rs (7.6K)

  1 //! Single instance over the cce socket convention, in cce-browser's
  2 //! claim-or-forward shape: the first `cce-notes` listens on
  3 //! `/tmp/cce-notes-<WAYLAND_DISPLAY>.sock` (keyed by display, so shadow
  4 //! sessions stay apart), and every later launch — and any app that wants a
  5 //! note open — sends it one line and exits.
  6 //!
  7 //! Commands, one per line, each answered `ok`:
  8 //!
  9 //! - `open <path>[#heading] [line]` — a vault path, an absolute path inside
 10 //!   the vault, or a note name; `line` is 1-based.
 11 //! - `daily [YYYY-MM-DD]` — open (creating it from the template) a day's note.
 12 //! - `search <query>` — show the search pane holding `query` (`#tag` too).
 13 //! - `show` — nothing but bringing the instance up.
 14 //! - `current` — answered `ok <vault path>` (or a bare `ok` with no note
 15 //!   open) straight from the listener thread; cce-graph's local graph
 16 //!   polls it to follow the note on screen.
 17 //!
 18 //! The claim, the startup race it closes, the parked listener and the
 19 //! bounded reads are `cce_ui::ipc::instance`'s; this module is the notes
 20 //! protocol on top.
 21 
 22 use std::sync::Mutex;
 23 
 24 use crate::Message;
 25 
 26 const PREFIX: &str = "cce-notes";
 27 /// The open note's vault path, for `current`. Set by the app whenever it
 28 /// changes; read by the listener without a trip through the event loop.
 29 static CURRENT: Mutex<Option<String>> = Mutex::new(None);
 30 
 31 pub fn set_current(path: Option<&str>) {
 32     *CURRENT.lock().unwrap_or_else(|e| e.into_inner()) = path.map(String::from);
 33 }
 34 
 35 #[derive(Debug, Clone, PartialEq)]
 36 pub enum Command {
 37     Open { target: String, line: Option<usize> },
 38     Daily(Option<chrono::NaiveDate>),
 39     Search(String),
 40     Show,
 41 }
 42 
 43 impl Command {
 44     /// The command line's own arguments as a socket command:
 45     /// `cce-notes [open] <path> [line]`, `cce-notes daily [date]`,
 46     /// `cce-notes search <query…>`, `cce-notes show`, or nothing (also
 47     /// `show`). The verbs are reserved, as `daily` and `search` always were:
 48     /// a note named `show` opens with `cce-notes open show`.
 49     pub fn from_args(args: &[String]) -> Result<Command, String> {
 50         let words: Vec<&str> = args.iter().map(String::as_str).collect();
 51         match words.as_slice() {
 52             [] | ["show"] => Ok(Command::Show),
 53             ["daily"] => Ok(Command::Daily(None)),
 54             ["daily", d] => parse_date(d).map(|d| Command::Daily(Some(d))),
 55             ["search", rest @ ..] => Ok(Command::Search(rest.join(" "))),
 56             ["open", target] | [target] => Ok(Command::Open { target: absolute(target), line: None }),
 57             ["open", target, line] | [target, line] => {
 58                 let line = line.parse::<usize>().map_err(|_| format!("not a line number: {line}"))?;
 59                 Ok(Command::Open { target: absolute(target), line: Some(line) })
 60             }
 61             _ => Err("usage: cce-notes [open] <note> [line] | daily [YYYY-MM-DD] | search <query> | show".into()),
 62         }
 63     }
 64 
 65     pub fn to_line(&self) -> String {
 66         match self {
 67             Command::Open { target, line: Some(l) } => format!("open {target} {l}"),
 68             Command::Open { target, line: None } => format!("open {target}"),
 69             Command::Daily(Some(d)) => format!("daily {d}"),
 70             Command::Daily(None) => "daily".into(),
 71             Command::Search(q) => format!("search {q}"),
 72             Command::Show => "show".into(),
 73         }
 74     }
 75 
 76     pub fn parse(line: &str) -> Option<Command> {
 77         let (verb, rest) = line.split_once(' ').unwrap_or((line, ""));
 78         let rest = rest.trim();
 79         match verb {
 80             "open" if !rest.is_empty() => {
 81                 // A trailing number is a line, unless it is all there is.
 82                 match rest.rsplit_once(' ') {
 83                     Some((t, l)) if l.parse::<usize>().is_ok() => {
 84                         Some(Command::Open { target: t.trim().to_string(), line: l.parse().ok() })
 85                     }
 86                     _ => Some(Command::Open { target: rest.to_string(), line: None }),
 87                 }
 88             }
 89             "daily" if rest.is_empty() => Some(Command::Daily(None)),
 90             "daily" => parse_date(rest).ok().map(|d| Command::Daily(Some(d))),
 91             "search" => Some(Command::Search(rest.to_string())),
 92             "show" => Some(Command::Show),
 93             _ => None,
 94         }
 95     }
 96 }
 97 
 98 fn parse_date(s: &str) -> Result<chrono::NaiveDate, String> {
 99     chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").map_err(|_| format!("not a date (YYYY-MM-DD): {s}"))
100 }
101 
102 /// A path that exists here travels absolute: the instance's cwd differs.
103 /// Anything else (a note name, a vault path) goes as written.
104 fn absolute(target: &str) -> String {
105     let (path, sub) = match target.split_once('#') {
106         Some((p, s)) => (p, Some(s)),
107         None => (target, None),
108     };
109     let p = std::path::Path::new(path);
110     let abs = if p.exists() && !p.is_absolute() {
111         std::fs::canonicalize(p).ok().and_then(|c| c.to_str().map(String::from))
112     } else {
113         None
114     };
115     match (abs, sub) {
116         (Some(a), Some(s)) => format!("{a}#{s}"),
117         (Some(a), None) => a,
118         (None, _) => target.to_string(),
119     }
120 }
121 
122 /// Hand `cmd` to a running instance, or claim the socket. True when a
123 /// running instance took it and this process should exit.
124 pub fn forward_or_claim(cmd: &Command) -> bool {
125     cce_ui::ipc::instance::forward_or_claim(PREFIX, &cmd.to_line())
126 }
127 
128 /// Serve the claimed listener on a thread, feeding the app's loop. False
129 /// when this process holds no listener (single-instance handling failed
130 /// and it runs standalone).
131 pub fn spawn_listener(sender: calloop::channel::Sender<Message>) -> bool {
132     cce_ui::ipc::instance::serve(move |line| {
133         // Answered from here, without a trip through the event loop.
134         if line == "current" {
135             let cur = CURRENT.lock().unwrap_or_else(|e| e.into_inner()).clone();
136             return Some(match cur {
137                 Some(p) => format!("ok {p}"),
138                 None => "ok".to_string(),
139             });
140         }
141         match Command::parse(line) {
142             Some(cmd) => {
143                 sender.send(Message::Command(cmd)).ok()?;
144                 Some("ok".into())
145             }
146             None => Some("error unknown command".into()),
147         }
148     })
149 }
150 
151 pub fn cleanup() {
152     cce_ui::ipc::instance::cleanup();
153 }
154 
155 #[cfg(test)]
156 mod tests {
157     use super::*;
158 
159     fn args(a: &[&str]) -> Vec<String> {
160         a.iter().map(|s| s.to_string()).collect()
161     }
162 
163     #[test]
164     fn args_and_lines_round_trip() {
165         let cases = [
166             (vec![], Command::Show),
167             (vec!["show"], Command::Show),
168             (vec!["open", "show"], Command::Open { target: "show".into(), line: None }),
169             (vec!["Note"], Command::Open { target: "Note".into(), line: None }),
170             (vec!["open", "My Note#Sec", "12"], Command::Open { target: "My Note#Sec".into(), line: Some(12) }),
171             (vec!["daily"], Command::Daily(None)),
172             (vec!["daily", "2026-10-01"], Command::Daily(chrono::NaiveDate::from_ymd_opt(2026, 10, 1))),
173             (vec!["search", "two", "words"], Command::Search("two words".into())),
174         ];
175         for (a, want) in cases {
176             let got = Command::from_args(&args(&a)).unwrap();
177             assert_eq!(got, want, "{a:?}");
178             assert_eq!(Command::parse(&got.to_line()), Some(want));
179         }
180         assert!(Command::from_args(&args(&["daily", "tomorrow"])).is_err());
181         assert!(Command::parse("open ").is_none());
182         assert!(Command::parse("bogus").is_none());
183         // A note whose name is a number is a target, not a line.
184         assert_eq!(Command::parse("open 2026"), Some(Command::Open { target: "2026".into(), line: None }));
185     }
186 }