use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use anyhow::{anyhow, bail, Context, Result};
pub fn run() -> Result<()> {
let workspace = find_workspace().ok_or_else(|| {
anyhow!(
"couldn't find the huddle source checkout to build from.\n\
`huddle app` builds the GUI from source — run it from inside a \
clone of the repo, set HUDDLE_SRC=/path/to/huddle, or install the \
GUI directly with `cargo install huddle-gui`."
)
})?;
println!("huddle source: {}", workspace.display());
build_gui(&workspace)?;
let built = workspace
.join("target")
.join("release")
.join(format!("huddle-gui{}", std::env::consts::EXE_SUFFIX));
if !built.exists() {
bail!(
"build reported success but the binary is missing at {}",
built.display()
);
}
match std::env::consts::OS {
"macos" => {
let app = install_macos(&built)?;
println!("installed {}", app.display());
launch_macos(&app)?;
}
"linux" => {
let dest = install_linux(&built)?;
launch_detached(&dest)?;
}
"windows" => {
let dest = install_windows(&built)?;
launch_detached(&dest)?;
}
other => {
eprintln!(
"note: `huddle app` doesn't know where to install on `{other}` — \
launching the freshly built binary in place."
);
launch_detached(&built)?;
}
}
Ok(())
}
fn build_gui(workspace: &Path) -> Result<()> {
println!("building huddle-gui (release) — the first build can take a few minutes…");
let status = Command::new(cargo_bin())
.args(["build", "--release", "-p", "huddle-gui"])
.current_dir(workspace)
.status()
.context("failed to launch `cargo` — is the Rust toolchain on your PATH?")?;
if !status.success() {
bail!("`cargo build -p huddle-gui` failed (exit {:?})", status.code());
}
Ok(())
}
fn cargo_bin() -> String {
std::env::var("CARGO").unwrap_or_else(|_| "cargo".to_string())
}
fn find_workspace() -> Option<PathBuf> {
if let Ok(p) = std::env::var("HUDDLE_SRC") {
let p = PathBuf::from(p);
if is_workspace(&p) {
return Some(p);
}
}
if let Ok(cwd) = std::env::current_dir() {
let mut dir: Option<&Path> = Some(cwd.as_path());
while let Some(d) = dir {
if is_workspace(d) {
return Some(d.to_path_buf());
}
dir = d.parent();
}
}
let built_from = Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(Path::parent);
if let Some(ws) = built_from {
if is_workspace(ws) {
return Some(ws.to_path_buf());
}
}
None
}
fn is_workspace(p: &Path) -> bool {
p.join("Cargo.toml").exists() && p.join("crates/huddle-gui/Cargo.toml").exists()
}
fn install_macos(bin: &Path) -> Result<PathBuf> {
let mut bases: Vec<PathBuf> = vec![PathBuf::from("/Applications")];
if let Some(home) = dirs::home_dir() {
bases.push(home.join("Applications"));
}
let mut last_err: Option<anyhow::Error> = None;
for base in bases {
match write_macos_bundle(&base, bin) {
Ok(app) => return Ok(app),
Err(e) => {
eprintln!(" ({} not usable: {e})", base.display());
last_err = Some(e);
}
}
}
Err(last_err.unwrap_or_else(|| anyhow!("no Applications directory available")))
}
fn write_macos_bundle(base: &Path, bin: &Path) -> Result<PathBuf> {
let app = base.join("Huddle.app");
let contents = app.join("Contents");
let macos = contents.join("MacOS");
let _ = std::fs::remove_dir_all(&app);
std::fs::create_dir_all(&macos)
.with_context(|| format!("create {}", macos.display()))?;
let dest = macos.join("huddle-gui");
let _ = std::fs::remove_file(&dest);
std::fs::copy(bin, &dest).with_context(|| format!("copy into {}", dest.display()))?;
set_executable(&dest)?;
std::fs::write(contents.join("Info.plist"), macos_info_plist())?;
let _ = std::fs::write(contents.join("PkgInfo"), "APPL????");
Ok(app)
}
fn macos_info_plist() -> String {
let version = env!("CARGO_PKG_VERSION");
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key><string>Huddle</string>
<key>CFBundleDisplayName</key><string>Huddle</string>
<key>CFBundleExecutable</key><string>huddle-gui</string>
<key>CFBundleIdentifier</key><string>com.huddle.gui</string>
<key>CFBundleVersion</key><string>{version}</string>
<key>CFBundleShortVersionString</key><string>{version}</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>LSMinimumSystemVersion</key><string>10.15</string>
<key>NSHighResolutionCapable</key><true/>
</dict>
</plist>
"#
)
}
fn launch_macos(app: &Path) -> Result<()> {
println!("launching {}", app.display());
let status = Command::new("open").arg(app).status()?;
if !status.success() {
bail!("`open {}` failed", app.display());
}
Ok(())
}
fn install_linux(bin: &Path) -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("no home directory"))?;
let bindir = home.join(".local/bin");
std::fs::create_dir_all(&bindir)?;
let dest = bindir.join("huddle-gui");
let _ = std::fs::remove_file(&dest);
std::fs::copy(bin, &dest).with_context(|| format!("copy into {}", dest.display()))?;
set_executable(&dest)?;
let apps = home.join(".local/share/applications");
std::fs::create_dir_all(&apps)?;
let desktop = apps.join("huddle.desktop");
std::fs::write(&desktop, linux_desktop_entry(&dest))?;
let _ = Command::new("update-desktop-database").arg(&apps).status();
println!("installed huddle-gui → {}", dest.display());
println!("added app launcher → {}", desktop.display());
if !dir_on_path(&bindir) {
println!(
"note: {} isn't on your PATH — add it (e.g. in ~/.profile) to run \
`huddle-gui` from a shell.",
bindir.display()
);
}
Ok(dest)
}
fn linux_desktop_entry(exec: &Path) -> String {
format!(
"[Desktop Entry]\n\
Type=Application\n\
Name=Huddle\n\
GenericName=Encrypted chat\n\
Comment=Decentralized end-to-end-encrypted chat\n\
Exec={exec} %U\n\
Icon=huddle\n\
Terminal=false\n\
Categories=Network;InstantMessaging;Chat;\n\
StartupWMClass=huddle\n",
exec = exec.display()
)
}
fn install_windows(bin: &Path) -> Result<PathBuf> {
let base = dirs::data_local_dir().ok_or_else(|| anyhow!("no %LOCALAPPDATA%"))?;
let dir = base.join("Programs").join("Huddle");
std::fs::create_dir_all(&dir)?;
let dest = dir.join("huddle-gui.exe");
let _ = std::fs::remove_file(&dest);
std::fs::copy(bin, &dest).with_context(|| format!("copy into {}", dest.display()))?;
if let Some(roaming) = dirs::data_dir() {
let lnk = roaming.join("Microsoft/Windows/Start Menu/Programs/Huddle.lnk");
if let Some(parent) = lnk.parent() {
let _ = std::fs::create_dir_all(parent);
}
let script = format!(
"$s=(New-Object -ComObject WScript.Shell).CreateShortcut('{lnk}');\
$s.TargetPath='{target}';$s.Save()",
lnk = lnk.display(),
target = dest.display()
);
let _ = Command::new("powershell")
.args(["-NoProfile", "-NonInteractive", "-Command", &script])
.status();
}
println!("installed huddle-gui → {}", dest.display());
Ok(dest)
}
fn launch_detached(bin: &Path) -> Result<()> {
println!("launching {}", bin.display());
Command::new(bin)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.with_context(|| format!("launch {}", bin.display()))?;
Ok(())
}
fn set_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perm = std::fs::metadata(path)?.permissions();
perm.set_mode(0o755);
std::fs::set_permissions(path, perm)?;
}
let _ = path;
Ok(())
}
fn dir_on_path(dir: &Path) -> bool {
std::env::var_os("PATH")
.map(|paths| std::env::split_paths(&paths).any(|p| p == dir))
.unwrap_or(false)
}