git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

src/instance.rs (5.9K)

  1 //! Single-instance forwarding over the CCE socket convention.
  2 //!
  3 //! Every external open (`xdg-open` via the desktop entry's `%u`) spawns a
  4 //! fresh `cce-browser <url>` process. A second full instance is not just
  5 //! clutter: the profile dir holds a plaintext cookie jar two engines must
  6 //! not share. So the first instance listens on
  7 //! `/tmp/cce-browser-<WAYLAND_DISPLAY>.sock` (keyed by display, which keeps
  8 //! shadow sessions isolated for free), and every later launch hands its
  9 //! argument to it and exits before any Wayland or engine work happens.
 10 //!
 11 //! The order in [`forward_or_claim`] is what closes the startup race: try to
 12 //! connect, and only bind after a connect has failed. A refused connection
 13 //! means the socket file outlived a crashed instance and is removed before
 14 //! binding; losing the bind to a simultaneous launch falls back to one more
 15 //! connect. If that also fails the launch proceeds un-listened rather than
 16 //! not at all.
 17 //!
 18 //! The claimed listener has to survive from `main()` (before the engine
 19 //! starts) to `BrowserApp::new` (where the calloop sender first exists), so
 20 //! it parks in a static until [`spawn_listener`] adopts it.
 21 
 22 use std::io::{BufRead, BufReader, Write};
 23 use std::os::unix::net::{UnixListener, UnixStream};
 24 use std::sync::Mutex;
 25 
 26 use crate::Message;
 27 
 28 /// Socket prefix; `cce_ui::ipc::socket_path` appends `-<WAYLAND_DISPLAY>`.
 29 const PREFIX: &str = "cce-browser";
 30 
 31 /// The listener claimed by `forward_or_claim`, waiting for `spawn_listener`.
 32 static CLAIMED: Mutex<Option<UnixListener>> = Mutex::new(None);
 33 /// The socket path this process bound (and must unlink on exit), if any.
 34 static OWNED_PATH: Mutex<Option<String>> = Mutex::new(None);
 35 
 36 /// Hand `arg` to a running instance, or claim the instance socket.
 37 ///
 38 /// Returns `true` when a running instance took the launch (the caller should
 39 /// exit without starting the engine). Returns `false` when this process is
 40 /// the instance — with the listener parked for [`spawn_listener`] — or when
 41 /// single-instance handling failed entirely and the launch should proceed
 42 /// standalone.
 43 pub fn forward_or_claim(arg: Option<&str>) -> bool {
 44     let path = cce_ui::ipc::socket_path(PREFIX);
 45 
 46     if try_forward(&path, arg) {
 47         return true;
 48     }
 49 
 50     // Nothing answered. A socket file that still exists is a leftover from a
 51     // crashed instance; binding needs it gone.
 52     if std::path::Path::new(&path).exists() {
 53         let _ = std::fs::remove_file(&path);
 54     }
 55     match UnixListener::bind(&path) {
 56         Ok(listener) => {
 57             *CLAIMED.lock().unwrap() = Some(listener);
 58             *OWNED_PATH.lock().unwrap() = Some(path);
 59             false
 60         }
 61         // Lost the bind race to a simultaneous launch: it is the instance.
 62         Err(_) => try_forward(&path, arg),
 63     }
 64 }
 65 
 66 /// One forwarding attempt. False on any failure — there is no retry inside.
 67 fn try_forward(path: &str, arg: Option<&str>) -> bool {
 68     let Ok(mut stream) = UnixStream::connect(path) else {
 69         return false;
 70     };
 71     // A relative file path is resolved against *this* process's cwd — the
 72     // instance's differs, so it must travel absolute.
 73     let command = match arg {
 74         Some(a) => {
 75             let p = std::path::Path::new(a);
 76             let abs = if p.exists() {
 77                 std::fs::canonicalize(p)
 78                     .ok()
 79                     .and_then(|c| c.to_str().map(String::from))
 80             } else {
 81                 None
 82             };
 83             format!("open {}\n", abs.as_deref().unwrap_or(a))
 84         }
 85         None => "new-tab\n".to_string(),
 86     };
 87     if stream.write_all(command.as_bytes()).is_err() {
 88         return false;
 89     }
 90     // Wait for the ack: returning (and exiting) on write alone races the
 91     // instance actually reading the line.
 92     let mut reply = String::new();
 93     BufReader::new(stream).read_line(&mut reply).is_ok()
 94 }
 95 
 96 /// Adopt the listener claimed in `main()` and serve it on a thread, pushing
 97 /// each received launch into the app's calloop channel. No-op when this
 98 /// process runs standalone.
 99 pub fn spawn_listener(sender: calloop::channel::Sender<Message>) {
100     let Some(listener) = CLAIMED.lock().unwrap().take() else {
101         return;
102     };
103     std::thread::spawn(move || {
104         for conn in listener.incoming() {
105             let Ok(conn) = conn else { continue };
106             let mut reader = BufReader::new(conn);
107             let mut line = String::new();
108             if reader.read_line(&mut line).is_err() {
109                 continue;
110             }
111             let msg = match parse_command(line.trim()) {
112                 Some(m) => m,
113                 None => continue,
114             };
115             if sender.send(msg).is_err() {
116                 return; // channel gone: the app is shutting down
117             }
118             let _ = reader.get_mut().write_all(b"ok\n");
119         }
120     });
121 }
122 
123 /// `open <arg>` / `new-tab` → the message the app loop handles.
124 fn parse_command(line: &str) -> Option<Message> {
125     if let Some(arg) = line.strip_prefix("open ") {
126         let arg = arg.trim();
127         (!arg.is_empty()).then(|| Message::OpenExternal(Some(arg.to_string())))
128     } else if line == "new-tab" {
129         Some(Message::OpenExternal(None))
130     } else {
131         None
132     }
133 }
134 
135 /// Unlink the socket if this process bound it. Called after the engine loop
136 /// returns; a crash skips it, which is what the stale-socket removal in
137 /// [`forward_or_claim`] exists for.
138 pub fn cleanup() {
139     if let Some(path) = OWNED_PATH.lock().unwrap().take() {
140         let _ = std::fs::remove_file(path);
141     }
142 }
143 
144 #[cfg(test)]
145 mod tests {
146     use super::*;
147 
148     #[test]
149     fn commands_parse() {
150         assert!(matches!(
151             parse_command("open https://example.com"),
152             Some(Message::OpenExternal(Some(u))) if u == "https://example.com"
153         ));
154         assert!(matches!(
155             parse_command("new-tab"),
156             Some(Message::OpenExternal(None))
157         ));
158         assert!(parse_command("open ").is_none());
159         assert!(parse_command("bogus").is_none());
160     }
161 }