1use std::path::{Path, PathBuf};
2
3use anyhow::{Context, Result};
4use nix::fcntl::{Flock, FlockArg};
5
6use crate::codegen::quadlet;
7use crate::config::{self, Config};
8use crate::env::HostEnv;
9use crate::podman::{PodmanVersion, podman_version};
10use crate::systemd;
11use crate::xdg::ResolvedXdgDirs;
12
13pub fn quadlet_dir() -> PathBuf {
15 dirs::config_dir()
16 .unwrap_or_else(|| config::expand_tilde("~/.config"))
17 .join("containers/systemd")
18}
19
20pub fn flat_container_path(name: &str) -> PathBuf {
22 quadlet_dir().join(format!("{name}.container"))
23}
24
25pub fn application_container_path(name: &str) -> PathBuf {
28 quadlet_dir().join(name).join(format!("{name}.container"))
29}
30
31pub fn is_installed(name: &str) -> bool {
33 container_unit_path(name).is_some()
34}
35
36pub fn container_unit_path(name: &str) -> Option<PathBuf> {
38 let flat = flat_container_path(name);
39 if flat.exists() {
40 return Some(flat);
41 }
42 let app = application_container_path(name);
43 if app.exists() {
44 return Some(app);
45 }
46 None
47}
48
49pub fn list_installed_names() -> Vec<String> {
51 let qdir = quadlet_dir();
52 let mut names = Vec::new();
53
54 let Ok(entries) = std::fs::read_dir(&qdir) else {
55 return names;
56 };
57
58 for entry in entries.flatten() {
59 let path = entry.path();
60 if path.extension().is_some_and(|e| e == "container") {
61 if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
62 names.push(stem.to_string());
63 }
64 continue;
65 }
66 if path.is_dir() {
68 let Some(dir_name) = path.file_name().and_then(|s| s.to_str()) else {
69 continue;
70 };
71 let nested = path.join(format!("{dir_name}.container"));
72 if nested.exists() {
73 names.push(dir_name.to_string());
74 }
75 }
76 }
77
78 names.sort();
79 names.dedup();
80 names
81}
82
83fn systemd_user_dir() -> PathBuf {
85 dirs::config_dir()
86 .unwrap_or_else(|| config::expand_tilde("~/.config"))
87 .join("systemd/user")
88}
89
90fn write_custom_units(
93 name: &str,
94 sdir: &Path,
95 socket_content: &str,
96 host_service_content: &str,
97 dbus_proxy_content: Option<&str>,
98 compositor_service_content: Option<&str>,
99) -> Result<()> {
100 std::fs::create_dir_all(sdir)?;
101 std::fs::write(sdir.join(format!("{name}.socket")), socket_content)?;
102 std::fs::write(
103 sdir.join(format!("{name}-host.service")),
104 host_service_content,
105 )?;
106 if let Some(proxy) = dbus_proxy_content {
107 std::fs::write(sdir.join(format!("{name}-proxy.service")), proxy)?;
108 }
109 if let Some(comp) = compositor_service_content {
110 std::fs::write(sdir.join(format!("{name}-compositor.service")), comp)?;
111 }
112 write_clean_stop_dropin(name, sdir)?;
113 Ok(())
114}
115
116fn write_clean_stop_dropin(name: &str, sdir: &Path) -> Result<()> {
123 let dir = sdir.join(format!("{name}.service.d"));
124 std::fs::create_dir_all(&dir)?;
125 std::fs::write(
126 dir.join("99-podbox-clean-stop.conf"),
127 "[Service]\nSuccessExitStatus=0 143 SIGTERM SIGINT\n",
128 )?;
129 Ok(())
130}
131
132fn finalize_units(
134 name: &str,
135 sdir: &Path,
136 socket_content: &str,
137 host_service_content: &str,
138 dbus_proxy_content: Option<&str>,
139 compositor_service_content: Option<&str>,
140 use_wayland_proxy: bool,
141) -> Result<()> {
142 write_custom_units(
143 name,
144 sdir,
145 socket_content,
146 host_service_content,
147 dbus_proxy_content,
148 compositor_service_content,
149 )?;
150 println!("Systemd units installed to {}", sdir.display());
151
152 systemd::daemon_reload()?;
153 systemd::reset_failed(name)?;
154 systemd::stop_socket_and_host(name)?;
155 if use_wayland_proxy {
156 systemd::stop_compositor(name)?;
157 }
158 systemd::enable_now_socket(name)?;
159 Ok(())
160}
161
162fn podman_quadlet_install_files(
167 name: &str,
168 container_content: &str,
169 build_content: Option<&str>,
170) -> Result<()> {
171 let tmp = std::env::temp_dir().join(format!("podbox-install-{name}"));
172 let _ = std::fs::remove_dir_all(&tmp);
173 std::fs::create_dir_all(&tmp)?;
174
175 let container_path = tmp.join(format!("{name}.container"));
176 std::fs::write(&container_path, container_content)?;
177
178 let mut args: Vec<std::ffi::OsString> = vec![
179 "quadlet".into(),
180 "install".into(),
181 "--replace".into(),
182 container_path.into(),
183 ];
184
185 if let Some(bc) = build_content {
186 let build_path = tmp.join(format!("{name}.build"));
187 std::fs::write(&build_path, bc)?;
188 args.push(build_path.into());
189 }
190
191 let output = crate::process::run_piped("podman", &args)?;
192 let _ = std::fs::remove_dir_all(&tmp);
193 if !output.status.success() {
194 let stderr = String::from_utf8_lossy(&output.stderr);
195 anyhow::bail!("podman quadlet install failed: {stderr}");
196 }
197 println!("Quadlet files installed via podman quadlet install.");
198 Ok(())
199}
200
201fn podman_quadlet_install_application(
207 name: &str,
208 container_content: &str,
209 build_content: Option<&str>,
210) -> Result<()> {
211 let tmp = std::env::temp_dir().join(format!("podbox-install-{name}"));
212 let _ = std::fs::remove_dir_all(&tmp);
213 std::fs::create_dir_all(&tmp)?;
214
215 std::fs::write(tmp.join(format!("{name}.container")), container_content)?;
216 if let Some(bc) = build_content {
217 std::fs::write(tmp.join(format!("{name}.build")), bc)?;
218 }
219
220 let args: Vec<std::ffi::OsString> = vec![
221 "quadlet".into(),
222 "install".into(),
223 "--replace".into(),
224 "--application".into(),
225 name.into(),
226 tmp.clone().into(),
227 ];
228
229 let output = crate::process::run_piped("podman", &args)?;
230 let _ = std::fs::remove_dir_all(&tmp);
231 if !output.status.success() {
232 let stderr = String::from_utf8_lossy(&output.stderr);
233 anyhow::bail!("podman quadlet install --application failed: {stderr}");
234 }
235 println!("Quadlet files installed via podman quadlet install --application {name}.");
236 Ok(())
237}
238
239fn remove_flat_units(name: &str) {
244 let qdir = quadlet_dir();
245 for ext in ["container", "build"] {
246 let path = qdir.join(format!("{name}.{ext}"));
247 if !path.exists() {
248 continue;
249 }
250 let args: Vec<std::ffi::OsString> = vec![
252 "quadlet".into(),
253 "rm".into(),
254 format!("{name}.{ext}").into(),
255 ];
256 let _ = crate::process::run_piped("podman", &args);
257 let _ = std::fs::remove_file(&path);
259 }
260}
261
262fn remove_application_dir(name: &str) {
264 let app_dir = quadlet_dir().join(name);
265 if app_dir.is_dir() {
266 let _ = std::fs::remove_dir_all(&app_dir);
267 }
268}
269
270fn preflight_check(config: &Config) -> Result<()> {
272 let name = &config.container.name;
273
274 if !config.container.home.exists() {
276 eprintln!(
277 " Note: home directory '{}' will be created (does not exist yet).",
278 config.container.home.display()
279 );
280 }
281
282 for mount in &config.container.mounts.extra {
284 let host_path = match mount.split_once(':') {
285 Some((host, _)) => host,
286 None => mount,
287 };
288 let path = std::path::Path::new(host_path);
289 if !path.exists() {
290 if crate::codegen::distros::is_tty() {
291 let prompt = format!(
292 "Mount path '{}' does not exist on the host. Create it?",
293 path.display()
294 );
295 let create =
296 dialoguer::Confirm::with_theme(&dialoguer::theme::ColorfulTheme::default())
297 .with_prompt(prompt)
298 .default(true)
299 .interact_opt()?;
300 if create == Some(true) {
301 std::fs::create_dir_all(path).with_context(|| {
302 format!("failed to create mount directory '{}'", path.display())
303 })?;
304 println!("✓ Directory '{}' created.", path.display());
305 } else {
306 eprintln!(
307 "Warning: mount path '{}' does not exist on the host. This may cause the container to fail to load.",
308 path.display()
309 );
310 }
311 } else {
312 eprintln!(
313 "Warning: mount path '{}' does not exist on the host (container '{}').",
314 path.display(),
315 name
316 );
317 }
318 }
319 }
320
321 let is_running = crate::podman::query_state(name)
323 .map(|state| state == crate::podman::ContainerState::Running)
324 .unwrap_or(false);
325
326 if is_running {
328 println!(
329 " Note: container '{name}' is running. Skipping port conflict checks for upgrade."
330 );
331 return Ok(());
332 }
333
334 let conflicts = crate::ports::check_host_ports(&config.network.ports);
336 if !conflicts.is_empty() {
337 let listed = conflicts
338 .iter()
339 .map(ToString::to_string)
340 .collect::<Vec<_>>()
341 .join(", ");
342 anyhow::bail!(
343 "Port conflict: already in use on the host — {listed}. \
344 Find the process with: `ss -ltnp 'sport = :<port>'`"
345 );
346 }
347
348 if config.security.cap_preset == crate::config::CapPreset::Admin {
350 if crate::codegen::distros::is_tty() {
351 let caps = config.security.cap_preset.caps().join(", ");
352 let confirmed = dialoguer::Confirm::with_theme(
353 &dialoguer::theme::ColorfulTheme::default(),
354 )
355 .with_prompt(format!(
356 "WARNING: CapPreset::Admin grants {caps}. Only proceed if you fully trust this container. Continue?"
357 ))
358 .default(false)
359 .interact()?;
360 if !confirmed {
361 anyhow::bail!(
362 "Aborted — set cap_preset to a lower level or use cap_add for specific caps"
363 );
364 }
365 } else {
366 let caps = config.security.cap_preset.caps().join(", ");
367 eprintln!(
368 "Note: cap_preset = \"admin\" grants {caps}. Non-interactive mode, continuing without confirmation."
369 );
370 }
371 }
372
373 Ok(())
374}
375
376pub fn install(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs, dry_run: bool) -> Result<()> {
378 let name = &config.container.name;
379 let ver = podman_version().unwrap_or(PodmanVersion {
380 major: 5,
381 minor: 5,
382 patch: 0,
383 });
384 let qdir = quadlet_dir();
385 let sdir = systemd_user_dir();
386 let context_dir = crate::build::build_context_dir(name);
387 let containerfile_path = context_dir.join("Containerfile");
388
389 let socket_content = quadlet::generate_socket(config);
390 let container_content = quadlet::generate_container(config, env, xdg);
391 let host_service_content = quadlet::generate_host_service(name);
392 let dbus_proxy_content = quadlet::generate_dbus_proxy_service(name, config);
393 let compositor_service_content = quadlet::generate_compositor_service(name, config);
394
395 let build_content = if !config.image.source().is_prebuilt() {
396 Some(quadlet::generate_build(config, &containerfile_path))
397 } else {
398 None
399 };
400
401 if dry_run {
402 if let Some(ref bc) = build_content {
403 println!("=== {name}.build ===");
404 println!("{bc}");
405 println!();
406 }
407 println!("=== {name}.socket ===");
408 println!("{socket_content}");
409 println!();
410 println!("=== {name}.container ===");
411 println!("{container_content}");
412 println!();
413 println!("=== {name}-host.service ===");
414 println!("{host_service_content}");
415 if let Some(ref proxy) = dbus_proxy_content {
416 println!();
417 println!("=== {name}-proxy.service ===");
418 println!("{proxy}");
419 }
420 if let Some(ref comp) = compositor_service_content {
421 println!();
422 println!("=== {name}-compositor.service ===");
423 println!("{comp}");
424 }
425 return Ok(());
426 }
427
428 let _install_lock = {
430 let lock_path = context_dir.join(".install.lock");
431 let _ = std::fs::create_dir_all(&context_dir);
432 let file = std::fs::File::create(&lock_path).with_context(|| {
433 format!("failed to create install lock at '{}'", lock_path.display())
434 })?;
435 Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?
436 };
437
438 let _ = std::fs::create_dir_all(&context_dir);
440 std::fs::write(
441 context_dir.join(".flatpak-info"),
442 "[Application]\nname=podbox\n",
443 )?;
444
445 preflight_check(config)?;
447
448 std::fs::create_dir_all(&config.container.home).with_context(|| {
450 format!(
451 "failed to create home dir '{}'",
452 config.container.home.display()
453 )
454 })?;
455
456 if ver.at_least(6, 0) {
457 remove_flat_units(name);
459 podman_quadlet_install_application(name, &container_content, build_content.as_deref())?;
460 } else if ver.at_least(5, 6) {
461 remove_application_dir(name);
463 podman_quadlet_install_files(name, &container_content, build_content.as_deref())?;
464 } else {
465 std::fs::create_dir_all(&qdir)?;
467 if let Some(ref bc) = build_content {
468 std::fs::write(qdir.join(format!("{name}.build")), bc)?;
469 }
470 std::fs::write(qdir.join(format!("{name}.container")), container_content)?;
471 println!("Quadlet files installed to {}", qdir.display());
472 }
473
474 finalize_units(
475 name,
476 &sdir,
477 &socket_content,
478 &host_service_content,
479 dbus_proxy_content.as_deref(),
480 compositor_service_content.as_deref(),
481 config.use_wayland_proxy(),
482 )?;
483
484 for app in &config.integration.export.apps {
486 if let Err(e) = crate::export::export_app(name, app) {
487 eprintln!("Warning: auto-export app '{app}' failed: {e}");
488 }
489 }
490 for bin in &config.integration.export.bins {
491 if let Err(e) = crate::export::export_bin(name, bin) {
492 eprintln!("Warning: auto-export bin '{bin}' failed: {e}");
493 }
494 }
495
496 if config.lifecycle.autostart {
497 systemd::enable_linger()?;
498 }
499
500 Ok(())
501}
502
503pub fn uninstall(name: &str) -> Result<()> {
505 let ver = podman_version().unwrap_or(PodmanVersion {
506 major: 5,
507 minor: 5,
508 patch: 0,
509 });
510 let qdir = quadlet_dir();
511 let sdir = systemd_user_dir();
512
513 if ver.at_least(5, 6) {
514 let mut removed_via_podman = false;
515
516 for ext in ["container", "build"] {
518 let path = qdir.join(format!("{name}.{ext}"));
519 if !path.exists() {
520 continue;
521 }
522 let args: Vec<std::ffi::OsString> = vec![
523 "quadlet".into(),
524 "rm".into(),
525 format!("{name}.{ext}").into(),
526 ];
527 let output = crate::process::run_piped("podman", &args)?;
528 if output.status.success() {
529 removed_via_podman = true;
530 } else {
531 let stderr = String::from_utf8_lossy(&output.stderr);
533 eprintln!("Warning: podman quadlet rm {name}.{ext} failed: {stderr}");
534 }
535 }
536
537 let app_dir = qdir.join(name);
539 if app_dir.is_dir() {
540 if ver.at_least(6, 0) {
541 let args: Vec<std::ffi::OsString> = vec![
542 "quadlet".into(),
543 "rm".into(),
544 "--recursive".into(),
545 name.into(),
546 ];
547 let output = crate::process::run_piped("podman", &args)?;
548 if output.status.success() {
549 removed_via_podman = true;
550 } else {
551 let stderr = String::from_utf8_lossy(&output.stderr);
552 eprintln!("Warning: podman quadlet rm --recursive {name} failed: {stderr}");
553 }
554 }
555 remove_application_dir(name);
556 }
557
558 for ext in ["build", "container"] {
560 let path = qdir.join(format!("{name}.{ext}"));
561 if path.exists() {
562 std::fs::remove_file(&path)?;
563 }
564 }
565
566 if removed_via_podman {
567 println!("Quadlet files removed via podman quadlet rm.");
568 }
569 } else {
570 for ext in ["build", "container"] {
572 let path = qdir.join(format!("{name}.{ext}"));
573 if path.exists() {
574 std::fs::remove_file(&path)?;
575 }
576 }
577 remove_application_dir(name);
578 }
579
580 for unit in [
582 "socket",
583 "host.service",
584 "proxy.service",
585 "compositor.service",
586 ] {
587 let path = sdir.join(format!("{name}.{unit}"));
588 if path.exists() {
589 std::fs::remove_file(&path)?;
590 }
591 }
592
593 let dropin_dir = sdir.join(format!("{name}.service.d"));
595 if dropin_dir.is_dir() {
596 std::fs::remove_dir_all(&dropin_dir)?;
597 }
598
599 systemd::daemon_reload()?;
600 println!("Files for '{name}' removed.");
601
602 Ok(())
603}
604
605#[cfg(test)]
606mod tests {
607 use super::*;
608
609 #[test]
610 fn flat_and_application_paths_differ() {
611 let flat = flat_container_path("myenv");
612 let app = application_container_path("myenv");
613 assert!(flat.to_string_lossy().ends_with("myenv.container"));
614 assert!(
615 app.to_string_lossy().ends_with("myenv/myenv.container")
616 || app.to_string_lossy().ends_with("myenv\\myenv.container")
617 );
618 assert_ne!(flat, app);
619 }
620}