tauri_bundler/bundle/linux/
debian.rs

1// Copyright 2016-2019 Cargo-Bundle developers <https://github.com/burtonageo/cargo-bundle>
2// Copyright 2019-2024 Tauri Programme within The Commons Conservancy
3// SPDX-License-Identifier: Apache-2.0
4// SPDX-License-Identifier: MIT
5
6// The structure of a Debian package looks something like this:
7//
8// foobar_1.2.3_i386.deb   # Actually an ar archive
9//     debian-binary           # Specifies deb format version (2.0 in our case)
10//     control.tar.gz          # Contains files controlling the installation:
11//         control                  # Basic package metadata
12//         md5sums                  # Checksums for files in data.tar.gz below
13//         postinst                 # Post-installation script (optional)
14//         prerm                    # Pre-uninstallation script (optional)
15//     data.tar.gz             # Contains files to be installed:
16//         usr/bin/foobar                            # Binary executable file
17//         usr/share/applications/foobar.desktop     # Desktop file (for apps)
18//         usr/share/icons/hicolor/...               # Icon files (for apps)
19//         usr/lib/foobar/...                        # Other resource files
20//
21// For cargo-bundle, we put bundle resource files under /usr/lib/package_name/,
22// and then generate the desktop file and control file from the bundle
23// metadata, as well as generating the md5sums file.  Currently we do not
24// generate postinst or prerm files.
25
26use super::freedesktop;
27use crate::{
28  bundle::settings::Arch,
29  error::{Context, ErrorExt},
30  utils::fs_utils,
31  Settings,
32};
33use flate2::{write::GzEncoder, Compression};
34use tar::HeaderMode;
35use walkdir::WalkDir;
36
37use std::{
38  fs::{self, File, OpenOptions},
39  io::{self, Write},
40  os::unix::fs::{MetadataExt, OpenOptionsExt},
41  path::{Path, PathBuf},
42};
43
44/// Bundles the project.
45/// Returns a vector of PathBuf that shows where the DEB was created.
46pub fn bundle_project(settings: &Settings) -> crate::Result<Vec<PathBuf>> {
47  let arch = match settings.binary_arch() {
48    Arch::X86_64 => "amd64",
49    Arch::X86 => "i386",
50    Arch::AArch64 => "arm64",
51    Arch::Armhf => "armhf",
52    Arch::Armel => "armel",
53    Arch::Riscv64 => "riscv64",
54    target => {
55      return Err(crate::Error::ArchError(format!(
56        "Unsupported architecture: {target:?}"
57      )));
58    }
59  };
60  let package_base_name = format!(
61    "{}_{}_{}",
62    settings.product_name(),
63    settings.version_string(),
64    arch
65  );
66  let package_name = format!("{package_base_name}.deb");
67
68  let base_dir = settings.project_out_directory().join("bundle/deb");
69  let package_dir = base_dir.join(&package_base_name);
70  if package_dir.exists() {
71    fs::remove_dir_all(&package_dir).fs_context(
72      "Failed to Remove old package directory",
73      package_dir.clone(),
74    )?;
75  }
76  let package_path = base_dir.join(&package_name);
77
78  log::info!(action = "Bundling"; "{} ({})", package_name, package_path.display());
79
80  let (data_dir, _) =
81    generate_data(settings, &package_dir).context("Failed to build data folders and files")?;
82  fs_utils::copy_custom_files(&settings.deb().files, &data_dir)
83    .context("Failed to copy custom files")?;
84
85  // Generate control files.
86  let control_dir = package_dir.join("control");
87  generate_control_file(settings, arch, &control_dir, &data_dir)
88    .context("Failed to create control file")?;
89  generate_scripts(settings, &control_dir).context("Failed to create control scripts")?;
90  generate_md5sums(&control_dir, &data_dir).context("Failed to create md5sums file")?;
91
92  // Generate `debian-binary` file; see
93  // http://www.tldp.org/HOWTO/Debian-Binary-Package-Building-HOWTO/x60.html#AEN66
94  let debian_binary_path = package_dir.join("debian-binary");
95  create_file_with_data(&debian_binary_path, "2.0\n")
96    .context("Failed to create debian-binary file")?;
97
98  // Apply tar/gzip/ar to create the final package file.
99  let control_tar_gz_path =
100    tar_and_gzip_dir(control_dir).with_context(|| "Failed to tar/gzip control directory")?;
101  let data_tar_gz_path =
102    tar_and_gzip_dir(data_dir).with_context(|| "Failed to tar/gzip data directory")?;
103  create_archive(
104    vec![debian_binary_path, control_tar_gz_path, data_tar_gz_path],
105    &package_path,
106  )
107  .with_context(|| "Failed to create package archive")?;
108  Ok(vec![package_path])
109}
110
111/// Generate the debian data folders and files.
112pub fn generate_data(
113  settings: &Settings,
114  package_dir: &Path,
115) -> crate::Result<(PathBuf, Vec<freedesktop::Icon>)> {
116  // Generate data files.
117  let data_dir = package_dir.join("data");
118  let bin_dir = data_dir.join("usr/bin");
119
120  for bin in settings.binaries() {
121    let bin_path = settings.binary_path(bin);
122    fs_utils::copy_file(&bin_path, &bin_dir.join(bin.name()))
123      .with_context(|| format!("Failed to copy binary from {bin_path:?}"))?;
124  }
125
126  copy_resource_files(settings, &data_dir).with_context(|| "Failed to copy resource files")?;
127
128  settings
129    .copy_binaries(&bin_dir)
130    .with_context(|| "Failed to copy external binaries")?;
131
132  let icons = freedesktop::copy_icon_files(settings, &data_dir)
133    .with_context(|| "Failed to create icon files")?;
134  freedesktop::generate_desktop_file(settings, &settings.deb().desktop_template, &data_dir)
135    .with_context(|| "Failed to create desktop file")?;
136  generate_changelog_file(settings, &data_dir)
137    .with_context(|| "Failed to create changelog.gz file")?;
138
139  Ok((data_dir, icons))
140}
141
142/// Generate the Changelog file by compressing, to be stored at /usr/share/doc/package-name/changelog.gz. See
143/// <https://www.debian.org/doc/debian-policy/ch-docs.html#changelog-files-and-release-notes>
144fn generate_changelog_file(settings: &Settings, data_dir: &Path) -> crate::Result<()> {
145  if let Some(changelog_src_path) = &settings.deb().changelog {
146    let mut src_file = File::open(changelog_src_path)?;
147    let product_name = settings.product_name();
148    let dest_path = data_dir.join(format!("usr/share/doc/{product_name}/changelog.gz"));
149
150    let changelog_file = fs_utils::create_file(&dest_path)?;
151    let mut gzip_encoder = GzEncoder::new(changelog_file, Compression::new(9));
152    io::copy(&mut src_file, &mut gzip_encoder)?;
153
154    let mut changelog_file = gzip_encoder.finish()?;
155    changelog_file.flush()?;
156  }
157  Ok(())
158}
159
160/// Generates the debian control file and stores it under the `control_dir`.
161fn generate_control_file(
162  settings: &Settings,
163  arch: &str,
164  control_dir: &Path,
165  data_dir: &Path,
166) -> crate::Result<()> {
167  // For more information about the format of this file, see
168  // https://www.debian.org/doc/debian-policy/ch-controlfields.html
169  let dest_path = control_dir.join("control");
170  let mut file = fs_utils::create_file(&dest_path)?;
171  let package = heck::AsKebabCase(settings.product_name());
172  writeln!(file, "Package: {package}")?;
173  writeln!(file, "Version: {}", settings.version_string())?;
174  writeln!(file, "Architecture: {arch}")?;
175  // Installed-Size must be divided by 1024, see https://www.debian.org/doc/debian-policy/ch-controlfields.html#installed-size
176  writeln!(file, "Installed-Size: {}", total_dir_size(data_dir)? / 1024)?;
177  let authors = settings
178    .authors_comma_separated()
179    .or_else(|| settings.publisher().map(ToString::to_string))
180    .unwrap_or_else(|| {
181      settings
182        .bundle_identifier()
183        .split('.')
184        .nth(1)
185        .unwrap_or(settings.bundle_identifier())
186        .to_string()
187    });
188
189  writeln!(file, "Maintainer: {authors}")?;
190  if let Some(section) = &settings.deb().section {
191    writeln!(file, "Section: {section}")?;
192  }
193  if let Some(priority) = &settings.deb().priority {
194    writeln!(file, "Priority: {priority}")?;
195  } else {
196    writeln!(file, "Priority: optional")?;
197  }
198
199  if let Some(homepage) = settings.homepage_url() {
200    writeln!(file, "Homepage: {homepage}")?;
201  }
202
203  let dependencies = settings.deb().depends.as_ref().cloned().unwrap_or_default();
204  if !dependencies.is_empty() {
205    writeln!(file, "Depends: {}", dependencies.join(", "))?;
206  }
207  let dependencies = settings
208    .deb()
209    .recommends
210    .as_ref()
211    .cloned()
212    .unwrap_or_default();
213  if !dependencies.is_empty() {
214    writeln!(file, "Recommends: {}", dependencies.join(", "))?;
215  }
216  let provides = settings
217    .deb()
218    .provides
219    .as_ref()
220    .cloned()
221    .unwrap_or_default();
222  if !provides.is_empty() {
223    writeln!(file, "Provides: {}", provides.join(", "))?;
224  }
225  let conflicts = settings
226    .deb()
227    .conflicts
228    .as_ref()
229    .cloned()
230    .unwrap_or_default();
231  if !conflicts.is_empty() {
232    writeln!(file, "Conflicts: {}", conflicts.join(", "))?;
233  }
234  let replaces = settings
235    .deb()
236    .replaces
237    .as_ref()
238    .cloned()
239    .unwrap_or_default();
240  if !replaces.is_empty() {
241    writeln!(file, "Replaces: {}", replaces.join(", "))?;
242  }
243  let mut short_description = settings.short_description().trim();
244  if short_description.is_empty() {
245    short_description = "(none)";
246  }
247  let mut long_description = settings.long_description().unwrap_or("").trim();
248  if long_description.is_empty() {
249    long_description = "(none)";
250  }
251  writeln!(file, "Description: {short_description}")?;
252  for line in long_description.lines() {
253    let line = line.trim();
254    if line.is_empty() {
255      writeln!(file, " .")?;
256    } else {
257      writeln!(file, " {line}")?;
258    }
259  }
260  file.flush()?;
261  Ok(())
262}
263
264fn generate_scripts(settings: &Settings, control_dir: &Path) -> crate::Result<()> {
265  if let Some(script_path) = &settings.deb().pre_install_script {
266    let dest_path = control_dir.join("preinst");
267    create_script_file_from_path(script_path, &dest_path)?
268  }
269
270  if let Some(script_path) = &settings.deb().post_install_script {
271    let dest_path = control_dir.join("postinst");
272    create_script_file_from_path(script_path, &dest_path)?
273  }
274
275  if let Some(script_path) = &settings.deb().pre_remove_script {
276    let dest_path = control_dir.join("prerm");
277    create_script_file_from_path(script_path, &dest_path)?
278  }
279
280  if let Some(script_path) = &settings.deb().post_remove_script {
281    let dest_path = control_dir.join("postrm");
282    create_script_file_from_path(script_path, &dest_path)?
283  }
284  Ok(())
285}
286
287fn create_script_file_from_path(from: &PathBuf, to: &PathBuf) -> crate::Result<()> {
288  let mut from = File::open(from)?;
289  let mut file = OpenOptions::new()
290    .create(true)
291    .truncate(true)
292    .write(true)
293    .mode(0o755)
294    .open(to)?;
295  std::io::copy(&mut from, &mut file)?;
296  Ok(())
297}
298
299/// Create an `md5sums` file in the `control_dir` containing the MD5 checksums
300/// for each file within the `data_dir`.
301fn generate_md5sums(control_dir: &Path, data_dir: &Path) -> crate::Result<()> {
302  let md5sums_path = control_dir.join("md5sums");
303  let mut md5sums_file = fs_utils::create_file(&md5sums_path)?;
304  for entry in WalkDir::new(data_dir) {
305    let entry = entry?;
306    let path = entry.path();
307    if path.is_dir() {
308      continue;
309    }
310    let mut file = File::open(path)?;
311    let mut hash = md5::Context::new();
312    io::copy(&mut file, &mut hash)?;
313    for byte in hash.finalize().iter() {
314      write!(md5sums_file, "{byte:02x}")?;
315    }
316    let rel_path = path.strip_prefix(data_dir)?;
317    let path_str = rel_path.to_str().ok_or_else(|| {
318      let msg = format!("Non-UTF-8 path: {rel_path:?}");
319      io::Error::new(io::ErrorKind::InvalidData, msg)
320    })?;
321    writeln!(md5sums_file, "  {path_str}")?;
322  }
323  Ok(())
324}
325
326/// Copy the bundle's resource files into an appropriate directory under the
327/// `data_dir`.
328fn copy_resource_files(settings: &Settings, data_dir: &Path) -> crate::Result<()> {
329  let resource_dir = data_dir.join("usr/lib").join(settings.product_name());
330  settings.copy_resources(&resource_dir)
331}
332
333/// Create an empty file at the given path, creating any parent directories as
334/// needed, then write `data` into the file.
335fn create_file_with_data<P: AsRef<Path>>(path: P, data: &str) -> crate::Result<()> {
336  let mut file = fs_utils::create_file(path.as_ref())?;
337  file.write_all(data.as_bytes())?;
338  file.flush()?;
339  Ok(())
340}
341
342/// Computes the total size, in bytes, of the given directory and all of its
343/// contents.
344fn total_dir_size(dir: &Path) -> crate::Result<u64> {
345  let mut total: u64 = 0;
346  for entry in WalkDir::new(dir) {
347    total += entry?.metadata()?.len();
348  }
349  Ok(total)
350}
351
352/// Writes a tar file to the given writer containing the given directory.
353fn create_tar_from_dir<P: AsRef<Path>, W: Write>(src_dir: P, dest_file: W) -> crate::Result<W> {
354  let src_dir = src_dir.as_ref();
355  let mut tar_builder = tar::Builder::new(dest_file);
356  for entry in WalkDir::new(src_dir) {
357    let entry = entry?;
358    let src_path = entry.path();
359    if src_path == src_dir {
360      continue;
361    }
362    let dest_path = src_path.strip_prefix(src_dir)?;
363    let stat = fs::metadata(src_path)?;
364    let mut header = tar::Header::new_gnu();
365    header.set_metadata_in_mode(&stat, HeaderMode::Deterministic);
366    header.set_mtime(stat.mtime() as u64);
367
368    if entry.file_type().is_dir() {
369      tar_builder.append_data(&mut header, dest_path, &mut io::empty())?;
370    } else {
371      let mut src_file = fs::File::open(src_path)?;
372      tar_builder.append_data(&mut header, dest_path, &mut src_file)?;
373    }
374  }
375  let dest_file = tar_builder.into_inner()?;
376  Ok(dest_file)
377}
378
379/// Creates a `.tar.gz` file from the given directory (placing the new file
380/// within the given directory's parent directory), then deletes the original
381/// directory and returns the path to the new file.
382fn tar_and_gzip_dir<P: AsRef<Path>>(src_dir: P) -> crate::Result<PathBuf> {
383  let src_dir = src_dir.as_ref();
384  let dest_path = src_dir.with_extension("tar.gz");
385  let dest_file = fs_utils::create_file(&dest_path)?;
386  let gzip_encoder = GzEncoder::new(dest_file, Compression::default());
387  let gzip_encoder = create_tar_from_dir(src_dir, gzip_encoder)?;
388  let mut dest_file = gzip_encoder.finish()?;
389  dest_file.flush()?;
390  Ok(dest_path)
391}
392
393/// Creates an `ar` archive from the given source files and writes it to the
394/// given destination path.
395fn create_archive(srcs: Vec<PathBuf>, dest: &Path) -> crate::Result<()> {
396  let mut builder = ar::Builder::new(fs_utils::create_file(dest)?);
397  for path in &srcs {
398    builder.append_path(path)?;
399  }
400  builder.into_inner()?.flush()?;
401  Ok(())
402}