use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use crate::server::{ServerConfig, WorkspaceInit};
use crate::workspace::WorkspaceFlags;
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonWorkspace {
pub path: PathBuf,
#[serde(default)]
pub flags: WorkspaceFlags,
#[serde(default)]
pub initial_path: Option<String>,
#[serde(default)]
pub single_file: Option<String>,
#[serde(default)]
pub collaborator_access_code_hash: String,
#[serde(default)]
pub alias: String,
}
impl From<DaemonWorkspace> for WorkspaceInit {
fn from(w: DaemonWorkspace) -> Self {
WorkspaceInit {
path: w.path,
flags: w.flags,
initial_path: w.initial_path,
single_file: w.single_file,
collaborator_access_code_hash: w.collaborator_access_code_hash,
alias: w.alias,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DaemonConfig {
pub host: String,
#[serde(default)]
pub advertised_host: String,
#[serde(default)]
pub trusted_hosts: Vec<String>,
pub port: u16,
#[serde(default = "default_theme")]
pub theme: String,
#[serde(default)]
pub qr: Option<String>,
#[serde(default)]
pub open_browser: Option<String>,
#[serde(default)]
pub db_path: Option<String>,
#[serde(default)]
pub salt: Option<String>,
#[serde(default)]
pub workspaces: Vec<DaemonWorkspace>,
#[serde(default)]
pub language: Option<String>,
#[serde(default)]
pub shortcuts_json: Option<String>,
#[serde(default)]
pub styles_css: Option<String>,
#[serde(default)]
pub default_chat_mode: String,
#[serde(default)]
pub editor_theme: String,
#[serde(default)]
pub collaborator_access_code_hash: String,
#[serde(default)]
pub print_collapsed_content: bool,
}
fn default_theme() -> String {
"auto".to_string()
}
impl ServerConfig {
pub fn from_daemon_config(cfg: DaemonConfig) -> Self {
let shared_annotation = cfg.workspaces.iter().any(|w| w.flags.shared_annotation);
let initial_workspaces = cfg
.workspaces
.into_iter()
.map(WorkspaceInit::from)
.collect();
ServerConfig {
host: cfg.host,
advertised_host: cfg.advertised_host,
trusted_hosts: cfg.trusted_hosts,
port: cfg.port,
theme: cfg.theme,
qr: cfg.qr,
open_browser: cfg.open_browser,
shared_annotation,
db_path: cfg.db_path,
salt: cfg.salt,
initial_workspaces,
bound_listener: None,
registry: None,
management_token: None,
admin_bootstraps: None,
language: cfg.language,
shortcuts_json: cfg.shortcuts_json,
styles_css: cfg.styles_css,
default_chat_mode: cfg.default_chat_mode,
editor_theme: cfg.editor_theme,
collaborator_access_code_hash: cfg.collaborator_access_code_hash,
print_collapsed_content: cfg.print_collapsed_content,
}
}
}
pub fn locate_markond() -> std::io::Result<PathBuf> {
let bin = format!("markond{}", std::env::consts::EXE_SUFFIX);
let bin = bin.as_str();
let exe = std::env::current_exe().map_err(|error| {
std::io::Error::new(
error.kind(),
format!("could not resolve the current executable: {error}"),
)
})?;
let dir = exe.parent().ok_or_else(|| {
std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"current executable has no parent directory: {}",
exe.display()
),
)
})?;
let candidate = dir.join(bin);
if candidate.is_file() {
return Ok(candidate);
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!(
"could not find the '{bin}' service binary beside the current executable (looked in: {}). \
Ensure the markon front-end and markond are installed side by side.",
candidate.display()
),
))
}
fn write_daemon_config(config: &DaemonConfig) -> std::io::Result<PathBuf> {
use std::io::Write;
let json = serde_json::to_vec(config)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let name = format!(
"markond-config-{}-{}.json",
std::process::id(),
temp_suffix()
);
let path = std::env::temp_dir().join(name);
let mut options = std::fs::OpenOptions::new();
options.write(true).create_new(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600);
}
let mut file = options.open(&path)?;
file.write_all(&json)?;
Ok(path)
}
fn temp_suffix() -> u128 {
use std::time::{SystemTime, UNIX_EPOCH};
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0)
}
async fn wait_for_ready() -> Option<crate::workspace::ServerLock> {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(10);
loop {
if let Some(lock) = crate::workspace::ServerLock::read() {
if lock.is_alive() {
return Some(lock);
}
}
if Instant::now() >= deadline {
return None;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
}
pub async fn spawn_and_connect(
config: DaemonConfig,
) -> std::io::Result<crate::control::RunningServer> {
use std::process::Stdio;
let markond = locate_markond()?;
let config_path = write_daemon_config(&config)?;
let mut command = std::process::Command::new(&markond);
command
.arg("--config")
.arg(&config_path)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
command.creation_flags(CREATE_NO_WINDOW);
}
if let Err(e) = command.spawn() {
let _ = std::fs::remove_file(&config_path);
return Err(std::io::Error::new(
e.kind(),
format!("spawn {}: {e}", markond.display()),
));
}
let ready = wait_for_ready().await;
let _ = std::fs::remove_file(&config_path);
match ready {
Some(lock) => Ok(crate::control::RunningServer::from_lock(&lock)),
None => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
"the markon server did not become ready in time",
)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_json() {
let cfg = DaemonConfig {
host: "127.0.0.1".to_string(),
advertised_host: "192.168.1.5".to_string(),
trusted_hosts: vec!["md.example.com".to_string()],
port: 6419,
theme: "auto".to_string(),
qr: Some("https://md.example.com".to_string()),
open_browser: None,
db_path: Some("/tmp/x.sqlite".to_string()),
salt: Some("markon:6419".to_string()),
workspaces: vec![DaemonWorkspace {
path: PathBuf::from("/tmp/docs"),
flags: WorkspaceFlags {
enable_search: true,
shared_annotation: true,
..Default::default()
},
initial_path: Some("readme.md".to_string()),
single_file: None,
collaborator_access_code_hash: "deadbeef".to_string(),
alias: "docs".to_string(),
}],
language: Some("en".to_string()),
shortcuts_json: None,
styles_css: None,
default_chat_mode: "in_page".to_string(),
editor_theme: "follow".to_string(),
collaborator_access_code_hash: "cafef00d".to_string(),
print_collapsed_content: true,
};
let json = serde_json::to_string(&cfg).unwrap();
let back: DaemonConfig = serde_json::from_str(&json).unwrap();
let server = ServerConfig::from_daemon_config(back);
assert_eq!(server.host, "127.0.0.1");
assert_eq!(server.port, 6419);
assert_eq!(server.advertised_host, "192.168.1.5");
assert_eq!(server.trusted_hosts, vec!["md.example.com".to_string()]);
assert_eq!(server.qr.as_deref(), Some("https://md.example.com"));
assert!(server.open_browser.is_none());
assert_eq!(server.salt.as_deref(), Some("markon:6419"));
assert!(server.shared_annotation, "derived from workspace flags");
assert_eq!(server.initial_workspaces.len(), 1);
let ws = &server.initial_workspaces[0];
assert_eq!(ws.path, PathBuf::from("/tmp/docs"));
assert_eq!(ws.collaborator_access_code_hash, "deadbeef");
assert_eq!(ws.alias, "docs");
assert_eq!(server.collaborator_access_code_hash, "cafef00d");
assert!(server.print_collapsed_content);
assert!(server.registry.is_none());
assert!(server.bound_listener.is_none());
assert!(server.management_token.is_none());
assert!(server.admin_bootstraps.is_none());
}
}