Skip to main content

winisland_plugin_api/packager/
mod.rs

1pub mod manifest;
2pub mod packaging;
3pub mod signing;
4
5use ed25519_dalek::SigningKey;
6use manifest::PluginManifest;
7use signing::{hash_file, load_signing_key, load_signing_key_from_env, sign_payload};
8use std::path::{Path, PathBuf};
9
10/// A build-time tool that compiles, packages, and optionally signs
11/// a WinIsland plugin DLL into a distributable ZIP archive.
12///
13/// # Usage
14///
15/// ```rust,no_run
16/// use winisland_plugin_api::packager::PluginPackager;
17///
18/// PluginPackager::from_cargo()
19///     .unwrap()
20///     .signing_key_path("signing_key.pem")
21///     .include_dir("assets")
22///     .build()
23///     .unwrap();
24/// ```
25pub struct PluginPackager {
26    name: String,
27    author: String,
28    version: String,
29    description: String,
30    github_link: String,
31    dll_name: String,
32    dll_path: Option<PathBuf>,
33    extra_dirs: Vec<String>,
34    signing_key: Option<SigningKey>,
35    output: Option<PathBuf>,
36}
37
38impl PluginPackager {
39    /// Create a packager by reading `Cargo.toml` from the current working directory.
40    ///
41    /// Automatically fills in `name`, `version`, and `author` from the
42    /// `[package]` section. The DLL filename is derived from the crate name
43    /// (hyphens replaced with underscores).
44    pub fn from_cargo() -> Result<Self, String> {
45        let cargo_toml_path = Path::new("Cargo.toml");
46        let contents = std::fs::read_to_string(cargo_toml_path).map_err(|e| {
47            format!(
48                "Cannot read Cargo.toml (run from the plugin project root): {}",
49                e
50            )
51        })?;
52
53        let value: toml::Value =
54            toml::from_str(&contents).map_err(|e| format!("Cannot parse Cargo.toml: {}", e))?;
55
56        let pkg = value
57            .get("package")
58            .ok_or_else(|| "Cargo.toml missing [package] section".to_string())?;
59
60        let name = pkg
61            .get("name")
62            .and_then(|v| v.as_str())
63            .ok_or_else(|| "Cargo.toml missing package.name".to_string())?
64            .to_string();
65
66        let version = pkg
67            .get("version")
68            .and_then(|v| v.as_str())
69            .unwrap_or("0.1.0")
70            .to_string();
71
72        let author = pkg
73            .get("authors")
74            .and_then(|v| v.as_array())
75            .and_then(|a| a.first())
76            .and_then(|v| v.as_str())
77            .unwrap_or("")
78            .to_string();
79
80        let description = pkg
81            .get("description")
82            .and_then(|v| v.as_str())
83            .unwrap_or("")
84            .to_string();
85
86        let dll_name = name.replace('-', "_");
87
88        Ok(Self {
89            name,
90            author,
91            version,
92            description,
93            github_link: String::new(),
94            dll_name,
95            dll_path: None,
96            extra_dirs: Vec::new(),
97            signing_key: None,
98            output: None,
99        })
100    }
101
102    /// Create a packager with manually specified plugin name.
103    pub fn new(name: &str) -> Self {
104        Self {
105            name: name.to_string(),
106            author: String::new(),
107            version: "0.1.0".to_string(),
108            description: String::new(),
109            github_link: String::new(),
110            dll_name: name.to_string().replace('-', "_"),
111            dll_path: None,
112            extra_dirs: Vec::new(),
113            signing_key: None,
114            output: None,
115        }
116    }
117
118    /// Set the plugin author.
119    pub fn author(&mut self, v: &str) -> &mut Self {
120        self.author = v.to_string();
121        self
122    }
123
124    /// Set the plugin version.
125    pub fn version(&mut self, v: &str) -> &mut Self {
126        self.version = v.to_string();
127        self
128    }
129
130    /// Set the plugin description.
131    pub fn description(&mut self, v: &str) -> &mut Self {
132        self.description = v.to_string();
133        self
134    }
135
136    /// Set the GitHub link for the plugin repository.
137    pub fn github_link(&mut self, v: &str) -> &mut Self {
138        self.github_link = v.to_string();
139        self
140    }
141
142    /// Override the DLL filename (without `.dll` extension).
143    ///
144    /// By default the DLL name is derived from the crate name by
145    /// replacing hyphens with underscores.
146    pub fn dll_name(&mut self, name: &str) -> &mut Self {
147        self.dll_name = name.to_string();
148        self
149    }
150
151    /// Override the built DLL path.
152    ///
153    /// By default it looks for `target/release/<dll_name>.dll`.
154    pub fn dll_path(&mut self, path: &str) -> &mut Self {
155        self.dll_path = Some(PathBuf::from(path));
156        self
157    }
158
159    /// Include an additional directory in the plugin ZIP.
160    ///
161    /// The directory is relative to the plugin project root.
162    /// Can be called multiple times to include multiple directories.
163    /// Typical uses: `assets`, `locales`, `fonts`.
164    pub fn include_dir(&mut self, dir: &str) -> &mut Self {
165        self.extra_dirs.push(dir.to_string());
166        self
167    }
168
169    /// Sign the plugin with a key loaded from a PEM file.
170    pub fn signing_key_path(&mut self, path: &str) -> &mut Self {
171        match load_signing_key(Path::new(path)) {
172            Ok(key) => {
173                self.signing_key = Some(key);
174            }
175            Err(e) => {
176                log::warn!("Signing key not loaded: {}", e);
177            }
178        }
179        self
180    }
181
182    /// Sign the plugin with a key loaded from an environment variable.
183    pub fn signing_key_env(&mut self, var: &str) -> &mut Self {
184        match load_signing_key_from_env(var) {
185            Ok(key) => {
186                self.signing_key = Some(key);
187            }
188            Err(e) => {
189                log::warn!("Signing key not loaded from env '{}': {}", var, e);
190            }
191        }
192        self
193    }
194
195    /// Sign the plugin with a key provided directly as bytes.
196    pub fn signing_key_bytes(&mut self, key_bytes: &[u8; 64]) -> &mut Self {
197        match SigningKey::from_keypair_bytes(key_bytes) {
198            Ok(key) => {
199                self.signing_key = Some(key);
200            }
201            Err(e) => {
202                log::warn!("Signing key not loaded from bytes: {}", e);
203            }
204        }
205        self
206    }
207
208    /// Set the output ZIP path.
209    ///
210    /// Defaults to `target/<name>-<version>.zip`.
211    pub fn output(&mut self, path: &str) -> &mut Self {
212        self.output = Some(PathBuf::from(path));
213        self
214    }
215
216    /// Execute the full build + package + sign pipeline.
217    ///
218    /// 1. Runs `cargo build --release`
219    /// 2. Locates the built `.dll`
220    /// 3. Creates a staging directory with the DLL and extra dirs
221    /// 4. Generates `plugin.yml` with DLL hashes
222    /// 5. Signs the manifest if a signing key was provided
223    /// 6. Packs everything into a ZIP archive
224    ///
225    /// Returns the path to the generated ZIP file.
226    pub fn build(&self) -> Result<PathBuf, String> {
227        // 1. Build the DLL
228        log::info!("Building plugin '{}' in release mode...", self.name);
229        let status = std::process::Command::new("cargo")
230            .args(["build", "--release"])
231            .status()
232            .map_err(|e| format!("Failed to run cargo build: {}", e))?;
233
234        if !status.success() {
235            return Err("cargo build --release failed".to_string());
236        }
237
238        // 2. Locate the DLL
239        let dll_path = self.locate_dll()?;
240        let dll_dest_name = dll_path
241            .file_name()
242            .and_then(|n| n.to_str())
243            .unwrap_or("plugin.dll");
244
245        // 3. Create staging directory
246        let staging = tempfile::tempdir().map_err(|e| format!("Cannot create temp dir: {}", e))?;
247        let staging_path = staging.path();
248
249        // 4. Copy DLL
250        std::fs::copy(&dll_path, staging_path.join(dll_dest_name))
251            .map_err(|e| format!("Cannot copy DLL: {}", e))?;
252
253        // 5. Copy extra directories
254        for dir in &self.extra_dirs {
255            let src = Path::new(dir);
256            if src.exists() {
257                let dst = staging_path.join(dir);
258                copy_dir_all(src, &dst)?;
259            } else {
260                log::warn!("Extra directory '{}' not found, skipping", dir);
261            }
262        }
263
264        // 6. Compute DLL hashes
265        let mut dll_hashes = Vec::new();
266        let dll_hash = hash_file(&dll_path).map_err(|e| format!("Cannot hash DLL: {}", e))?;
267        dll_hashes.push(dll_hash);
268        // Also hash any extra DLLs in extra dirs
269        for dir in &self.extra_dirs {
270            let dll_dir = staging_path.join(dir);
271            if dll_dir.exists() {
272                collect_dll_hashes(&dll_dir, &mut dll_hashes)?;
273            }
274        }
275
276        // 7. Build manifest
277        let mut manifest = PluginManifest {
278            name: self.name.clone(),
279            author: self.author.clone(),
280            version: self.version.clone(),
281            description: self.description.clone(),
282            github_link: self.github_link.clone(),
283            signature: None,
284            dll_hashes: Some(dll_hashes.clone()),
285        };
286
287        // 8. Sign the manifest
288        if let Some(key) = &self.signing_key {
289            let payload = manifest.signing_payload();
290            let sig = sign_payload(key, payload.as_bytes());
291            manifest.signature = Some(sig);
292            log::info!("Plugin signed");
293        } else {
294            log::info!("Plugin not signed (no signing key provided)");
295        }
296
297        // 9. Write plugin.yml
298        manifest
299            .write_to_yaml(&staging_path.join("plugin.yml"))
300            .map_err(|e| format!("Cannot write plugin.yml: {}", e))?;
301
302        // 10. Create ZIP
303        let output_path = self
304            .output
305            .clone()
306            .unwrap_or_else(|| PathBuf::from(format!("target/{}-{}.zip", self.name, self.version)));
307
308        packaging::create_zip(staging_path, &output_path)?;
309
310        log::info!("Plugin packaged: {}", output_path.display());
311        Ok(output_path)
312    }
313
314    fn locate_dll(&self) -> Result<PathBuf, String> {
315        if let Some(path) = &self.dll_path {
316            if path.exists() {
317                return Ok(path.clone());
318            }
319            return Err(format!(
320                "Specified DLL path does not exist: {}",
321                path.display()
322            ));
323        }
324
325        // Default: target/release/<dll_name>.dll
326        let release_path = PathBuf::from(format!("target/release/{}.dll", self.dll_name));
327        if release_path.exists() {
328            return Ok(release_path);
329        }
330
331        // Fallback: target/release/<dll_name>.so (Linux/macOS)
332        let release_so = PathBuf::from(format!("target/release/lib{}.so", self.dll_name));
333        if release_so.exists() {
334            return Ok(release_so);
335        }
336
337        Err(format!(
338            "Cannot find built DLL. Expected at '{}' or '{}'. \
339             Make sure 'cargo build --release' completed successfully.",
340            release_path.display(),
341            release_so.display(),
342        ))
343    }
344}
345
346fn copy_dir_all(src: &Path, dst: &Path) -> Result<(), String> {
347    std::fs::create_dir_all(dst)
348        .map_err(|e| format!("Cannot create dir '{}': {}", dst.display(), e))?;
349
350    for entry in
351        std::fs::read_dir(src).map_err(|e| format!("Cannot read dir '{}': {}", src.display(), e))?
352    {
353        let entry = entry.map_err(|e| format!("Dir entry error: {}", e))?;
354        let ty = entry
355            .file_type()
356            .map_err(|e| format!("File type error: {}", e))?;
357        let src_path = entry.path();
358        let file_name = src_path
359            .file_name()
360            .ok_or_else(|| "Invalid filename".to_string())?;
361        let dst_path = dst.join(file_name);
362
363        if ty.is_dir() {
364            copy_dir_all(&src_path, &dst_path)?;
365        } else {
366            std::fs::copy(&src_path, &dst_path)
367                .map_err(|e| format!("Cannot copy '{}': {}", src_path.display(), e))?;
368        }
369    }
370    Ok(())
371}
372
373fn collect_dll_hashes(dir: &Path, hashes: &mut Vec<String>) -> Result<(), String> {
374    for entry in std::fs::read_dir(dir).map_err(|e| format!("Cannot read dir: {}", e))? {
375        let entry = entry.map_err(|e| format!("Dir entry error: {}", e))?;
376        let path = entry.path();
377        if path.extension().is_some_and(|ext| ext == "dll") {
378            let hash = hash_file(&path)?;
379            hashes.push(hash);
380        }
381        if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
382            collect_dll_hashes(&path, hashes)?;
383        }
384    }
385    Ok(())
386}