use anyhow::{Context, Result};
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use super::layout::InstanceLayout;
use crate::app::LaunchSpec;
pub struct Launcher;
impl Launcher {
pub fn generate_file(
alias: &str,
layout: &InstanceLayout,
binary_name: &str,
gui: bool,
) -> String {
let target = format!("${{WARREN_INSTANCE_DIR}}/bin/{}", shell_quote(binary_name));
Self::render(alias, layout, &target, gui)
}
pub fn generate_wrapped(alias: &str, layout: &InstanceLayout, spec: &LaunchSpec) -> String {
let quoted: Vec<String> = spec.command.iter().map(|a| shell_quote(a)).collect();
Self::render(alias, layout, "ed.join(" "), spec.gui)
}
fn render(alias: &str, layout: &InstanceLayout, exec_target: &str, gui: bool) -> String {
let instance_dir = shell_quote(&layout.root.to_string_lossy());
let runtime_block = if gui {
r#": "${XDG_RUNTIME_DIR:=/run/user/$(id -u)}"
export XDG_RUNTIME_DIR
export WARREN_HOST_RUNTIME_DIR="$XDG_RUNTIME_DIR"
export WARREN_RUNTIME_DIR="${WARREN_INSTANCE_DIR}/runtime"
mkdir -p "$WARREN_RUNTIME_DIR""#
} else {
r#"export XDG_RUNTIME_DIR="${WARREN_INSTANCE_DIR}/runtime""#
};
format!(
r#"#!/usr/bin/env bash
# Warren launcher — {alias}
# Generated by warren {version}
set -euo pipefail
WARREN_INSTANCE_DIR={instance_dir}
export HOME="${{WARREN_INSTANCE_DIR}}/home"
export XDG_CONFIG_HOME="${{WARREN_INSTANCE_DIR}}/config"
export XDG_CACHE_HOME="${{WARREN_INSTANCE_DIR}}/cache"
export XDG_DATA_HOME="${{WARREN_INSTANCE_DIR}}/data"
export XDG_STATE_HOME="${{WARREN_INSTANCE_DIR}}/state"
{runtime_block}
export TMPDIR="${{WARREN_INSTANCE_DIR}}/tmp"
export WARREN_INSTANCE="{alias}"
export WARREN_INSTANCE_DIR
# --- GUI / desktop session passthrough -----------------------------------
# These are inherited on exec, but listed explicitly so graphical apps keep
# working under the sandbox (display server, keyring, audio, ssh-agent).
for _wvar in DISPLAY WAYLAND_DISPLAY XAUTHORITY DBUS_SESSION_BUS_ADDRESS \
SESSION_MANAGER DESKTOP_SESSION XDG_SESSION_TYPE XDG_CURRENT_DESKTOP \
XDG_SESSION_DESKTOP PULSE_SERVER PULSE_COOKIE SSH_AUTH_SOCK; do
if [ -n "${{!_wvar:-}}" ]; then export "$_wvar"; fi
done
unset _wvar
exec {exec_target} "$@"
"#,
alias = alias,
version = env!("CARGO_PKG_VERSION"),
instance_dir = instance_dir,
runtime_block = runtime_block,
exec_target = exec_target,
)
}
pub fn write(layout: &InstanceLayout, content: &str) -> Result<PathBuf> {
let launcher_path = layout.launcher_path();
std::fs::write(&launcher_path, content)
.with_context(|| format!("failed to write launcher to {}", launcher_path.display()))?;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&launcher_path, perms)
.with_context(|| format!("failed to set permissions on {}", launcher_path.display()))?;
tracing::debug!(path = %launcher_path.display(), "wrote launcher script");
Ok(launcher_path)
}
pub fn install(layout: &InstanceLayout, bin_dir: &Path, alias: &str) -> Result<PathBuf> {
std::fs::create_dir_all(bin_dir)
.with_context(|| format!("failed to create bin directory {}", bin_dir.display()))?;
let target = bin_dir.join(alias);
let launcher = layout.launcher_path();
if target.exists() || target.symlink_metadata().is_ok() {
std::fs::remove_file(&target).with_context(|| {
format!("failed to remove existing launcher at {}", target.display())
})?;
}
match std::os::unix::fs::symlink(&launcher, &target) {
Ok(()) => {
tracing::debug!(src = %launcher.display(), dst = %target.display(), "symlinked launcher");
}
Err(e) => {
tracing::warn!(error = %e, "symlink failed, falling back to copy");
std::fs::copy(&launcher, &target)
.with_context(|| format!("failed to copy launcher to {}", target.display()))?;
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&target, perms)?;
}
}
Ok(target)
}
pub fn uninstall(bin_dir: &Path, alias: &str) -> Result<()> {
let target = bin_dir.join(alias);
if target.exists() || target.symlink_metadata().is_ok() {
std::fs::remove_file(&target)
.with_context(|| format!("failed to remove launcher at {}", target.display()))?;
tracing::debug!(path = %target.display(), "removed launcher");
}
Ok(())
}
}
pub fn shell_quote(s: &str) -> String {
if s.is_empty() {
return "''".to_string();
}
if s.chars().all(|c| {
c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | '/' | ':' | '=' | ',' | '+')
}) {
return s.to_string();
}
format!("'{}'", s.replace('\'', "'\\''"))
}
#[cfg(test)]
mod tests {
use super::*;
fn layout() -> InstanceLayout {
InstanceLayout {
root: PathBuf::from("/home/u/.warren/instances/demo"),
alias: "demo".to_string(),
}
}
#[test]
fn cli_launcher_overrides_runtime_and_execs_bin() {
let out = Launcher::generate_file("demo", &layout(), "mybin", false);
assert!(out.contains("export XDG_RUNTIME_DIR=\"${WARREN_INSTANCE_DIR}/runtime\""));
assert!(out.contains("exec ${WARREN_INSTANCE_DIR}/bin/mybin \"$@\""));
assert!(out.contains("WAYLAND_DISPLAY"));
}
#[test]
fn gui_wrapped_launcher_keeps_host_runtime() {
let spec = LaunchSpec {
display_name: "discord".to_string(),
command: vec![
"flatpak".to_string(),
"run".to_string(),
"com.discordapp.Discord".to_string(),
],
gui: true,
icon: None,
desktop_id: None,
};
let out = Launcher::generate_wrapped("discord-work", &layout(), &spec);
assert!(out.contains("WARREN_HOST_RUNTIME_DIR"));
assert!(!out.contains("export XDG_RUNTIME_DIR=\"${WARREN_INSTANCE_DIR}/runtime\""));
assert!(out.contains("exec flatpak run com.discordapp.Discord \"$@\""));
}
#[test]
fn quoting_escapes_spaces_and_single_quotes() {
assert_eq!(shell_quote("plain-1.2:/x"), "plain-1.2:/x");
assert_eq!(shell_quote(""), "''");
assert_eq!(shell_quote("/opt/my app/run"), "'/opt/my app/run'");
assert_eq!(shell_quote("o'clock"), "'o'\\''clock'");
let spec = LaunchSpec {
display_name: "x".to_string(),
command: vec!["/opt/my app/run".to_string(), "--flag".to_string()],
gui: false,
icon: None,
desktop_id: None,
};
let out = Launcher::generate_wrapped("x", &layout(), &spec);
assert!(out.contains("exec '/opt/my app/run' --flag \"$@\""));
}
}