Skip to main content

xtask_lib/tasks/
dist.rs

1//! `cargo xtask dist` — assemble the Kobo distribution directory.
2//!
3//! Copies the compiled Cadmus binary, shared libraries, scripts, fonts,
4//! icons, and other assets into a `dist/` directory that mirrors the layout
5//! expected on the Kobo device.
6//!
7//! ## Prerequisites
8//!
9//! - `cargo xtask build-kobo` must have been run first.
10//! - `libs/` must contain the ARM shared libraries.
11//! - `bin/`, `resources/`, and `hyphenation-patterns/` must exist.
12//!
13//! ## Output layout
14//!
15//! ```text
16//! dist/
17//! ├── cadmus                  (ARM binary)
18//! ├── libs/                   (versioned .so files)
19//! ├── fonts/
20//! ├── icons/
21//! ├── css/
22//! ├── scripts/
23//! ├── keyboard-layouts/
24//! ├── hyphenation-patterns/
25//! ├── bin/
26//! ├── resources/
27//! ├── Settings-sample.toml
28//! ├── LICENSE
29//! └── *.sh                    (contrib scripts)
30//! ```
31
32use std::path::Path;
33
34use anyhow::{Context, Result, bail};
35use clap::Args;
36use wildmatch::WildMatch;
37
38use super::util::{cmd, fs, workspace};
39use build_deps::build::kobo;
40use build_deps::versions;
41
42/// Arguments for `cargo xtask dist`.
43#[derive(Debug, Args)]
44pub struct DistArgs {
45    /// Build for the test feature set (`--features test`).
46    #[arg(long)]
47    pub test: bool,
48}
49
50/// Assembles the Kobo distribution directory.
51///
52/// # Errors
53///
54/// Returns an error if the ARM binary or any required asset is missing.
55pub fn run(args: DistArgs) -> Result<()> {
56    let root = workspace::root()?;
57
58    let binary = root.join("target/arm-unknown-linux-gnueabihf/release/cadmus");
59    if !binary.exists() {
60        bail!(
61            "ARM binary not found at {}.\n\
62             Run `cargo xtask build-kobo` first.",
63            binary.display()
64        );
65    }
66
67    let dist_dir = root.join("dist");
68    if dist_dir.exists() {
69        std::fs::remove_dir_all(&dist_dir).context("failed to remove existing dist/")?;
70    }
71    std::fs::create_dir_all(&dist_dir)?;
72    std::fs::create_dir_all(dist_dir.join("libs"))?;
73    std::fs::create_dir_all(dist_dir.join("dictionaries"))?;
74
75    copy_libraries(&root, &dist_dir)?;
76    copy_assets(&root, &dist_dir)?;
77    copy_binary(&root, &dist_dir)?;
78    strip_and_patch(&root, &dist_dir)?;
79    clean_user_files(&dist_dir)?;
80
81    if args.test {
82        println!("Test build assembled in dist/");
83    } else {
84        println!("Distribution assembled in dist/");
85    }
86
87    Ok(())
88}
89
90/// Copies ARM shared libraries from `libs/` into `dist/libs/` with versioned
91/// names expected by the Kobo runtime linker.
92fn copy_libraries(root: &Path, dist_dir: &Path) -> Result<()> {
93    let libs_dir = root.join("libs");
94    let dist_libs = dist_dir.join("libs");
95
96    for &lib in versions::SONAMES {
97        let soname = kobo::soname(&libs_dir, lib)?;
98        let src = libs_dir.join(lib);
99        let dest = dist_libs.join(&soname);
100        std::fs::copy(&src, &dest).with_context(|| {
101            format!(
102                "failed to copy {} → {}\n\
103                 Run `cargo xtask build-kobo` to build the libraries.",
104                src.display(),
105                dest.display()
106            )
107        })?;
108    }
109
110    Ok(())
111}
112
113/// Copies static assets (fonts, icons, scripts, etc.) into `dist/`.
114fn copy_assets(root: &Path, dist_dir: &Path) -> Result<()> {
115    let dirs = [
116        "hyphenation-patterns",
117        "keyboard-layouts",
118        "bin",
119        "scripts",
120        "icons",
121        "resources",
122        "fonts",
123        "css",
124    ];
125
126    for dir in dirs {
127        let src = root.join(dir);
128        if !src.exists() {
129            bail!(
130                "Required asset directory '{}' not found.\n\
131                 Run `cargo xtask download-assets` to download it.",
132                src.display()
133            );
134        }
135        fs::copy_dir_all(&src, &dist_dir.join(dir))?;
136    }
137
138    // Contrib scripts and sample config
139    for entry in std::fs::read_dir(root.join("contrib"))? {
140        let entry = entry?;
141        let path = entry.path();
142        if path.extension().is_some_and(|e| e == "sh") {
143            std::fs::copy(&path, dist_dir.join(entry.file_name()))?;
144        }
145    }
146
147    std::fs::copy(
148        root.join("contrib/Settings-sample.toml"),
149        dist_dir.join("Settings-sample.toml"),
150    )?;
151
152    std::fs::copy(root.join("LICENSE"), dist_dir.join("LICENSE"))?;
153
154    Ok(())
155}
156
157/// Copies the compiled ARM binary into `dist/`.
158fn copy_binary(root: &Path, dist_dir: &Path) -> Result<()> {
159    std::fs::copy(
160        root.join("target/arm-unknown-linux-gnueabihf/release/cadmus"),
161        dist_dir.join("cadmus"),
162    )
163    .context("failed to copy cadmus binary")?;
164    Ok(())
165}
166
167/// Strips debug symbols and removes RPATH from the binary and all libraries.
168///
169/// RPATH is removed so the libraries resolve against the device's default
170/// linker search paths rather than the build host's paths.
171fn strip_and_patch(root: &Path, dist_dir: &Path) -> Result<()> {
172    let libs_dir = dist_dir.join("libs");
173    for entry in std::fs::read_dir(&libs_dir)? {
174        let path = entry?.path();
175        cmd::run(
176            "patchelf",
177            &["--remove-rpath", &path.to_string_lossy()],
178            root,
179            &[],
180        )?;
181    }
182
183    // Strip the binary and all libraries to reduce size.
184    let binary = dist_dir.join("cadmus");
185    let mut strip_targets = vec![binary.to_string_lossy().into_owned()];
186    for entry in std::fs::read_dir(&libs_dir)? {
187        strip_targets.push(entry?.path().to_string_lossy().into_owned());
188    }
189
190    let strip_refs: Vec<&str> = strip_targets.iter().map(String::as_str).collect();
191    cmd::run("arm-linux-gnueabihf-strip", &strip_refs, root, &[])
192}
193
194/// Removes user-specific files that should not be distributed.
195fn clean_user_files(dist_dir: &Path) -> Result<()> {
196    let patterns: &[(&str, &str)] = &[
197        ("css", "*-user.css"),
198        ("keyboard-layouts", "*-user.json"),
199        ("hyphenation-patterns", "*.bounds"),
200        ("scripts", "wifi-*-*.sh"),
201    ];
202
203    for (subdir, pattern) in patterns {
204        let dir = dist_dir.join(subdir);
205        if dir.exists() {
206            remove_matching(&dir, pattern)?;
207        }
208    }
209
210    Ok(())
211}
212
213/// Removes files in `dir` whose names match the glob `pattern`.
214fn remove_matching(dir: &Path, pattern: &str) -> Result<()> {
215    let matcher = WildMatch::new(pattern);
216
217    for entry in std::fs::read_dir(dir)? {
218        let path = entry?.path();
219        if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
220            if matcher.matches(name) {
221                std::fs::remove_file(&path)
222                    .with_context(|| format!("failed to remove {}", path.display()))?;
223            }
224        }
225    }
226    Ok(())
227}
228
229#[cfg(test)]
230mod tests {
231    use super::*;
232    use std::fs;
233
234    #[test]
235    fn remove_matching_deletes_only_matching_entries() {
236        let tmp = tempfile::tempdir().unwrap();
237        let dir = tmp.path();
238
239        fs::write(dir.join("default-user.css"), b"x").unwrap();
240        fs::write(dir.join("default.css"), b"x").unwrap();
241        fs::write(dir.join("wifi-enable-eth0.sh"), b"x").unwrap();
242        fs::write(dir.join("wifi-enable.sh"), b"x").unwrap();
243
244        remove_matching(dir, "*-user.css").unwrap();
245        remove_matching(dir, "wifi-*-*.sh").unwrap();
246
247        assert!(!dir.join("default-user.css").exists());
248        assert!(dir.join("default.css").exists());
249        assert!(!dir.join("wifi-enable-eth0.sh").exists());
250        assert!(dir.join("wifi-enable.sh").exists());
251    }
252}