use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{anyhow, bail, Result};
use super::{DaemonHandle, DaemonHost};
use crate::tui::{self, LogSource, LogTx, ServiceStatus};
pub struct RemoteHost {
socket_path: PathBuf,
dev_dir_override: Option<PathBuf>,
log_tx: Option<LogTx>,
}
impl RemoteHost {
pub fn new(
socket_path: PathBuf,
dev_dir_override: Option<PathBuf>,
log_tx: Option<LogTx>,
) -> Self {
Self {
socket_path,
dev_dir_override,
log_tx,
}
}
}
impl DaemonHost for RemoteHost {
fn pre_start_dev_dir(&self) -> Option<PathBuf> {
self.dev_dir_override
.clone()
.or_else(|| Some(default_dev_dir()))
}
fn apps_dir(&self) -> Option<PathBuf> {
lane_apps_dir(&self.socket_path)
}
fn ensure_running(&self) -> Result<DaemonHandle> {
if !self.socket_path.exists() {
return Err(anyhow!(
"socket '{}' does not exist locally. \
Either start the daemon, or set up an SSH tunnel:\n \
ssh -fNL {}:/run/node/control.sock pi@pi.local",
self.socket_path.display(),
self.socket_path.display(),
));
}
let dev_dir = self
.dev_dir_override
.clone()
.unwrap_or_else(default_dev_dir);
std::fs::create_dir_all(&dev_dir).ok();
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Ready,
Some(format!("remote socket: {}", self.socket_path.display())),
);
Ok(DaemonHandle {
name: String::new(),
builtin_apps_dir: lane_apps_dir(&self.socket_path),
socket_path: self.socket_path.clone(),
dev_dir,
banner: format!(
"remote daemon at {} (you started it; we just sideload)",
self.socket_path.display()
),
api_base_url: None,
})
}
fn restart(&self) -> Result<()> {
tui::sys_log(self.log_tx.as_ref(), "→ sending app.restart over IPC…");
let result = ipc_call(&self.socket_path, "app.restart", serde_json::json!({}));
match result {
Ok(_) => {
tui::sys_log(self.log_tx.as_ref(), "✓ restart request sent to remote daemon");
tui::update_status(
self.log_tx.as_ref(),
LogSource::Daemon,
ServiceStatus::Starting,
Some("restart sent via IPC".into()),
);
}
Err(e) => {
tui::sys_log(
self.log_tx.as_ref(),
format!(
"⚠ app.restart IPC failed: {e}\n \
The remote daemon may not support IPC restart. \
Restart it manually."
),
);
}
}
Ok(())
}
}
fn ipc_call(socket_path: &std::path::Path, method: &str, params: serde_json::Value) -> Result<serde_json::Value> {
let stream = UnixStream::connect(socket_path).with_context(|| {
format!("connect to {}", socket_path.display())
})?;
stream.set_read_timeout(Some(Duration::from_secs(10))).ok();
stream.set_write_timeout(Some(Duration::from_secs(10))).ok();
let req = serde_json::json!({
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
});
let mut line = serde_json::to_string(&req)?;
line.push('\n');
let mut stream = stream;
stream.write_all(line.as_bytes())?;
stream.flush().ok();
let response_line = BufReader::new(&stream)
.lines()
.next()
.ok_or_else(|| anyhow!("daemon closed connection without responding"))??;
let v: serde_json::Value = serde_json::from_str(&response_line)?;
if let Some(err) = v.get("error") {
bail!("IPC error: {}", err);
}
Ok(v["result"].clone())
}
use anyhow::Context;
pub(crate) fn lane_apps_dir(socket_path: &Path) -> Option<PathBuf> {
let env = std::fs::read_to_string(socket_path.parent()?.join("daemon.env")).ok()?;
let first = |key: &str| {
env.lines()
.find_map(|line| line.trim().strip_prefix(key)?.strip_prefix('='))
.map(str::trim)
};
let declared = Path::new(first("NODE_IPC_SOCKET")?).canonicalize().ok()?;
if declared != socket_path.canonicalize().ok()? {
return None;
}
let apps_dir = PathBuf::from(first("APT_APPS_DIR")?);
(apps_dir.is_absolute() && apps_dir.is_dir()).then_some(apps_dir)
}
fn default_dev_dir() -> PathBuf {
if let Ok(env) = std::env::var("NODE_DEV_APPS_DIR") {
if !env.is_empty() {
return PathBuf::from(env);
}
}
let home = std::env::var_os("HOME").map(PathBuf::from).unwrap_or_default();
home.join(".local/share/node-app/dev-apps")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_lane_socket_names_its_apps_dir_and_any_other_socket_does_not() {
let tmp = tempfile::tempdir().unwrap();
let lane = tmp.path().canonicalize().unwrap();
let modules = lane.join("node/modules");
std::fs::create_dir_all(&modules).unwrap();
let socket = lane.join("control.sock");
std::fs::write(&socket, "").unwrap();
std::fs::write(
lane.join("daemon.env"),
format!(
"# APT_APPS_DIR: load apps from modules/\nAPT_APPS_DIR={}\nNODE_IPC_SOCKET={}\nAPT_APPS_DIR=../../modules\n",
modules.display(),
socket.display()
),
)
.unwrap();
assert_eq!(lane_apps_dir(&socket), Some(modules));
let elsewhere = lane.join("other.sock");
std::fs::write(&elsewhere, "").unwrap();
assert_eq!(lane_apps_dir(&elsewhere), None, "the env file names another socket");
assert_eq!(lane_apps_dir(&tmp.path().join("nowhere/control.sock")), None);
}
}