steamroom_cli/daemon/
lifecycle.rs1use crate::daemon::ipc::socket_name_string;
14use crate::daemon::server::DaemonState;
15use crate::errors::CliError;
16use std::path::PathBuf;
17
18fn cache_dir() -> PathBuf {
23 if let Ok(dir) = std::env::var("XDG_CACHE_HOME") {
24 return PathBuf::from(dir).join("steamroom");
25 }
26 #[cfg(windows)]
33 if let Some(dir) = dirs_next::cache_dir() {
34 return dir.join("steamroom");
35 }
36 if let Some(home) = std::env::var_os("HOME") {
37 return PathBuf::from(home).join(".cache").join("steamroom");
38 }
39 PathBuf::from("/tmp").join(format!("steamroom-{}", unix_uid()))
41}
42
43pub fn pid_file_path() -> PathBuf {
44 cache_dir().join("daemon.pid")
45}
46
47#[cfg(unix)]
48fn unix_uid() -> u32 {
49 unsafe { libc::getuid() }
50}
51#[cfg(not(unix))]
52fn unix_uid() -> u32 {
53 0
54}
55
56pub fn write_pid_file(pid: u32) -> Result<(), CliError> {
57 let path = pid_file_path();
58 if let Some(parent) = path.parent() {
59 std::fs::create_dir_all(parent).map_err(CliError::Io)?;
60 }
61 std::fs::write(&path, format!("{pid}\n")).map_err(CliError::Io)
62}
63
64pub fn read_pid_file() -> Result<u32, CliError> {
65 let data = std::fs::read_to_string(pid_file_path()).map_err(CliError::Io)?;
66 data.trim()
67 .parse::<u32>()
68 .map_err(|e| CliError::MalformedFrame(format!("pid file: {e}")))
69}
70
71pub fn remove_pid_file() {
72 let _ = std::fs::remove_file(pid_file_path());
73}
74
75pub fn render_daemon_info() {
78 let path = pid_file_path();
79 println!("pid file: {}", path.display());
80 match read_pid_file() {
81 Ok(pid) => println!("pid : {pid}"),
82 Err(_) => println!("pid : (none; no daemon recorded)"),
83 }
84 println!("socket : {}", socket_name_string());
85 println!("stop : steamroom daemon stop");
86}
87
88pub fn log_path() -> PathBuf {
89 cache_dir().join("daemon.log")
90}
91
92pub fn recent_history_path() -> PathBuf {
95 cache_dir().join("recent.json")
96}
97
98pub async fn load_recent_history(state: &DaemonState) {
102 let path = recent_history_path();
103 let Ok(data) = std::fs::read_to_string(&path) else {
104 return;
105 };
106 let Ok(records) = serde_json::from_str::<Vec<crate::daemon::proto::JobRecord>>(&data) else {
107 tracing::warn!("recent history at {} is corrupt; ignoring", path.display());
108 return;
109 };
110 let mut recent = state.recent.lock().await;
111 for r in records {
112 recent.push(r);
113 }
114}
115
116pub async fn save_recent_history(state: &DaemonState) {
119 let path = recent_history_path();
120 if let Some(parent) = path.parent() {
121 let _ = std::fs::create_dir_all(parent);
122 }
123 let records: Vec<_> = state.recent.lock().await.iter().cloned().collect();
124 match serde_json::to_string(&records) {
125 Ok(json) => {
126 if let Err(e) = std::fs::write(&path, json) {
127 tracing::warn!("failed to write recent history to {}: {e}", path.display());
128 }
129 }
130 Err(e) => {
131 tracing::warn!("failed to serialize recent history: {e}");
132 }
133 }
134}
135
136pub fn detach_and_exec_resume(username: &str, log_path: &std::path::Path) -> Result<(), CliError> {
152 use std::process::Command;
153 use std::process::Stdio;
154
155 if let Some(parent) = log_path.parent() {
156 std::fs::create_dir_all(parent).map_err(CliError::Io)?;
157 }
158 let log_out = std::fs::OpenOptions::new()
159 .create(true)
160 .append(true)
161 .open(log_path)
162 .map_err(CliError::Io)?;
163 let log_err = log_out.try_clone().map_err(CliError::Io)?;
164
165 let exe = std::env::current_exe().map_err(CliError::Io)?;
166 let mut cmd = Command::new(exe);
167 cmd.arg("--daemon-resume")
171 .arg(username)
172 .arg("daemon")
173 .arg("start");
174 cmd.stdin(Stdio::null());
175 cmd.stdout(Stdio::from(log_out));
176 cmd.stderr(Stdio::from(log_err));
177
178 #[cfg(unix)]
179 unsafe {
180 use std::os::unix::process::CommandExt;
181 cmd.pre_exec(|| {
185 if libc::setsid() == -1 {
186 return Err(std::io::Error::last_os_error());
187 }
188 Ok(())
189 });
190 }
191
192 #[cfg(windows)]
193 {
194 use std::os::windows::process::CommandExt;
195 const DETACHED_PROCESS: u32 = 0x0000_0008;
202 const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200;
203 cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
204 }
205
206 let child = cmd.spawn().map_err(CliError::Io)?;
207 let pid = child.id();
208 std::mem::forget(child);
212
213 if !wait_for_socket(std::time::Duration::from_secs(5)) {
214 eprintln!("steamroom daemon (pid {pid}) failed to bind socket within 5s");
215 eprintln!("check the log for the failure:");
216 eprintln!(" {}", log_path.display());
217 std::process::exit(1);
218 }
219 #[cfg(windows)]
220 let manual_kill = format!("taskkill /PID {pid} /F");
221 #[cfg(not(windows))]
222 let manual_kill = format!("kill {pid}");
223 println!("steamroom daemon started");
224 println!(" pid : {pid}");
225 println!(" socket : {}", socket_name_string());
226 println!(" stop : steamroom daemon stop (or: {manual_kill})");
227 println!(" logs : {}", log_path.display());
228 std::process::exit(0);
229}
230
231fn wait_for_socket(timeout: std::time::Duration) -> bool {
236 let rt = match tokio::runtime::Builder::new_current_thread()
237 .enable_all()
238 .build()
239 {
240 Ok(rt) => rt,
241 Err(_) => return false,
242 };
243 rt.block_on(async move {
244 let deadline = std::time::Instant::now() + timeout;
245 let mut delay = std::time::Duration::from_millis(50);
246 while std::time::Instant::now() < deadline {
247 if crate::daemon::ipc::probe_peer().await.is_ok() {
248 return true;
249 }
250 tokio::time::sleep(delay).await;
251 delay = (delay * 2).min(std::time::Duration::from_millis(200));
253 }
254 false
255 })
256}
257
258use crate::cli::Cli;
259use crate::commands::shared;
260use crate::daemon::ipc;
261use crate::daemon::server::handle_connection;
262use crate::daemon::server::worker_loop;
263use crate::daemon::tracing_layer::JobIdAttachmentInstaller;
264use crate::daemon::tracing_layer::JobScopedLogLayer;
265
266pub async fn launch_daemon_authenticate(cli: &Cli) -> Result<Option<String>, CliError> {
278 if ipc::probe_peer().await.is_ok() {
280 return Err(CliError::DaemonAlreadyRunning);
281 }
282 if let Ok(stale_pid) = read_pid_file()
284 && !pid_is_alive(stale_pid)
285 {
286 remove_pid_file();
287 }
288
289 let auth = &cli.auth;
290 let has_explicit_auth = auth.username.is_some()
291 || auth.password.is_some()
292 || auth.qr
293 || auth.use_steam_token
294 || auth.device_name.is_some();
295
296 if !has_explicit_auth {
297 return Ok(None);
298 }
299
300 let client = shared::connect_and_login(auth, None).await?;
301 let username = auth
302 .username
303 .clone()
304 .or_else(|| shared::detect_steam_user().map(|(u, _)| u))
305 .ok_or(CliError::InteractiveAuthRequired)?;
306 drop(client);
307 Ok(Some(username))
308}
309
310#[cfg(unix)]
315fn pid_is_alive(pid: u32) -> bool {
316 let rc = unsafe { libc::kill(pid as libc::pid_t, 0) };
318 if rc == 0 {
319 return true;
320 }
321 std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
324}
325#[cfg(not(unix))]
326fn pid_is_alive(_pid: u32) -> bool {
327 true
328}
329
330pub async fn serve_resumed(username: String, _cli: Cli) -> Result<(), CliError> {
338 let (initial_client, preferred_user) = if username.is_empty() {
339 (None, None)
343 } else {
344 let token = shared::load_saved_token(&username).ok_or(CliError::InteractiveAuthRequired)?;
345 let client = steamroom_client::login::LoginBuilder::new()
346 .device_name("steamroom")
347 .with_refresh_token(&username, &token)
348 .login()
349 .await?;
350 (Some(client), Some(username.clone()))
351 };
352
353 let listener = ipc::bind_listener().await?;
354
355 let pid = std::process::id();
356 write_pid_file(pid)?;
357 let account_label = if username.is_empty() {
358 None
359 } else {
360 Some(username.clone())
361 };
362 let state = DaemonState::new(account_label, pid, unix_now_lifecycle());
363
364 load_recent_history(&state).await;
367
368 use tracing_subscriber::filter::LevelFilter;
369 use tracing_subscriber::layer::SubscriberExt;
370 use tracing_subscriber::util::SubscriberInitExt;
371 let _ = tracing_subscriber::registry()
376 .with(crate::commands::shared::log_filter(LevelFilter::INFO))
377 .with(tracing_subscriber::fmt::layer())
378 .with(JobIdAttachmentInstaller)
379 .with(JobScopedLogLayer::new(state.events.clone()))
380 .try_init();
381
382 let worker_state = state.clone();
383 let worker_client = initial_client;
384 let worker_user = preferred_user;
385 let mut worker_task = Some(tokio::spawn(async move {
386 worker_loop(worker_state, worker_client, worker_user).await;
387 }));
388
389 crate::daemon::server::spawn_replay_collector(state.clone());
392
393 loop {
394 let join_arm = match worker_task {
397 Some(ref mut h) => h,
398 None => break,
399 };
400 tokio::select! {
401 _ = state.shutdown.cancelled() => break,
402 res = join_arm => {
403 match res {
405 Ok(()) => tracing::info!("worker_loop exited"),
406 Err(ref e) if e.is_panic() => tracing::error!("worker_loop panicked: {e}"),
407 Err(ref e) => tracing::warn!("worker_loop join error: {e}"),
408 }
409 worker_task = None;
410 state.shutdown.cancel();
411 break;
412 }
413 res = ipc::accept(&listener) => match res {
414 Ok(stream) => {
415 let st = state.clone();
416 tokio::spawn(handle_connection(st, stream));
417 }
418 Err(e) => {
419 tracing::warn!("accept failed: {e}");
420 }
421 }
422 }
423 }
424
425 let _ = state.events.send(crate::daemon::proto::Event::Log {
426 job_id: None,
427 level: crate::daemon::proto::LogLevel::Info,
428 target: "daemon".into(),
429 message: "shutting down".into(),
430 });
431 if let Some(h) = worker_task {
434 h.abort();
435 let _ = h.await;
436 }
437 save_recent_history(&state).await;
440 remove_pid_file();
441 Ok(())
442}
443
444fn unix_now_lifecycle() -> u64 {
445 std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .map(|d| d.as_secs())
448 .unwrap_or(0)
449}