//! `cargo xtask package` (plan P3): installable packages for the host system, //! without code signing. macOS: `RustFig.app` + `.dmg`; Linux: tarball with //! install script, `.deb` (needs `dpkg-deb`), desktop file and MIME type; //! Windows: zip with the exe and a `.reg` file for the `.fig` association. //! Every file is generated here from text; nothing binary is committed. use std::fs; use std::path::{Path, PathBuf}; use std::process::Command; pub const BUNDLE_ID: &str = "io.github.fabianhassler.rustfig"; pub const UTI: &str = "org.xfig.fig"; /// The workspace version from the root `Cargo.toml` (`[workspace.package]`). pub fn workspace_version(manifest: &str) -> String { manifest .lines() .find_map(|l| { let l = l.trim(); l.strip_prefix("version") .and_then(|r| r.trim_start().strip_prefix('=')) .map(|v| v.trim().trim_matches('"').to_string()) }) .unwrap_or_else(|| "0.0.0".into()) } /// macOS `Info.plist`: bundle id, version, icon and the `.fig` document type. pub fn info_plist(version: &str, with_icon: bool) -> String { let icon = if with_icon { "\tCFBundleIconFile\n\tRustFig\n" } else { "" }; format!( r#" CFBundleName RustFig CFBundleDisplayName RustFig CFBundleIdentifier {BUNDLE_ID} CFBundleExecutable rustfig CFBundlePackageType APPL CFBundleVersion {version} CFBundleShortVersionString {version} {icon} LSMinimumSystemVersion 11.0 NSHighResolutionCapable CFBundleDocumentTypes CFBundleTypeName xfig drawing CFBundleTypeRole Editor LSHandlerRank Default LSItemContentTypes {UTI} UTExportedTypeDeclarations UTTypeIdentifier {UTI} UTTypeDescription xfig drawing UTTypeConformsTo public.data UTTypeTagSpecification public.filename-extension fig public.mime-type image/x-xfig "# ) } /// Freedesktop `.desktop` entry. pub fn desktop_file() -> String { "[Desktop Entry]\nType=Application\nName=RustFig\nGenericName=Vector drawing\n\ Comment=Native reimplementation of xfig\nExec=rustfig %f\nIcon=rustfig\n\ Terminal=false\nCategories=Graphics;2DGraphics;VectorGraphics;\n\ MimeType=image/x-xfig;\nStartupWMClass=rustfig\n" .into() } /// shared-mime-info type for `*.fig` (xfig's own magic line `#FIG`). pub fn mime_xml() -> String { r##" xfig drawing "## .into() } /// Windows registry file associating `.fig` with `rustfig.exe` (per user). /// `exe` is the install path; backslashes are doubled for the `.reg` syntax. pub fn reg_file(exe: &str) -> String { let e = exe.replace('\\', "\\\\"); format!( "Windows Registry Editor Version 5.00\r\n\r\n\ [HKEY_CURRENT_USER\\Software\\Classes\\.fig]\r\n@=\"RustFig.Drawing\"\r\n\r\n\ [HKEY_CURRENT_USER\\Software\\Classes\\RustFig.Drawing]\r\n@=\"xfig drawing\"\r\n\r\n\ [HKEY_CURRENT_USER\\Software\\Classes\\RustFig.Drawing\\DefaultIcon]\r\n@=\"{e},0\"\r\n\r\n\ [HKEY_CURRENT_USER\\Software\\Classes\\RustFig.Drawing\\shell\\open\\command]\r\n@=\"\\\"{e}\\\" \\\"%1\\\"\"\r\n" ) } /// Debian `control` file. pub fn deb_control(version: &str, arch: &str, size_kb: u64) -> String { format!( "Package: rustfig\nVersion: {version}\nSection: graphics\nPriority: optional\n\ Architecture: {arch}\nInstalled-Size: {size_kb}\n\ Maintainer: Fabian Hassler \n\ Depends: libc6, libgtk-3-0 | zenity\n\ Description: Rust-native reimplementation of xfig\n \ RustFig opens and saves Fig files and exports with the ported fig2dev drivers.\n" ) } fn install_script() -> String { "#!/bin/sh\n# Installs RustFig for the current user (or system-wide with PREFIX=/usr/local, as root).\n\ set -e\nPREFIX=${PREFIX:-$HOME/.local}\nd=$(dirname \"$0\")\n\ install -Dm755 \"$d/rustfig\" \"$PREFIX/bin/rustfig\"\n\ # the figure libraries, where the program looks for them\n\ rm -rf \"$PREFIX/share/rustfig/libraries\"\n\ mkdir -p \"$PREFIX/share/rustfig\"\n\ cp -R \"$d/libraries\" \"$PREFIX/share/rustfig/libraries\"\n\ # fig2dev under its own name only where no fig2dev is installed\n\ command -v fig2dev >/dev/null 2>&1 || ln -sf rustfig \"$PREFIX/bin/fig2dev\"\n\ # and xfig's name (S3: `xfig file.fig` starts RustFig) where no xfig is\n\ command -v xfig >/dev/null 2>&1 || ln -sf rustfig \"$PREFIX/bin/xfig\"\n\ install -Dm644 \"$d/rustfig.desktop\" \"$PREFIX/share/applications/rustfig.desktop\"\n\ install -Dm644 \"$d/rustfig.xml\" \"$PREFIX/share/mime/packages/rustfig.xml\"\n\ [ -f \"$d/rustfig.png\" ] && install -Dm644 \"$d/rustfig.png\" \"$PREFIX/share/icons/hicolor/256x256/apps/rustfig.png\"\n\ update-mime-database \"$PREFIX/share/mime\" 2>/dev/null || true\n\ update-desktop-database \"$PREFIX/share/applications\" 2>/dev/null || true\n\ echo \"RustFig installed in $PREFIX/bin\"\n" .into() } // ---- PNG without a compression library (stored deflate blocks) ---- fn crc32(data: &[u8]) -> u32 { let mut c = 0xFFFF_FFFFu32; for &b in data { c ^= u32::from(b); for _ in 0..8 { c = if c & 1 != 0 { 0xEDB8_8320 ^ (c >> 1) } else { c >> 1 }; } } !c } fn adler32(data: &[u8]) -> u32 { let (mut a, mut b) = (1u32, 0u32); for &x in data { a = (a + u32::from(x)) % 65521; b = (b + a) % 65521; } (b << 16) | a } /// RGBA pixels (`size` square) as an uncompressed PNG. pub fn png_from_rgba(rgba: &[u8], size: u32) -> Option> { let row = size as usize * 4; if size == 0 || rgba.len() != row * size as usize { return None; } let mut raw = Vec::with_capacity(rgba.len() + size as usize); for r in rgba.chunks(row) { raw.push(0); raw.extend_from_slice(r); } let mut z = vec![0x78, 0x01]; let blocks: Vec<&[u8]> = raw.chunks(65535).collect(); for (i, b) in blocks.iter().enumerate() { z.push(u8::from(i + 1 == blocks.len())); let n = b.len() as u16; z.extend_from_slice(&n.to_le_bytes()); z.extend_from_slice(&(!n).to_le_bytes()); z.extend_from_slice(b); } z.extend_from_slice(&adler32(&raw).to_be_bytes()); let mut out = vec![0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A]; let mut chunk = |kind: &[u8; 4], data: &[u8]| { out.extend_from_slice(&(data.len() as u32).to_be_bytes()); let mut body = kind.to_vec(); body.extend_from_slice(data); out.extend_from_slice(&body); out.extend_from_slice(&crc32(&body).to_be_bytes()); }; let mut ihdr = Vec::new(); ihdr.extend_from_slice(&size.to_be_bytes()); ihdr.extend_from_slice(&size.to_be_bytes()); ihdr.extend_from_slice(&[8, 6, 0, 0, 0]); chunk(b"IHDR", &ihdr); chunk(b"IDAT", &z); chunk(b"IEND", &[]); Some(out) } // ---- driver ---- pub(crate) fn sh(cmd: &mut Command) -> Result<(), String> { eprintln!("$ {cmd:?}"); let s = cmd.status().map_err(|e| format!("{cmd:?}: {e}"))?; if s.success() { Ok(()) } else { Err(format!("command failed: {cmd:?}")) } } fn write(path: &Path, text: &str) -> Result<(), String> { if let Some(p) = path.parent() { fs::create_dir_all(p).map_err(|e| format!("{}: {e}", p.display()))?; } fs::write(path, text).map_err(|e| format!("{}: {e}", path.display())) } /// The app icon's PNG at `size`, asked of the built `rustfig` (`--dump-icon`). fn icon_png(exe: &Path, size: u32) -> Option> { let out = Command::new(exe) .arg("--dump-icon") .arg(size.to_string()) .output() .ok()?; if !out.status.success() { return None; } png_from_rgba(&out.stdout, size) } fn copy_docs(root: &Path, dir: &Path) -> Result<(), String> { for f in [ "README.md", "LICENSE-MIT", "LICENSE-APACHE", "LICENSE-xfig", "NOTICE", "ASSETS.md", ] { fs::copy(root.join(f), dir.join(f)).map_err(|e| format!("{f}: {e}"))?; } Ok(()) } /// The figure libraries (`libraries/`) into `dest`, where the program /// finds them (`rustfig_engine::library::beside_program`). Returns the /// number of files. pub fn copy_libraries(root: &Path, dest: &Path) -> Result { fn walk(from: &Path, to: &Path, n: &mut usize) -> Result<(), String> { fs::create_dir_all(to).map_err(|e| format!("{}: {e}", to.display()))?; let mut entries: Vec<_> = fs::read_dir(from) .map_err(|e| format!("{}: {e}", from.display()))? .filter_map(Result::ok) .collect(); entries.sort_by_key(|e| e.file_name()); for e in entries { let (src, dst) = (e.path(), to.join(e.file_name())); if src.is_dir() { walk(&src, &dst, n)?; } else { fs::copy(&src, &dst).map_err(|e| format!("{}: {e}", src.display()))?; *n += 1; } } Ok(()) } let mut n = 0; walk(&root.join("libraries"), dest, &mut n)?; Ok(n) } pub fn run(root: &Path, mut cargo: Command) -> Result<(), String> { let version = workspace_version(&fs::read_to_string(root.join("Cargo.toml")).unwrap_or_default()); sh(cargo .current_dir(root) .args(["build", "--release", "-p", "rustfig", "-p", "rustfig-cli"]))?; let target = std::env::var_os("CARGO_TARGET_DIR").map_or_else(|| root.join("target"), PathBuf::from); let rel = target.join("release"); let out = target.join("package"); let _ = fs::remove_dir_all(&out); fs::create_dir_all(&out).map_err(|e| e.to_string())?; if cfg!(target_os = "macos") { macos(root, &rel, &out, &version) } else if cfg!(target_os = "windows") { windows(root, &rel, &out, &version) } else { linux(root, &rel, &out, &version) }?; eprintln!("packages in {}", out.display()); Ok(()) } fn macos(root: &Path, rel: &Path, out: &Path, version: &str) -> Result<(), String> { let exe = rel.join("rustfig"); let app = out.join("RustFig.app"); let (macos_dir, res) = (app.join("Contents/MacOS"), app.join("Contents/Resources")); fs::create_dir_all(&macos_dir).map_err(|e| e.to_string())?; fs::create_dir_all(&res).map_err(|e| e.to_string())?; copy_libraries(root, &res.join("libraries"))?; fs::copy(&exe, macos_dir.join("rustfig")).map_err(|e| format!("rustfig: {e}"))?; #[cfg(unix)] std::os::unix::fs::symlink("rustfig", macos_dir.join("fig2dev")).map_err(|e| e.to_string())?; // Icon: iconset of PNGs from the app's own drawing, then iconutil. let set = out.join("RustFig.iconset"); let mut icon = false; if Command::new("iconutil").arg("--help").output().is_ok() { fs::create_dir_all(&set).map_err(|e| e.to_string())?; icon = true; for (name, px) in [ ("icon_16x16", 16), ("icon_16x16@2x", 32), ("icon_32x32", 32), ("icon_32x32@2x", 64), ("icon_128x128", 128), ("icon_128x128@2x", 256), ("icon_256x256", 256), ("icon_256x256@2x", 512), ("icon_512x512", 512), ("icon_512x512@2x", 1024), ] { match icon_png(&exe, px) { Some(png) => fs::write(set.join(format!("{name}.png")), png).map_err(|e| e.to_string())?, None => { icon = false; break; } } } if icon { icon = sh(Command::new("iconutil") .args(["-c", "icns", "-o"]) .arg(res.join("RustFig.icns")) .arg(&set)) .is_ok(); } if icon { let _ = fs::remove_dir_all(&set); } } if !icon { eprintln!("warning: no app icon (iconutil missing or the icon could not be rendered)"); } write(&app.join("Contents/Info.plist"), &info_plist(version, icon))?; // Sign the whole bundle ad hoc (no certificate): the linker signed only // the binary, and a bundle whose signature misses its resources is // reported as "damaged" by Gatekeeper after a download. sh(Command::new("codesign") .args(["--force", "--deep", "--sign", "-"]) .arg(&app))?; let dmg = out.join(format!("RustFig-{version}-macos.dmg")); let stage = out.join("dmg"); fs::create_dir_all(&stage).map_err(|e| e.to_string())?; sh(Command::new("cp").arg("-R").arg(&app).arg(&stage))?; #[cfg(unix)] std::os::unix::fs::symlink("/Applications", stage.join("Applications")).map_err(|e| e.to_string())?; copy_docs(root, &stage)?; sh(Command::new("hdiutil") .args([ "create", "-volname", "RustFig", "-fs", "HFS+", "-format", "UDZO", "-ov", "-srcfolder", ]) .arg(&stage) .arg(&dmg))?; let _ = fs::remove_dir_all(&stage); Ok(()) } fn linux(root: &Path, rel: &Path, out: &Path, version: &str) -> Result<(), String> { let exe = rel.join("rustfig"); // The architecture in the name: x86-64 and ARM64 builds go into one release. let name = format!( "rustfig-{version}-linux-{}", if cfg!(target_arch = "aarch64") { "arm64" } else { "x86_64" } ); let dir = out.join(&name); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::copy(&exe, dir.join("rustfig")).map_err(|e| format!("rustfig: {e}"))?; write(&dir.join("rustfig.desktop"), &desktop_file())?; write(&dir.join("rustfig.xml"), &mime_xml())?; write(&dir.join("install.sh"), &install_script())?; copy_libraries(root, &dir.join("libraries"))?; let png = icon_png(&exe, 256); match &png { Some(p) => fs::write(dir.join("rustfig.png"), p).map_err(|e| e.to_string())?, None => eprintln!("warning: no app icon"), } copy_docs(root, &dir)?; #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; for f in ["rustfig", "install.sh"] { let p = dir.join(f); let _ = fs::set_permissions(&p, fs::Permissions::from_mode(0o755)); } } sh(Command::new("tar") .arg("czf") .arg(out.join(format!("{name}.tar.gz"))) .arg("-C") .arg(out) .arg(&name))?; // .deb if Command::new("dpkg-deb").arg("--version").output().is_err() { eprintln!("warning: dpkg-deb not found, no .deb"); return Ok(()); } let pkg = out.join("deb"); let size_kb = fs::metadata(&exe).map(|m| m.len() / 1024).unwrap_or(0); let arch = if cfg!(target_arch = "aarch64") { "arm64" } else { "amd64" }; write(&pkg.join("DEBIAN/control"), &deb_control(version, arch, size_kb))?; let bin = pkg.join("usr/bin"); fs::create_dir_all(&bin).map_err(|e| e.to_string())?; fs::copy(&exe, bin.join("rustfig")).map_err(|e| e.to_string())?; // No /usr/bin/fig2dev: Debian's fig2dev package owns that name (and // xfig depends on it); `rustfig fig2dev …` runs the built-in one. write(&pkg.join("usr/share/applications/rustfig.desktop"), &desktop_file())?; write(&pkg.join("usr/share/mime/packages/rustfig.xml"), &mime_xml())?; if let Some(p) = png { let d = pkg.join("usr/share/icons/hicolor/256x256/apps"); fs::create_dir_all(&d).map_err(|e| e.to_string())?; fs::write(d.join("rustfig.png"), p).map_err(|e| e.to_string())?; } copy_libraries(root, &pkg.join("usr/share/rustfig/libraries"))?; let doc = pkg.join("usr/share/doc/rustfig"); fs::create_dir_all(&doc).map_err(|e| e.to_string())?; copy_docs(root, &doc)?; sh(Command::new("dpkg-deb") .args(["--root-owner-group", "--build"]) .arg(&pkg) .arg(out.join(format!("rustfig_{version}_{arch}.deb"))))?; let _ = fs::remove_dir_all(&pkg); Ok(()) } fn windows(root: &Path, rel: &Path, out: &Path, version: &str) -> Result<(), String> { let name = format!("rustfig-{version}-windows"); let dir = out.join(&name); fs::create_dir_all(&dir).map_err(|e| e.to_string())?; fs::copy(rel.join("rustfig.exe"), dir.join("rustfig.exe")).map_err(|e| format!("rustfig.exe: {e}"))?; fs::copy(rel.join("rustfig-cli.exe"), dir.join("rustfig-cli.exe")).map_err(|e| format!("rustfig-cli.exe: {e}"))?; // The .reg points at C:\Program Files\RustFig; edit the path if installed elsewhere. write( &dir.join("fig-association.reg"), ®_file(r"C:\Program Files\RustFig\rustfig.exe"), )?; write( &dir.join("INSTALL.txt"), "Copy this folder to C:\\Program Files\\RustFig (or anywhere), then double-click\r\n\ fig-association.reg to open .fig files with RustFig (edit the path inside first\r\n\ if you chose another folder). The program is not code-signed: Windows SmartScreen\r\n\ may ask for confirmation on the first start.\r\n", )?; copy_docs(root, &dir)?; copy_libraries(root, &dir.join("libraries"))?; let zip = out.join(format!("{name}.zip")); // tar (bsdtar) ships with Windows 10+ and writes zip with -a. sh(Command::new("tar") .arg("-a") .arg("-c") .arg("-f") .arg(&zip) .arg("-C") .arg(out) .arg(&name)) } #[cfg(test)] mod tests { /// The Scoop and winget manifests in `packaging/` name the workspace /// version (bump them with the release; their hashes are the release /// zip's SHA-256). #[test] fn windows_package_manifests_follow_the_version() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let v = super::workspace_version(&std::fs::read_to_string(root.join("Cargo.toml")).unwrap()); let scoop = std::fs::read_to_string(root.join("packaging/scoop/rustfig.json")).unwrap(); assert!( scoop.contains(&format!("\"version\": \"{v}\"")), "packaging/scoop/rustfig.json is not {v}" ); assert!(scoop.contains(&format!("/v{v}/rustfig-{v}-windows.zip"))); let dir = root.join(format!("packaging/winget/FabianHassler.RustFig/{v}")); for f in ["", ".installer", ".locale.en-US"] { let text = std::fs::read_to_string(dir.join(format!("FabianHassler.RustFig{f}.yaml"))) .unwrap_or_else(|e| panic!("winget manifests for {v}: {e}")); assert!(text.contains(&format!("PackageVersion: {v}\n")), "{f}"); } } use super::*; /// Every package gets the whole `libraries/` folder. #[test] fn libraries_are_copied_whole() { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join(".."); let dest = std::env::temp_dir().join(format!("rustfig-pkglibs-{}", std::process::id())); let n = copy_libraries(&root, &dest).unwrap(); fn count(p: &std::path::Path) -> usize { std::fs::read_dir(p) .unwrap() .filter_map(Result::ok) .map(|e| if e.path().is_dir() { count(&e.path()) } else { 1 }) .sum() } assert_eq!(n, count(&root.join("libraries"))); assert!(dest.join("Feynman/fermion.fig").is_file()); let _ = std::fs::remove_dir_all(&dest); assert!(install_script().contains("share/rustfig/libraries")); } #[test] fn plist_has_document_type_and_id() { let p = info_plist("1.2.3", true); assert!(p.contains(BUNDLE_ID) && p.contains("CFBundleDocumentTypes")); assert!(p.contains("fig") && p.contains("Editor")); assert!(p.contains("1.2.3") && p.contains("CFBundleIconFile")); assert!(!info_plist("1", false).contains("CFBundleIconFile")); } #[test] fn linux_files() { assert!(desktop_file().contains("MimeType=image/x-xfig;")); assert!(mime_xml().contains("*.fig")); let c = deb_control("0.1.0", "amd64", 10); assert!(c.starts_with("Package: rustfig\nVersion: 0.1.0\n") && c.ends_with('\n')); } #[test] fn reg_escapes_paths() { let r = reg_file(r"C:\x\rustfig.exe"); assert!(r.contains(r#"@="\"C:\\x\\rustfig.exe\" \"%1\"""#)); } #[test] fn version_and_png() { assert_eq!(workspace_version("[workspace.package]\nversion = \"0.4.2\"\n"), "0.4.2"); let png = png_from_rgba(&[255; 2 * 2 * 4], 2).unwrap(); assert_eq!(&png[1..4], b"PNG"); assert!(png_from_rgba(&[0; 3], 2).is_none()); assert_eq!(crc32(b"IEND"), 0xAE42_6082); } } .