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!("{}.socket", name)), socket_content)?;
102 std::fs::write(
103 sdir.join(format!("{}-host.service", name)),
104 host_service_content,
105 )?;
106 if let Some(proxy) = dbus_proxy_content {
107 std::fs::write(sdir.join(format!("{}-proxy.service", name)), proxy)?;
108 }
109 if let Some(comp) = compositor_service_content {
110 std::fs::write(sdir.join(format!("{}-compositor.service", name)), 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!("{}.service.d", name));
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.into(),
227 ];
228
229 let output = crate::process::run_piped("podman", &args)?;
230 let _ = std::fs::remove_dir_all(std::env::temp_dir().join(format!("podbox-install-{name}")));
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 '{}' is running. Skipping port conflict checks for upgrade.",
330 name
331 );
332 return Ok(());
333 }
334
335 let conflicts = crate::ports::check_host_ports(&config.network.ports);
337 if !conflicts.is_empty() {
338 let listed = conflicts
339 .iter()
340 .map(ToString::to_string)
341 .collect::<Vec<_>>()
342 .join(", ");
343 anyhow::bail!(
344 "Port conflict: already in use on the host — {listed}. \
345 Find the process with: `ss -ltnp 'sport = :<port>'`"
346 );
347 }
348
349 if config.security.cap_preset == crate::config::CapPreset::Admin {
351 if crate::codegen::distros::is_tty() {
352 let caps = config.security.cap_preset.caps().join(", ");
353 let confirmed = dialoguer::Confirm::with_theme(
354 &dialoguer::theme::ColorfulTheme::default(),
355 )
356 .with_prompt(format!(
357 "WARNING: CapPreset::Admin grants {caps}. Only proceed if you fully trust this container. Continue?"
358 ))
359 .default(false)
360 .interact()?;
361 if !confirmed {
362 anyhow::bail!(
363 "Aborted — set cap_preset to a lower level or use cap_add for specific caps"
364 );
365 }
366 } else {
367 let caps = config.security.cap_preset.caps().join(", ");
368 eprintln!(
369 "Note: cap_preset = \"admin\" grants {caps}. Non-interactive mode, continuing without confirmation."
370 );
371 }
372 }
373
374 Ok(())
375}
376
377pub fn install(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs, dry_run: bool) -> Result<()> {
379 let name = &config.container.name;
380 let ver = podman_version().unwrap_or(PodmanVersion {
381 major: 5,
382 minor: 5,
383 patch: 0,
384 });
385 let qdir = quadlet_dir();
386 let sdir = systemd_user_dir();
387 let context_dir = crate::build::build_context_dir(name);
388 let containerfile_path = context_dir.join("Containerfile");
389
390 let socket_content = quadlet::generate_socket(config);
391 let container_content = quadlet::generate_container(config, env, xdg);
392 let host_service_content = quadlet::generate_host_service(name);
393 let dbus_proxy_content = quadlet::generate_dbus_proxy_service(name, config);
394 let compositor_service_content = quadlet::generate_compositor_service(name, config);
395
396 let build_content = if !config.image.source().is_prebuilt() {
397 Some(quadlet::generate_build(config, &containerfile_path))
398 } else {
399 None
400 };
401
402 if dry_run {
403 if let Some(ref bc) = build_content {
404 println!("=== {}.build ===", name);
405 println!("{}", bc);
406 println!();
407 }
408 println!("=== {}.socket ===", name);
409 println!("{}", socket_content);
410 println!();
411 println!("=== {}.container ===", name);
412 println!("{}", container_content);
413 println!();
414 println!("=== {}-host.service ===", name);
415 println!("{}", host_service_content);
416 if let Some(ref proxy) = dbus_proxy_content {
417 println!();
418 println!("=== {}-proxy.service ===", name);
419 println!("{}", proxy);
420 }
421 if let Some(ref comp) = compositor_service_content {
422 println!();
423 println!("=== {}-compositor.service ===", name);
424 println!("{}", comp);
425 }
426 return Ok(());
427 }
428
429 let _install_lock = {
431 let lock_path = context_dir.join(".install.lock");
432 let _ = std::fs::create_dir_all(&context_dir);
433 let file = std::fs::File::create(&lock_path).with_context(|| {
434 format!("failed to create install lock at '{}'", lock_path.display())
435 })?;
436 Flock::lock(file, FlockArg::LockExclusive).map_err(|(_, e)| e)?
437 };
438
439 let _ = std::fs::create_dir_all(&context_dir);
441 std::fs::write(
442 context_dir.join(".flatpak-info"),
443 "[Application]\nname=podbox\n",
444 )?;
445
446 preflight_check(config)?;
448
449 std::fs::create_dir_all(&config.container.home).with_context(|| {
451 format!(
452 "failed to create home dir '{}'",
453 config.container.home.display()
454 )
455 })?;
456
457 if ver.at_least(6, 0) {
458 remove_flat_units(name);
460 podman_quadlet_install_application(name, &container_content, build_content.as_deref())?;
461 } else if ver.at_least(5, 6) {
462 remove_application_dir(name);
464 podman_quadlet_install_files(name, &container_content, build_content.as_deref())?;
465 } else {
466 std::fs::create_dir_all(&qdir)?;
468 if let Some(ref bc) = build_content {
469 std::fs::write(qdir.join(format!("{name}.build")), bc)?;
470 }
471 std::fs::write(qdir.join(format!("{name}.container")), container_content)?;
472 println!("Quadlet files installed to {}", qdir.display());
473 }
474
475 finalize_units(
476 name,
477 &sdir,
478 &socket_content,
479 &host_service_content,
480 dbus_proxy_content.as_deref(),
481 compositor_service_content.as_deref(),
482 config.use_wayland_proxy(),
483 )?;
484
485 for app in &config.integration.export.apps {
487 if let Err(e) = crate::export::export_app(name, app) {
488 eprintln!("Warning: auto-export app '{}' failed: {}", app, e);
489 }
490 }
491 for bin in &config.integration.export.bins {
492 if let Err(e) = crate::export::export_bin(name, bin) {
493 eprintln!("Warning: auto-export bin '{}' failed: {}", bin, e);
494 }
495 }
496
497 if config.lifecycle.autostart {
498 systemd::enable_linger()?;
499 }
500
501 Ok(())
502}
503
504pub fn uninstall(name: &str) -> Result<()> {
506 let ver = podman_version().unwrap_or(PodmanVersion {
507 major: 5,
508 minor: 5,
509 patch: 0,
510 });
511 let qdir = quadlet_dir();
512 let sdir = systemd_user_dir();
513
514 if ver.at_least(5, 6) {
515 let mut removed_via_podman = false;
516
517 for ext in ["container", "build"] {
519 let path = qdir.join(format!("{name}.{ext}"));
520 if !path.exists() {
521 continue;
522 }
523 let args: Vec<std::ffi::OsString> = vec![
524 "quadlet".into(),
525 "rm".into(),
526 format!("{name}.{ext}").into(),
527 ];
528 let output = crate::process::run_piped("podman", &args)?;
529 if output.status.success() {
530 removed_via_podman = true;
531 } else {
532 let stderr = String::from_utf8_lossy(&output.stderr);
534 eprintln!("Warning: podman quadlet rm {name}.{ext} failed: {stderr}");
535 }
536 }
537
538 let app_dir = qdir.join(name);
540 if app_dir.is_dir() {
541 if ver.at_least(6, 0) {
542 let args: Vec<std::ffi::OsString> = vec![
543 "quadlet".into(),
544 "rm".into(),
545 "--recursive".into(),
546 name.into(),
547 ];
548 let output = crate::process::run_piped("podman", &args)?;
549 if output.status.success() {
550 removed_via_podman = true;
551 } else {
552 let stderr = String::from_utf8_lossy(&output.stderr);
553 eprintln!("Warning: podman quadlet rm --recursive {name} failed: {stderr}");
554 }
555 }
556 remove_application_dir(name);
557 }
558
559 for ext in ["build", "container"] {
561 let path = qdir.join(format!("{name}.{ext}"));
562 if path.exists() {
563 std::fs::remove_file(&path)?;
564 }
565 }
566
567 if removed_via_podman {
568 println!("Quadlet files removed via podman quadlet rm.");
569 }
570 } else {
571 for ext in ["build", "container"] {
573 let path = qdir.join(format!("{name}.{ext}"));
574 if path.exists() {
575 std::fs::remove_file(&path)?;
576 }
577 }
578 remove_application_dir(name);
579 }
580
581 for unit in [
583 "socket",
584 "host.service",
585 "proxy.service",
586 "compositor.service",
587 ] {
588 let path = sdir.join(format!("{name}.{unit}"));
589 if path.exists() {
590 std::fs::remove_file(&path)?;
591 }
592 }
593
594 let dropin_dir = sdir.join(format!("{name}.service.d"));
596 if dropin_dir.is_dir() {
597 std::fs::remove_dir_all(&dropin_dir)?;
598 }
599
600 systemd::daemon_reload()?;
601 println!("Files for '{name}' removed.");
602
603 Ok(())
604}
605
606#[cfg(test)]
607mod tests {
608 use super::*;
609
610 #[test]
611 fn flat_and_application_paths_differ() {
612 let flat = flat_container_path("myenv");
613 let app = application_container_path("myenv");
614 assert!(flat.to_string_lossy().ends_with("myenv.container"));
615 assert!(
616 app.to_string_lossy().ends_with("myenv/myenv.container")
617 || app.to_string_lossy().ends_with("myenv\\myenv.container")
618 );
619 assert_ne!(flat, app);
620 }
621}