day-cli 0.0.7

Declarative app development API using native UI toolkits
//! windows-winui → NSIS `-setup.exe`: the classic direct-download installer (cross-compilable —
//! makensis runs on Linux/macOS too, which is why NSIS won the DP-6 "optional" slot over WiX MSI).
//! The .nsi is rendered from a minimal template: per-user install (no elevation → no UAC wall,
//! same choice Tauri defaults to), ARP uninstall entry, Start-Menu shortcut, silent `/S` support.
//! The setup exe is signed with the same provider chain as the MSIX.

use std::path::{Path, PathBuf};
use std::process::Command;

use super::settings::PackOptions;
use super::{Artifact, PackError, SignTier, run_tool};
use crate::meta::Project;
use crate::ops::status;

pub fn pack(
    project: &Project,
    opts: &PackOptions,
    payload: &Path,
    dist: &Path,
) -> Result<Artifact, PackError> {
    let makensis = find_makensis().ok_or_else(|| {
        PackError::Other(
            "makensis not found — install NSIS (choco install nsis / apt install nsis / brew install makensis), or set DAY_MAKENSIS (docs/environment.md)"
                .into(),
        )
    })?;

    let name = &project.manifest.app.name;
    let title = project
        .manifest
        .app
        .title
        .clone()
        .unwrap_or_else(|| name.clone());
    let version = &project.manifest.app.version;

    let work = project.root.join("build/day/pack/windows-nsis");
    std::fs::create_dir_all(&work).map_err(|e| PackError::Other(e.to_string()))?;
    let setup = dist.join(format!("{name}-{version}-setup.exe"));
    let _ = std::fs::remove_file(&setup);

    let nsi = work.join("installer.nsi");
    std::fs::write(
        &nsi,
        render_nsi(
            &title,
            name,
            version,
            &project.manifest.app.id,
            payload,
            &setup,
        ),
    )
    .map_err(|e| PackError::Other(e.to_string()))?;

    status("Packing", "makensis");
    run_tool(Command::new(&makensis).arg("-V2").arg(&nsi), "makensis").map_err(PackError::Other)?;
    if !setup.exists() {
        return Err(PackError::Other(format!(
            "makensis produced no installer at {}",
            setup.display()
        )));
    }

    // Signing needs signtool — Windows-host only; cross-compiled installers are left unsigned.
    let tier = if opts.no_sign || !cfg!(windows) {
        if !cfg!(windows) && !opts.no_sign {
            status(
                "Signing",
                "skipped (signtool is Windows-only; cross-compiled installer is unsigned)",
            );
        } else {
            status("Signing", "skipped (--no-sign)");
        }
        SignTier::Unsigned
    } else {
        let settings = super::msix::resolve_signing(project)?;
        super::msix::sign_file(&settings, &setup).map_err(PackError::Sign)?
    };

    Ok(Artifact {
        path: setup,
        kind: "nsis",
        sha256: String::new(),
        tier,
    })
}

/// A minimal, complete NSIS script: recursive payload, per-user install, uninstaller, ARP keys.
pub(crate) fn render_nsi(
    title: &str,
    name: &str,
    version: &str,
    id: &str,
    payload: &Path,
    out: &Path,
) -> String {
    // NSIS wants backslashes on Windows and tolerates them everywhere in File/OutFile paths.
    let payload_s = payload.display().to_string().replace('/', "\\");
    let out_s = out.display().to_string().replace('/', "\\");
    format!(
        r#"; generated by day pack — do not edit
Unicode true
Name "{title}"
OutFile "{out_s}"
InstallDir "$LOCALAPPDATA\{title}"
RequestExecutionLevel user
SetCompressor /SOLID lzma

!define UNINST_KEY "Software\Microsoft\Windows\CurrentVersion\Uninstall\{id}"

Page directory
Page instfiles
UninstPage uninstConfirm
UninstPage instfiles

Section "Install"
  SetOutPath "$INSTDIR"
  File /r "{payload_s}\*.*"
  WriteUninstaller "$INSTDIR\uninstall.exe"
  CreateShortcut "$SMPROGRAMS\{title}.lnk" "$INSTDIR\{name}.exe"
  WriteRegStr HKCU "${{UNINST_KEY}}" "DisplayName" "{title}"
  WriteRegStr HKCU "${{UNINST_KEY}}" "DisplayVersion" "{version}"
  WriteRegStr HKCU "${{UNINST_KEY}}" "DisplayIcon" "$INSTDIR\{name}.exe"
  WriteRegStr HKCU "${{UNINST_KEY}}" "UninstallString" "$\"$INSTDIR\uninstall.exe$\""
  WriteRegStr HKCU "${{UNINST_KEY}}" "QuietUninstallString" "$\"$INSTDIR\uninstall.exe$\" /S"
  WriteRegDWORD HKCU "${{UNINST_KEY}}" "NoModify" 1
  WriteRegDWORD HKCU "${{UNINST_KEY}}" "NoRepair" 1
SectionEnd

Section "Uninstall"
  Delete "$SMPROGRAMS\{title}.lnk"
  RMDir /r "$INSTDIR"
  DeleteRegKey HKCU "${{UNINST_KEY}}"
SectionEnd
"#
    )
}

fn find_makensis() -> Option<PathBuf> {
    // Shared, env-overridable lookup: DAY_MAKENSIS, then PATH, then the conventional
    // %ProgramFiles% install dir (docs/environment.md).
    day_toolchain::makensis()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn nsi_shape() {
        let nsi = render_nsi(
            "Day Showcase",
            "showcase",
            "0.1.0",
            "dev.daybrite.showcase",
            Path::new("C:/work/payload"),
            Path::new("C:/dist/showcase-0.1.0-setup.exe"),
        );
        assert!(nsi.contains(r#"File /r "C:\work\payload\*.*""#));
        assert!(nsi.contains("RequestExecutionLevel user"));
        assert!(
            nsi.contains(
                r#"CreateShortcut "$SMPROGRAMS\Day Showcase.lnk" "$INSTDIR\showcase.exe""#
            )
        );
        assert!(nsi.contains(r#"Uninstall\dev.daybrite.showcase""#));
    }
}