use std::path::{Path, PathBuf};
use crate::config::{Config, GpuMode};
use crate::env::HostEnv;
use crate::xdg::ResolvedXdgDirs;
fn home() -> PathBuf {
dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
}
pub fn generate_build(config: &Config, containerfile_path: &Path) -> String {
let mut lines: Vec<String> = Vec::new();
lines.push("[Build]".into());
lines.push(format!(
"ImageTag=localhost/podbox-{}:latest",
config.image.name
));
lines.push(format!("File={}", containerfile_path.to_string_lossy()));
lines.push(format!("Retry={}", config.image.pull_retry));
lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
lines.join("\n")
}
pub fn generate_socket(config: &Config) -> String {
let name = &config.container.name;
let host_service = format!("{}-host.service", name);
let mut lines: Vec<String> = Vec::new();
lines.push("[Unit]".into());
lines.push(format!("Description=podbox host-guest socket -- {}", name));
lines.push(String::new());
lines.push("[Socket]".into());
lines.push(format!("ListenStream=%t/podbox/{}.sock", name));
lines.push(format!("Service={}", host_service));
lines.push("SocketMode=0600".into());
lines.push("DirectoryMode=0700".into());
lines.push("RuntimeDirectory=podbox".into());
lines.push("RuntimeDirectoryMode=0700".into());
lines.push(String::new());
lines.push("[Install]".into());
lines.push("WantedBy=sockets.target".into());
lines.join("\n")
}
pub fn generate_container(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs) -> String {
let name = &config.container.name;
let home_in_container = "/home/%u";
let mut lines: Vec<String> = Vec::new();
emit_unit(&mut lines, config, name);
emit_container_image(&mut lines, config, name, home_in_container, env);
emit_network(&mut lines, config);
emit_volumes(&mut lines, config, xdg, env, name, home_in_container);
emit_env(&mut lines, config, name, env);
emit_gpu(&mut lines, config, env);
emit_auto_update(&mut lines, config);
emit_podman_args(&mut lines, config);
emit_service_section(&mut lines, config);
emit_install_section(&mut lines, config);
lines.join("\n")
}
fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
lines.push("[Unit]".into());
lines.push(format!("Description=podbox -- {}", name));
lines.push(format!("Requires={}.socket", name));
lines.push(format!("After={}.socket", name));
for dep in &config.systemd.requires {
lines.push(format!("Requires={}", dep));
}
for dep in &config.systemd.after {
lines.push(format!("After={}", dep));
}
if config.use_dbus_proxy() {
lines.push(format!("Requires={}-proxy.service", name));
lines.push(format!("After={}-proxy.service", name));
}
if config.use_wayland_proxy() {
lines.push(format!("Requires={}-compositor.service", name));
lines.push(format!("After={}-compositor.service", name));
}
lines.push("StartLimitBurst=5".into());
lines.push("StartLimitIntervalSec=30s".into());
lines.push(String::new());
}
fn emit_container_image(
lines: &mut Vec<String>,
config: &Config,
name: &str,
home_in_container: &str,
env: &HostEnv,
) {
lines.push("[Container]".into());
if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
let ref_str = match config.image.source() {
crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
_ => config.image.base.clone(),
};
lines.push(format!("Image={}", ref_str));
lines.push(format!("Retry={}", config.image.pull_retry));
lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
} else {
lines.push(format!(
"Image=localhost/podbox-{}:latest",
config.image.name
));
}
lines.push(format!("ContainerName={}", name));
if let Some(ref mode) = config.security.userns {
lines.push(format!("UserNS={}", mode));
} else {
lines.push("UserNS=keep-id".into());
}
lines.push("User=root".into());
if config.security.security_label_disable {
lines.push("SecurityLabelDisable=true".into());
}
if let Some(ref seccomp) = config.security.seccomp {
lines.push(format!("SeccompProfile={}", seccomp));
}
if config.security.no_new_privileges {
lines.push("NoNewPrivileges=true".into());
}
if let Some(ref mem) = config.container.memory {
lines.push(format!("Memory={}", mem));
}
if let Some(ref cpus) = config.container.cpus {
if let Ok(v) = cpus.parse::<f64>() {
#[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
let quota = (v * 100_000.0) as u64;
lines.push(format!("CpuQuota={}", quota));
}
}
if config.security.read_only_rootfs {
lines.push("ReadOnly=true".into());
}
if let Some(ref profile) = config.security.apparmor {
lines.push(format!("AppArmor={}", profile));
}
lines.push(format!("Environment=HOME={}", home_in_container));
lines.push(format!("Environment=HOST_USER={}", env.username));
lines.push("Environment=HOST_UID=%U".into());
lines.push("Environment=HOST_GID=%G".into());
lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
lines.push(String::new());
}
fn emit_network(lines: &mut Vec<String>, config: &Config) {
lines.push(format!("Network={}", config.network.mode));
if config.network.mode != "host" {
for port in &config.network.ports {
lines.push(format!("PublishPort={}", port));
}
}
lines.push(String::new());
}
fn emit_volumes(
lines: &mut Vec<String>,
config: &Config,
xdg: &ResolvedXdgDirs,
env: &HostEnv,
name: &str,
home_in_container: &str,
) {
let host_home = config.container.home.to_string_lossy().to_string();
lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
lines.push(String::new());
emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
if xdg.documents.is_some()
|| xdg.downloads.is_some()
|| xdg.pictures.is_some()
|| xdg.music.is_some()
|| xdg.videos.is_some()
|| xdg.desktop.is_some()
|| xdg.projects.is_some()
{
lines.push(String::new());
}
if config.integration.sync_themes {
let h = home();
if h.join(".themes").exists() {
lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
}
if env.host_has_local_share_themes {
lines.push(format!(
"Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
));
}
}
if config.integration.sync_icons {
let h = home();
if h.join(".icons").exists() {
lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
}
if env.host_has_local_share_icons {
lines.push(format!(
"Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
));
}
}
if config.integration.sync_fonts {
let h = home();
if h.join(".fonts").exists() {
lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
}
if env.host_has_local_share_fonts {
lines.push(format!(
"Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
));
}
}
if config.integration.sync_themes
|| config.integration.sync_icons
|| config.integration.sync_fonts
{
lines.push(String::new());
}
if env.host_has_localtime {
lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
}
if env.host_has_timezone_file {
lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
}
if env.host_has_localtime || env.host_has_timezone_file {
lines.push(String::new());
}
lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
if config.integration.wayland {
if let Some(ref display) = env.wayland_display {
lines.push(format!("Environment=WAYLAND_DISPLAY={}", display));
lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
if config.wayland.firewall {
lines.push(format!(
"Volume=%t/podbox/{}-wayland.sock:%t/{}:ro",
name, display
));
} else {
lines.push(format!("Volume=%t/{}:%t/{}:ro", display, display));
}
lines.push(String::new());
}
}
if config.integration.audio {
if env.pipewire_socket.is_some() {
lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
}
if env.pulse_dir.is_some() {
lines.push("Volume=%t/pulse:%t/pulse".into());
lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
}
if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
lines.push(String::new());
}
}
if config.integration.ssh_agent {
let ver = crate::podman::podman_version().ok();
if ver.is_some_and(|v| v.at_least(5, 6) && !v.at_least(6, 0)) {
lines.push("SshAgent=default".into());
lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
} else {
eprintln!(
"Warning: ssh_agent = true requires Podman 5.6 - 5.x for SSH_AUTH_SOCK passthrough. Skipping SSH agent."
);
}
lines.push(String::new());
}
if config.integration.gpg_agent {
if let Some(ref sock) = env.gpg_agent_socket {
lines.push(format!(
"Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
sock.display()
));
lines.push("Environment=GPG_TTY=/dev/pts/0".into());
lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
} else {
eprintln!(
"Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
);
}
lines.push(String::new());
}
let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
lines.push(format!(
"Volume={}:/.flatpak-info:ro",
flatpak_info_path.display()
));
lines.push(String::new());
if config.integration.dbus && env.dbus_socket.is_some() {
if config.use_dbus_proxy() {
lines.push(format!(
"Volume=%t/podbox/{}-dbus.sock:/run/podbox/dbus.sock:ro",
name
));
lines.push(
"Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
);
} else {
lines.push("Volume=%t/bus:%t/bus".into());
lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
}
lines.push(String::new());
}
lines.push(format!(
"Volume=%t/podbox/{}.sock:%t/podbox/{}.sock",
name, name
));
lines.push(String::new());
for mount in &config.container.mounts.extra {
lines.push(format!("Volume={}", mount));
}
if !config.container.mounts.extra.is_empty() {
lines.push(String::new());
}
}
fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
if let Some(ref locale) = _env.host_locale {
lines.push(format!("Environment=LANG={}", locale));
lines.push(format!("Environment=LC_ALL={}", locale));
lines.push(format!("Environment=LC_CTYPE={}", locale));
lines.push(String::new());
}
for (key, value) in &config.container.env {
if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
let clean = value.replace('\n', " ").replace('\r', "");
let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
let env_val = if escaped.contains(' ') || escaped.is_empty() {
format!("\"{}\"", escaped)
} else {
escaped
};
lines.push(format!("Environment={}={}", key, env_val));
} else {
eprintln!(
"Warning: ignoring invalid environment variable key '{}'",
key
);
}
}
lines.push(format!("Environment=PODBOX_CONTAINER={}", name));
lines.push(String::new());
}
fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
match config.integration.gpu {
GpuMode::Enabled => {
lines.push("AddDevice=/dev/dri".into());
lines.push(String::new());
}
GpuMode::Nvidia => {
lines.push("AddDevice=/dev/dri".into());
lines.push("AddDevice=-/dev/nvidiactl".into());
lines.push("AddDevice=-/dev/nvidia0".into());
if env.gpu_has_nvidia_uvm {
lines.push("AddDevice=-/dev/nvidia-uvm".into());
}
lines.push(String::new());
}
GpuMode::Auto => {
if env.gpu_has_dri {
lines.push("AddDevice=/dev/dri".into());
}
if env.gpu_has_nvidia {
lines.push("AddDevice=-/dev/nvidiactl".into());
lines.push("AddDevice=-/dev/nvidia0".into());
if env.gpu_has_nvidia_uvm {
lines.push("AddDevice=-/dev/nvidia-uvm".into());
}
}
if env.gpu_has_dri || env.gpu_has_nvidia {
lines.push(String::new());
}
}
GpuMode::Disabled => {}
}
}
fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
if config.lifecycle.auto_update {
if config.image.source().is_prebuilt() {
lines.push("AutoUpdate=registry".into());
} else {
lines.push("AutoUpdate=local".into());
}
lines.push(String::new());
}
}
fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
lines.push("PodmanArgs=--init".into());
lines.push("PodmanArgs=--workdir=/home/%u".into());
let cap_preset = config.security.cap_preset;
let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
for cap in cap_preset.caps() {
lines.push(format!("PodmanArgs=--cap-add={}", cap));
}
for cap in &config.security.cap_add {
lines.push(format!("PodmanArgs=--cap-add={}", cap));
}
if has_any_cap {
lines.push(String::new());
}
if let Some(ref cmd) = config.container.reload_cmd {
lines.push(format!("ReloadCmd={}", cmd));
lines.push(String::new());
}
}
fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
lines.push("[Service]".into());
lines.push("Restart=on-failure".into());
lines.push("RestartSec=2s".into());
if config.lifecycle.on_stop == crate::config::OnStop::Remove {
lines.push("AutoRemove=true".into());
}
lines.push(String::new());
}
fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
lines.push("[Install]".into());
if config.lifecycle.autostart {
lines.push("WantedBy=default.target".into());
}
}
pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
if !config.use_dbus_proxy() {
return None;
}
let mut args = vec![
"unix:path=%t/bus".to_string(),
format!("%t/podbox/{}-dbus.sock", name),
];
args.push("--filter".into());
for service in &config.dbus_effective_talk() {
args.push(format!("--talk={}", service));
}
for rule in config.dbus_portal_calls() {
args.push(rule);
}
for service in &config.dbus.own {
args.push(format!("--own={}", service));
}
let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
Some(format!(
r#"[Unit]
Description=D-Bus Proxy for podbox container {name}
PartOf={name}.service
[Service]
Type=simple
ExecStart={exec_start}
Restart=on-failure
RestartSec=1s
[Install]
WantedBy={name}.service
"#,
name = name,
exec_start = exec_start,
))
}
pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
if !config.use_wayland_proxy() {
return None;
}
let podbox_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/usr/local/bin/podbox".into());
Some(format!(
r#"[Unit]
Description=Wayland Firewall Proxy for podbox container {name}
PartOf={name}.service
[Service]
Type=simple
ExecStart={podbox_bin} compositor {name}
Restart=on-failure
RestartSec=1s
[Install]
WantedBy={name}.service
"#,
name = name,
podbox_bin = podbox_bin,
))
}
pub fn generate_host_service(name: &str) -> String {
let podbox_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| "/usr/local/bin/podbox".into());
format!(
r#"[Unit]
Description=podbox host socket server -- {name}
[Service]
Type=simple
ExecStart={podbox_bin} serve {name}
Restart=on-failure
RestartSec=2s
[Install]
WantedBy={name}.socket
"#,
name = name,
podbox_bin = podbox_bin,
)
}
fn emit_xdg_dir(
lines: &mut Vec<String>,
dir_name: &str,
xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
container_home: &str,
) {
if let Some(resolved) = xdg_dir {
let mode = if resolved.read_write { "z" } else { "ro,z" };
lines.push(format!(
"Volume={}:{container_home}/{dir_name}:{mode}",
resolved.path.display()
));
}
}