picoframe_core/
sidecar.rs1use crate::CliResult;
22use serde::Deserialize;
23use serde_json::Value;
24use std::io::{BufRead, BufReader};
25use std::path::PathBuf;
26use std::process::{Child, Command};
27use std::sync::atomic::{AtomicBool, Ordering};
28use std::sync::{Arc, Mutex};
29use std::time::{Duration, Instant};
30use tauri::{AppHandle, Emitter, Runtime};
31
32#[derive(Clone)]
34pub struct SidecarOptions {
35 pub bin: String,
38 pub dev_command: Option<Vec<String>>,
42 pub event_prefix: String,
45 pub ready_timeout: Duration,
47 pub restart: bool,
49}
50
51impl SidecarOptions {
52 pub fn new(bin: impl Into<String>, event_prefix: impl Into<String>) -> Self {
54 Self {
55 bin: bin.into(),
56 dev_command: None,
57 event_prefix: event_prefix.into(),
58 ready_timeout: Duration::from_secs(10),
59 restart: true,
60 }
61 }
62}
63
64#[derive(Deserialize)]
65struct Handshake {
66 port: u16,
67 token: String,
68}
69
70struct Connection {
71 port: u16,
72 token: String,
73 child: Child,
74}
75
76#[derive(Clone)]
79pub struct Sidecar(Arc<Inner>);
80
81struct Inner {
82 conn: Mutex<Connection>,
83 http: reqwest::blocking::Client,
84 shutting_down: AtomicBool,
85}
86
87impl Sidecar {
88 pub fn spawn<R: Runtime>(app: &AppHandle<R>, opts: SidecarOptions) -> Result<Sidecar, String> {
92 let http = reqwest::blocking::Client::builder()
96 .connect_timeout(Duration::from_secs(5))
97 .build()
98 .map_err(|e| format!("build http client: {e}"))?;
99
100 let conn = start_process(&opts, &http)?;
101 let sidecar = Sidecar(Arc::new(Inner {
102 conn: Mutex::new(conn),
103 http,
104 shutting_down: AtomicBool::new(false),
105 }));
106
107 sidecar.clone().supervise(app.clone(), opts);
108 Ok(sidecar)
109 }
110
111 fn base_url(&self) -> String {
113 let conn = self.0.conn.lock().unwrap();
114 format!("http://127.0.0.1:{}", conn.port)
115 }
116
117 fn token(&self) -> String {
118 self.0.conn.lock().unwrap().token.clone()
119 }
120
121 pub fn request(&self, command: &str, args: Value) -> CliResult {
125 let url = format!("{}/command", self.base_url());
126 let res = self
127 .0
128 .http
129 .post(&url)
130 .bearer_auth(self.token())
131 .json(&serde_json::json!({ "command": command, "args": args }))
132 .send()
133 .and_then(|r| r.json::<CliResult>());
134 match res {
135 Ok(result) => result,
136 Err(e) => CliResult::err(format!("sidecar request failed: {e}")),
137 }
138 }
139
140 pub fn healthy(&self) -> bool {
142 let url = format!("{}/health", self.base_url());
143 self.0
144 .http
145 .get(&url)
146 .bearer_auth(self.token())
147 .timeout(Duration::from_secs(2))
148 .send()
149 .map(|r| r.status().is_success())
150 .unwrap_or(false)
151 }
152
153 pub fn shutdown(&self) {
155 self.0.shutting_down.store(true, Ordering::SeqCst);
156 if let Ok(mut conn) = self.0.conn.lock() {
157 let _ = conn.child.kill();
158 let _ = conn.child.wait();
159 }
160 }
161
162 fn supervise<R: Runtime>(self, app: AppHandle<R>, opts: SidecarOptions) {
165 let sse = self.clone();
167 let sse_app = app.clone();
168 let sse_prefix = opts.event_prefix.clone();
169 std::thread::spawn(move || {
170 while !sse.0.shutting_down.load(Ordering::SeqCst) {
171 stream_events(&sse, &sse_app, &sse_prefix);
172 std::thread::sleep(Duration::from_millis(500));
174 }
175 });
176
177 let sup = self.clone();
179 std::thread::spawn(move || loop {
180 std::thread::sleep(Duration::from_millis(500));
181 if sup.0.shutting_down.load(Ordering::SeqCst) {
182 return;
183 }
184 let exited = {
185 let mut conn = sup.0.conn.lock().unwrap();
186 matches!(conn.child.try_wait(), Ok(Some(_)))
187 };
188 if !exited {
189 continue;
190 }
191 if !opts.restart {
192 return;
193 }
194 match start_process(&opts, &sup.0.http) {
195 Ok(next) => {
196 *sup.0.conn.lock().unwrap() = next;
197 let _ = app.emit(&format!("{}/restarted", opts.event_prefix), ());
198 }
199 Err(e) => {
200 let _ = app.emit(&format!("{}/error", opts.event_prefix), e);
202 std::thread::sleep(Duration::from_secs(2));
203 }
204 }
205 });
206 }
207}
208
209impl Drop for Inner {
210 fn drop(&mut self) {
211 self.shutting_down.store(true, Ordering::SeqCst);
212 if let Ok(mut conn) = self.conn.lock() {
213 let _ = conn.child.kill();
214 let _ = conn.child.wait();
215 }
216 }
217}
218
219fn stream_events<R: Runtime>(sidecar: &Sidecar, app: &AppHandle<R>, event_prefix: &str) {
222 let url = format!("{}/events", sidecar.base_url());
223 let resp = sidecar.0.http.get(&url).bearer_auth(sidecar.token()).send();
225 let Ok(resp) = resp else { return };
226 if !resp.status().is_success() {
227 return;
228 }
229 let event = format!("{event_prefix}/progress");
230 let reader = BufReader::new(resp);
231 for line in reader.lines() {
232 if sidecar.0.shutting_down.load(Ordering::SeqCst) {
233 return;
234 }
235 let Ok(line) = line else { return };
236 if let Some(payload) = line.strip_prefix("data: ") {
237 if let Ok(value) = serde_json::from_str::<Value>(payload) {
238 let _ = app.emit(&event, value);
239 }
240 }
241 }
242}
243
244fn start_process(opts: &SidecarOptions, http: &reqwest::blocking::Client) -> Result<Connection, String> {
246 let token = random_token();
247 let handshake_path = handshake_path(&opts.bin);
248 let _ = std::fs::remove_file(&handshake_path);
250
251 let mut cmd = resolve_command(opts)?;
252 cmd.env("PICOFRAME_SIDECAR_TOKEN", &token)
253 .env("PICOFRAME_SIDECAR_HANDSHAKE", &handshake_path)
254 .env("PICOFRAME_SIDECAR_PARENT_PID", std::process::id().to_string())
255 .env("PICOFRAME_SIDECAR_HOST", "127.0.0.1");
256
257 let mut child = cmd.spawn().map_err(|e| format!("spawn sidecar `{}`: {e}", opts.bin))?;
258
259 let deadline = Instant::now() + opts.ready_timeout;
260 let handshake = match wait_for_handshake(&handshake_path, deadline) {
261 Ok(h) => h,
262 Err(e) => {
263 let _ = child.kill();
264 return Err(e);
265 }
266 };
267 if handshake.token != token {
269 let _ = child.kill();
270 return Err("sidecar handshake token mismatch".to_string());
271 }
272
273 let conn = Connection { port: handshake.port, token, child };
274 let sidecar_url = format!("http://127.0.0.1:{}/health", conn.port);
275 if let Err(e) = wait_for_health(http, &sidecar_url, &conn.token, deadline) {
276 let mut conn = conn;
277 let _ = conn.child.kill();
278 return Err(e);
279 }
280 Ok(conn)
281}
282
283fn resolve_command(opts: &SidecarOptions) -> Result<Command, String> {
286 if let Some(path) = resolve_bin(&opts.bin) {
287 return Ok(Command::new(path));
288 }
289 if cfg!(debug_assertions) {
290 if let Some(dev) = &opts.dev_command {
291 if let Some((program, args)) = dev.split_first() {
292 let mut cmd = Command::new(program);
293 cmd.args(args);
294 return Ok(cmd);
295 }
296 }
297 }
298 Err(format!(
299 "sidecar binary `{}` not found next to the executable (and no dev command available)",
300 opts.bin
301 ))
302}
303
304fn resolve_bin(bin: &str) -> Option<PathBuf> {
307 if let Ok(p) = std::env::var("PICOFRAME_SIDECAR_BIN") {
308 if !p.is_empty() {
309 let path = PathBuf::from(p);
310 return path.exists().then_some(path);
311 }
312 }
313 let exe = std::env::current_exe().ok()?;
314 let dir = exe.parent()?;
315 let candidate = dir.join(format!("{bin}{}", std::env::consts::EXE_SUFFIX));
316 candidate.exists().then_some(candidate)
317}
318
319fn handshake_path(bin: &str) -> PathBuf {
320 std::env::temp_dir().join(format!("picoframe-sidecar-{bin}-{}.json", std::process::id()))
321}
322
323fn wait_for_handshake(path: &PathBuf, deadline: Instant) -> Result<Handshake, String> {
324 loop {
325 if let Ok(text) = std::fs::read_to_string(path) {
326 if let Ok(h) = serde_json::from_str::<Handshake>(&text) {
327 return Ok(h);
328 }
329 }
330 if Instant::now() >= deadline {
331 return Err("timed out waiting for sidecar handshake".to_string());
332 }
333 std::thread::sleep(Duration::from_millis(50));
334 }
335}
336
337fn wait_for_health(
338 http: &reqwest::blocking::Client,
339 url: &str,
340 token: &str,
341 deadline: Instant,
342) -> Result<(), String> {
343 loop {
344 let ok = http
345 .get(url)
346 .bearer_auth(token)
347 .timeout(Duration::from_secs(1))
348 .send()
349 .map(|r| r.status().is_success())
350 .unwrap_or(false);
351 if ok {
352 return Ok(());
353 }
354 if Instant::now() >= deadline {
355 return Err("timed out waiting for sidecar to become healthy".to_string());
356 }
357 std::thread::sleep(Duration::from_millis(50));
358 }
359}
360
361fn random_token() -> String {
363 let mut bytes = [0u8; 32];
364 getrandom::getrandom(&mut bytes).expect("os rng");
365 let mut s = String::with_capacity(64);
366 for b in bytes {
367 s.push_str(&format!("{b:02x}"));
368 }
369 s
370}