Skip to main content

lean_ctx/core/
cockpit_launch_record.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The Cockpit launch record: which local Cockpit server belongs to this
3//! data dir.
4//!
5//! The server records itself in `dashboard.launch.json` in the state dir
6//! (PID, process identity, port, version, foreground or background) right
7//! after it bound its port and removes the record when it ends. The record
8//! holds no secret: the bearer token stays in `dashboard.token` (0600).
9//!
10//! The record is shared state between the Cockpit server, the CLI that
11//! starts and stops it, and the proxy page that links to it; the start
12//! decision itself lives with the dashboard.
13
14use std::path::PathBuf;
15
16use serde::{Deserialize, Serialize};
17
18use crate::ipc::process::ProcessIdentity;
19
20/// The record file in the state dir.
21pub(crate) const RECORD_FILE: &str = "dashboard.launch.json";
22/// Set by a background start for its server child.
23pub const DETACHED_ENV: &str = "LEAN_CTX_COCKPIT_DETACHED";
24
25/// Where the Cockpit server of this data dir runs.
26#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
27pub struct LaunchRecord {
28    pub(crate) schema_version: u32,
29    pub pid: u32,
30    /// Defends the PID against reuse by an unrelated process.
31    pub(crate) identity: Option<ProcessIdentity>,
32    pub host: String,
33    pub port: u16,
34    pub base_path: String,
35    pub(crate) version: String,
36    /// Started by `lean-ctx cockpit` in the background (stoppable with
37    /// `lean-ctx cockpit stop`), not in someone's terminal.
38    pub detached: bool,
39}
40
41impl LaunchRecord {
42    /// The record of this process, serving `host:port`.
43    pub fn of_this_process(host: &str, port: u16, base_path: &str) -> Self {
44        let pid = std::process::id();
45        Self {
46            schema_version: 1,
47            pid,
48            identity: crate::ipc::process::identity(pid),
49            host: host.to_string(),
50            port,
51            base_path: base_path.to_string(),
52            version: env!("CARGO_PKG_VERSION").to_string(),
53            detached: std::env::var_os(DETACHED_ENV).is_some(),
54        }
55    }
56
57    /// The process identity recorded at start, when the platform has one.
58    #[must_use]
59    pub fn identity(&self) -> Option<&ProcessIdentity> {
60        self.identity.as_ref()
61    }
62
63    /// The recorded process still runs (and is the one recorded, when the
64    /// platform can tell). An update renaming the running image (`(deleted)`,
65    /// `lean-ctx.old.exe`) does not make it a different process.
66    pub fn process_alive(&self) -> bool {
67        if !crate::ipc::process::is_alive(self.pid) {
68            return false;
69        }
70        match (&self.identity, crate::ipc::process::identity(self.pid)) {
71            (Some(recorded), Some(current)) => {
72                crate::ipc::process::same_process(recorded, &current)
73            }
74            _ => true,
75        }
76    }
77}
78
79fn record_path() -> Option<PathBuf> {
80    crate::core::paths::state_dir()
81        .ok()
82        .map(|dir| dir.join(RECORD_FILE))
83}
84
85pub fn read_record() -> Option<LaunchRecord> {
86    let bytes = std::fs::read(record_path()?).ok()?;
87    if bytes.len() > 16 * 1024 {
88        return None;
89    }
90    serde_json::from_slice::<LaunchRecord>(&bytes)
91        .ok()
92        .filter(|record| record.schema_version == 1)
93}
94
95pub fn write_record(record: &LaunchRecord) {
96    let Some(path) = record_path() else {
97        return;
98    };
99    if let Some(dir) = path.parent() {
100        let _ = std::fs::create_dir_all(dir);
101    }
102    if let Ok(bytes) = serde_json::to_vec_pretty(record) {
103        let _ = crate::core::updater::atomic_write_bytes(&path, &bytes);
104    }
105}
106
107/// Remove the record when it names `pid` (a newer server may own it).
108pub fn remove_record_of(pid: u32) {
109    if read_record().is_some_and(|record| record.pid == pid)
110        && let Some(path) = record_path()
111    {
112        let _ = std::fs::remove_file(path);
113    }
114}
115
116#[cfg(test)]
117mod tests {
118    use super::*;
119
120    #[test]
121    fn the_record_round_trips_and_carries_no_token() {
122        let record = LaunchRecord::of_this_process("127.0.0.1", 3333, "");
123        let json = serde_json::to_string(&record).unwrap();
124        assert!(!json.contains("token"));
125        assert_eq!(serde_json::from_str::<LaunchRecord>(&json).unwrap(), record);
126        assert!(record.process_alive());
127    }
128}