1use std::path::{Path, PathBuf};
2
3use crate::config::{Config, GpuMode};
4use crate::env::HostEnv;
5use crate::xdg::ResolvedXdgDirs;
6
7fn home() -> PathBuf {
8 dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
9}
10
11pub fn generate_build(config: &Config, containerfile_path: &Path) -> String {
13 let mut lines: Vec<String> = Vec::new();
14
15 lines.push("[Build]".into());
16 lines.push(format!(
17 "ImageTag=localhost/podbox-{}:latest",
18 config.image.name
19 ));
20 lines.push(format!("File={}", containerfile_path.to_string_lossy()));
21 lines.push(format!("Retry={}", config.image.pull_retry));
22 lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
23
24 lines.join("\n")
25}
26
27pub fn generate_socket(config: &Config) -> String {
29 let name = &config.container.name;
30 let host_service = format!("{name}-host.service");
31 let mut lines: Vec<String> = Vec::new();
32
33 lines.push("[Unit]".into());
34 lines.push(format!("Description=podbox host-guest socket -- {name}"));
35 lines.push(String::new());
36
37 lines.push("[Socket]".into());
38 lines.push(format!("ListenStream=%t/podbox/{name}.sock"));
39 lines.push(format!("Service={host_service}"));
40 lines.push("SocketMode=0600".into());
41 lines.push("DirectoryMode=0700".into());
42 lines.push("RuntimeDirectory=podbox".into());
43 lines.push("RuntimeDirectoryMode=0700".into());
44 lines.push("RuntimeDirectoryPreserve=yes".into());
48 lines.push(String::new());
49
50 lines.push("[Install]".into());
51 lines.push("WantedBy=sockets.target".into());
52
53 lines.join("\n")
54}
55
56pub fn generate_container(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs) -> String {
60 let name = &config.container.name;
61 let home_in_container = "/home/%u";
62 let mut lines: Vec<String> = Vec::new();
63
64 emit_unit(&mut lines, config, name);
65 emit_container_image(&mut lines, config, name, home_in_container, env);
66 emit_network(&mut lines, config);
67 emit_volumes(&mut lines, config, xdg, env, name, home_in_container);
68 emit_env(&mut lines, config, name, env);
69 emit_gpu(&mut lines, config, env);
70 emit_auto_update(&mut lines, config);
71 emit_podman_args(&mut lines, config);
72 emit_service_section(&mut lines, config);
73 emit_install_section(&mut lines, config);
74
75 lines.join("\n")
76}
77
78fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
79 lines.push("[Unit]".into());
80 lines.push(format!("Description=podbox -- {name}"));
81 lines.push(format!("Requires={name}.socket"));
82 lines.push(format!("After={name}.socket"));
83 for dep in &config.systemd.requires {
84 lines.push(format!("Requires={dep}"));
85 }
86 for dep in &config.systemd.after {
87 lines.push(format!("After={dep}"));
88 }
89 if config.use_dbus_proxy() {
90 lines.push(format!("Requires={name}-proxy.service"));
91 lines.push(format!("After={name}-proxy.service"));
92 }
93 if config.use_wayland_proxy() {
94 lines.push(format!("Requires={name}-compositor.service"));
95 lines.push(format!("After={name}-compositor.service"));
96 }
97 lines.push("StartLimitBurst=5".into());
98 lines.push("StartLimitIntervalSec=30s".into());
99 lines.push(String::new());
100}
101
102fn emit_container_image(
103 lines: &mut Vec<String>,
104 config: &Config,
105 name: &str,
106 home_in_container: &str,
107 env: &HostEnv,
108) {
109 lines.push("[Container]".into());
110 if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
111 let ref_str = match config.image.source() {
112 crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
113 _ => config.image.base.clone(),
114 };
115 lines.push(format!("Image={ref_str}"));
116 lines.push(format!("Retry={}", config.image.pull_retry));
117 lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
118 } else {
119 lines.push(format!(
120 "Image=localhost/podbox-{}:latest",
121 config.image.name
122 ));
123 }
124 lines.push(format!("ContainerName={name}"));
125 if let Some(ref mode) = config.security.userns {
126 lines.push(format!("UserNS={mode}"));
127 } else {
128 lines.push("UserNS=keep-id".into());
129 }
130 lines.push("User=root".into());
131 if config.security.security_label_disable {
132 lines.push("SecurityLabelDisable=true".into());
133 }
134 if let Some(ref seccomp) = config.security.seccomp {
135 lines.push(format!("SeccompProfile={seccomp}"));
136 }
137 if config.security.no_new_privileges {
138 lines.push("NoNewPrivileges=true".into());
139 }
140 if let Some(ref mem) = config.container.memory {
141 lines.push(format!("Memory={mem}"));
142 }
143 if let Some(ref cpus) = config.container.cpus {
144 if let Ok(v) = cpus.parse::<f64>() {
145 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
146 let quota = (v * 100_000.0) as u64;
147 lines.push(format!("CpuQuota={quota}"));
148 }
149 }
150 if config.security.read_only_rootfs {
151 lines.push("ReadOnly=true".into());
152 }
153 if let Some(ref profile) = config.security.apparmor {
154 lines.push(format!("AppArmor={profile}"));
155 }
156 lines.push(format!("Environment=HOME={home_in_container}"));
157 lines.push(format!("Environment=HOST_USER={}", env.username));
158 lines.push("Environment=HOST_UID=%U".into());
159 lines.push("Environment=HOST_GID=%G".into());
160 lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
161 lines.push(String::new());
162}
163
164fn emit_network(lines: &mut Vec<String>, config: &Config) {
165 lines.push(format!("Network={}", config.network.mode));
166 if config.network.mode != "host" {
167 for port in &config.network.ports {
168 lines.push(format!("PublishPort={port}"));
169 }
170 }
171 lines.push(String::new());
172}
173
174fn emit_volumes(
175 lines: &mut Vec<String>,
176 config: &Config,
177 xdg: &ResolvedXdgDirs,
178 env: &HostEnv,
179 name: &str,
180 home_in_container: &str,
181) {
182 let host_home = config.container.home.to_string_lossy().to_string();
184 lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
185 lines.push(String::new());
186
187 emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
189 emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
190 emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
191 emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
192 emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
193 emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
194 emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
195
196 if xdg.documents.is_some()
197 || xdg.downloads.is_some()
198 || xdg.pictures.is_some()
199 || xdg.music.is_some()
200 || xdg.videos.is_some()
201 || xdg.desktop.is_some()
202 || xdg.projects.is_some()
203 {
204 lines.push(String::new());
205 }
206
207 if config.integration.sync_themes {
209 let h = home();
210 if h.join(".themes").exists() {
211 lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
212 }
213 if env.host_has_local_share_themes {
214 lines.push(format!(
215 "Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
216 ));
217 }
218 }
219 if config.integration.sync_icons {
220 let h = home();
221 if h.join(".icons").exists() {
222 lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
223 }
224 if env.host_has_local_share_icons {
225 lines.push(format!(
226 "Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
227 ));
228 }
229 }
230 if config.integration.sync_fonts {
231 let h = home();
232 if h.join(".fonts").exists() {
233 lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
234 }
235 if env.host_has_local_share_fonts {
236 lines.push(format!(
237 "Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
238 ));
239 }
240 }
241 if config.integration.sync_themes
242 || config.integration.sync_icons
243 || config.integration.sync_fonts
244 {
245 lines.push(String::new());
246 }
247
248 if env.host_has_localtime {
250 lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
251 }
252 if env.host_has_timezone_file {
253 lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
254 }
255 if env.host_has_localtime || env.host_has_timezone_file {
256 lines.push(String::new());
257 }
258
259 lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
262
263 if config.integration.wayland {
265 if let Some(ref display) = env.wayland_display {
266 lines.push(format!("Environment=WAYLAND_DISPLAY={display}"));
267 lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
268 if config.wayland.firewall {
269 lines.push(format!(
270 "Volume=%t/podbox/{name}-wayland.sock:%t/{display}:ro"
271 ));
272 } else {
273 lines.push(format!("Volume=%t/{display}:%t/{display}:ro"));
274 }
275 lines.push(String::new());
276 }
277 }
278
279 if config.integration.audio {
281 if env.pipewire_socket.is_some() {
282 lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
283 lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
284 }
285 if env.pulse_dir.is_some() {
286 lines.push("Volume=%t/pulse:%t/pulse".into());
287 lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
288 }
289 if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
290 lines.push(String::new());
291 }
292 }
293
294 if config.integration.ssh_agent {
296 if let Some(ref sock) = env.ssh_agent_socket {
297 lines.push(format!(
298 "Volume={}:/run/podbox/ssh-agent.sock",
299 sock.display()
300 ));
301 lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
302 } else {
303 eprintln!(
304 "Warning: ssh_agent = true but SSH_AUTH_SOCK not found on host. Skipping SSH agent."
305 );
306 }
307 lines.push(String::new());
308 }
309
310 if config.integration.gpg_agent {
312 if let Some(ref sock) = env.gpg_agent_socket {
313 lines.push(format!(
314 "Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
315 sock.display()
316 ));
317 lines.push("Environment=GPG_TTY=/dev/pts/0".into());
318 lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
319 } else {
320 eprintln!(
321 "Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
322 );
323 }
324 lines.push(String::new());
325 }
326
327 let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
329 lines.push(format!(
330 "Volume={}:/.flatpak-info:ro",
331 flatpak_info_path.display()
332 ));
333 lines.push(String::new());
334
335 if config.integration.dbus && env.dbus_socket.is_some() {
337 if config.use_dbus_proxy() {
338 lines.push(format!(
339 "Volume=%t/podbox/{name}-dbus.sock:/run/podbox/dbus.sock:ro"
340 ));
341 lines.push(
342 "Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
343 );
344 } else {
345 lines.push("Volume=%t/bus:%t/bus".into());
346 lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
347 }
348 lines.push(String::new());
349 }
350
351 lines.push(format!(
353 "Volume=%t/podbox/{name}.sock:%t/podbox/{name}.sock"
354 ));
355 lines.push(String::new());
356
357 for mount in &config.container.mounts.extra {
359 lines.push(format!("Volume={mount}"));
360 }
361 if !config.container.mounts.extra.is_empty() {
362 lines.push(String::new());
363 }
364}
365
366fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
367 if let Some(ref locale) = _env.host_locale {
369 lines.push(format!("Environment=LANG={locale}"));
370 lines.push(format!("Environment=LC_ALL={locale}"));
371 lines.push(format!("Environment=LC_CTYPE={locale}"));
372 lines.push(String::new());
373 }
374
375 for (key, value) in &config.container.env {
377 if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
378 let clean = value.replace('\n', " ").replace('\r', "");
379 let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
380 let env_val = if escaped.contains(' ') || escaped.is_empty() {
381 format!("\"{escaped}\"")
382 } else {
383 escaped
384 };
385 lines.push(format!("Environment={key}={env_val}"));
386 } else {
387 eprintln!("Warning: ignoring invalid environment variable key '{key}'");
388 }
389 }
390 lines.push(format!("Environment=PODBOX_CONTAINER={name}"));
391 lines.push(String::new());
392}
393
394fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
395 match config.integration.gpu {
396 GpuMode::Enabled => {
397 lines.push("AddDevice=/dev/dri".into());
398 lines.push(String::new());
399 }
400 GpuMode::Nvidia => {
401 lines.push("AddDevice=/dev/dri".into());
402 lines.push("AddDevice=-/dev/nvidiactl".into());
403 lines.push("AddDevice=-/dev/nvidia0".into());
404 if env.gpu_has_nvidia_uvm {
405 lines.push("AddDevice=-/dev/nvidia-uvm".into());
406 }
407 lines.push(String::new());
408 }
409 GpuMode::Auto => {
410 if env.gpu_has_dri {
411 lines.push("AddDevice=/dev/dri".into());
412 }
413 if env.gpu_has_nvidia {
414 lines.push("AddDevice=-/dev/nvidiactl".into());
415 lines.push("AddDevice=-/dev/nvidia0".into());
416 if env.gpu_has_nvidia_uvm {
417 lines.push("AddDevice=-/dev/nvidia-uvm".into());
418 }
419 }
420 if env.gpu_has_dri || env.gpu_has_nvidia {
421 lines.push(String::new());
422 }
423 }
424 GpuMode::Disabled => {}
425 }
426}
427
428fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
429 if config.lifecycle.auto_update {
430 if config.image.source().is_prebuilt() {
431 lines.push("AutoUpdate=registry".into());
432 } else {
433 lines.push("AutoUpdate=local".into());
434 }
435 lines.push(String::new());
436 }
437}
438
439fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
440 lines.push("PodmanArgs=--init".into());
441 lines.push("PodmanArgs=--workdir=/home/%u".into());
442 let cap_preset = config.security.cap_preset;
443 let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
444 for cap in cap_preset.caps() {
445 lines.push(format!("PodmanArgs=--cap-add={cap}"));
446 }
447 for cap in &config.security.cap_add {
448 lines.push(format!("PodmanArgs=--cap-add={cap}"));
449 }
450 if has_any_cap {
451 lines.push(String::new());
452 }
453 if let Some(ref cmd) = config.container.reload_cmd {
454 lines.push(format!("ReloadCmd={cmd}"));
455 lines.push(String::new());
456 }
457}
458
459fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
460 lines.push("[Service]".into());
461 lines.push("Restart=on-failure".into());
462 lines.push("RestartSec=2s".into());
463 if config.lifecycle.on_stop == crate::config::OnStop::Remove {
464 lines.push("AutoRemove=true".into());
465 }
466 lines.push(String::new());
467}
468
469fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
470 lines.push("[Install]".into());
471 if config.lifecycle.autostart {
472 lines.push("WantedBy=default.target".into());
473 }
474}
475
476pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
478 if !config.use_dbus_proxy() {
479 return None;
480 }
481
482 let mut args = vec![
483 "unix:path=%t/bus".to_string(),
484 format!("%t/podbox/{}-dbus.sock", name),
485 ];
486
487 args.push("--filter".into());
488
489 for service in &config.dbus_effective_talk() {
490 args.push(format!("--talk={service}"));
491 }
492 for rule in config.dbus_portal_calls() {
493 args.push(rule);
494 }
495 for service in &config.dbus.own {
496 args.push(format!("--own={service}"));
497 }
498
499 let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
500
501 Some(format!(
502 r#"[Unit]
503Description=D-Bus Proxy for podbox container {name}
504PartOf={name}.service
505
506[Service]
507Type=simple
508ExecStart={exec_start}
509Restart=on-failure
510RestartSec=1s
511
512[Install]
513WantedBy={name}.service
514"#,
515 ))
516}
517
518pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
521 if !config.use_wayland_proxy() {
522 return None;
523 }
524 let podbox_bin = std::env::current_exe()
525 .map(|p| p.to_string_lossy().to_string())
526 .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
527
528 Some(format!(
529 r#"[Unit]
530Description=Wayland Firewall Proxy for podbox container {name}
531PartOf={name}.service
532
533[Service]
534Type=simple
535ExecStart={podbox_bin} compositor {name}
536Restart=on-failure
537RestartSec=1s
538
539[Install]
540WantedBy={name}.service
541"#,
542 ))
543}
544
545pub fn generate_host_service(name: &str) -> String {
547 let podbox_bin = std::env::current_exe()
548 .map(|p| p.to_string_lossy().to_string())
549 .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
550
551 format!(
552 r#"[Unit]
553Description=podbox host socket server -- {name}
554
555[Service]
556Type=simple
557ExecStart={podbox_bin} serve {name}
558Restart=on-failure
559RestartSec=2s
560
561[Install]
562WantedBy={name}.socket
563"#,
564 )
565}
566
567fn emit_xdg_dir(
568 lines: &mut Vec<String>,
569 dir_name: &str,
570 xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
571 container_home: &str,
572) {
573 if let Some(resolved) = xdg_dir {
574 let mode = if resolved.read_write { "z" } else { "ro,z" };
575 lines.push(format!(
576 "Volume={}:{container_home}/{dir_name}:{mode}",
577 resolved.path.display()
578 ));
579 }
580}