collective

Browse / github.com/storytold/craft-launcher / patch

fix(windows): support nested portable ZIP directories

fix(windows): support nested portable ZIP directories ## Summary Fix Windows portable ZIP installation failures when official Craft releases contain an additional top-level directory. The installer currently expects the application executable directly at the archive root, causing installation to fail with: ```text The portable ZIP does not contain the expected executable at its root. ``` ## Changes - Extract the ZIP as before, preserving existing archive safety checks. - Detect a single top-level directory containing the expected executable. - Use that directory as the installation source, removing the versioned wrapper from installed paths.

Status
open
Score
0.00
Adopters
0 other operators
Author
daro93 (imported from pull request #610, open)
Base
ba3b0345f0b14f5cb892dcb8b917ca212c124cf9
Size
+99 −15 in 1 file
Review
pending
Shared
2026-10-28
Id
721f65718c87cb39f00683d4

How it went

No other operator has reported using it yet.

Diff

raw · crates/native/src/portable.rs

diff --git a/crates/native/src/portable.rs b/crates/native/src/portable.rs--- a/crates/native/src/portable.rs+++ b/crates/native/src/portable.rs@@ -100,6 +100,7 @@ impl Drop for Staging {     } } +/// Extracts a portable ZIP into staging, enforcing path, size, and link safety checks. fn extract(file: &Path, destination: &Path) -> Result<Vec<PathBuf>, String> {     let file = std::fs::File::open(file).map_err(|e| e.to_string())?;     let mut archive = zip::ZipArchive::new(file).map_err(|e| format!("couldn't read the portable ZIP ({e})"))?;@@ -143,6 +144,22 @@ fn extract(file: &Path, destination: &Path) -> Result<Vec<PathBuf>, String> {     Ok(files) } +/// Finds the app executable directly under the managed application root.+/// This preserves the path layout required by updates and removal.+fn root_executable(app: &CraftApp, files: &[PathBuf]) -> Option<PathBuf> {+    files+        .iter()+        .find(|relative| {+            relative.parent().is_some_and(|p| p.as_os_str().is_empty())+                && relative+                    .file_name()+                    .and_then(|n| n.to_str())+                    .is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe"))))+        })+        .cloned()+}++/// Resolves the managed install root and checks previously recorded ownership and paths. fn recorded_files<'a>(app: &CraftApp, record: &'a Record) -> Result<(&'a Path, Vec<PathBuf>), String> {     let root = record.path.parent().ok_or("The portable installation has no parent folder.")?;     if record.kind != PackageKind::PortableZip || root.file_name().is_none_or(|n| n != app.id) {@@ -202,20 +219,35 @@ pub fn install(     std::fs::create_dir_all(staging.parent().ok_or("no staging folder")?).map_err(|e| e.to_string())?;     std::fs::create_dir(&staging).map_err(|e| e.to_string())?;     let staging = Staging(staging);-    let unpacked = staging.0.join("unpacked");+    let mut unpacked = staging.0.join("unpacked");     std::fs::create_dir(&unpacked).map_err(|e| e.to_string())?;-    let files = extract(file, &unpacked)?;-    let executable = files-        .iter()-        .find(|relative| {-            relative.parent().is_some_and(|p| p.as_os_str().is_empty())-                && relative-                    .file_name()-                    .and_then(|n| n.to_str())-                    .is_some_and(|name| app.stems().any(|stem| name.eq_ignore_ascii_case(&format!("{stem}.exe"))))-        })-        .ok_or("The portable ZIP does not contain the expected executable at its root.")?-        .clone();+    let mut files = extract(file, &unpacked)?;++    // Some official portable releases package everything inside one versioned directory.+    // Treat that directory as the app root so installed paths/manifest entries stay flat.+    // Root-level layouts retain their existing behavior.+    if root_executable(app, &files).is_none() {+        // Inspect extracted files, not directory entries: ZIPs may contain unrelated+        // empty directories alongside the actual wrapped application.+        let prefix = files+            .first()+            .and_then(|file| file.components().next())+            .map(|component| PathBuf::from(component.as_os_str()));+        if let Some(prefix) = prefix {+            let stripped = files+                .iter()+                .map(|file| file.strip_prefix(&prefix).ok().map(Path::to_path_buf))+                .collect::<Option<Vec<_>>>();+            if let Some(stripped) = stripped+                && root_executable(app, &stripped).is_some()+            {+                unpacked = unpacked.join(prefix);+                files = stripped;+            }+        }+    }+    let executable = root_executable(app, &files)+        .ok_or("The portable ZIP does not contain the expected executable at its root.")?;     // Validate the entire transaction before moving any existing program file.     for relative in &files {         let target = destination.join(relative);@@ -309,12 +341,18 @@ mod tests {     use super::*;     use std::io::Write; +    /// Creates a ZIP fixture containing files and optional explicit directory entries.     fn archive(root: &Path, name: &str, files: &[(&str, &[u8])]) -> PathBuf {         let path = root.join(name);         let mut zip = zip::ZipWriter::new(std::fs::File::create(&path).unwrap());         for (name, content) in files {-            zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();-            zip.write_all(content).unwrap();+            if name.ends_with('/') {+                assert!(content.is_empty());+                zip.add_directory(*name, zip::write::SimpleFileOptions::default()).unwrap();+            } else {+                zip.start_file(*name, zip::write::SimpleFileOptions::default()).unwrap();+                zip.write_all(content).unwrap();+            }         }         zip.finish().unwrap();         path@@ -424,6 +462,52 @@ mod tests {         assert_eq!(std::fs::read(personal).unwrap(), b"personal");     } +    /// Verifies that a versioned ZIP wrapper is flattened for install, update, and removal,+    /// even when the archive contains an unrelated empty directory entry.+    #[test]+    fn wrapped_release_zip_installs_updates_and_uninstalls() {+        let dir = tempfile::tempdir().unwrap();+        let app = CraftApp::by_id("photocraft").unwrap();+        let paths = Paths::under(dir.path());+        let parent = dir.path().join("Apps");+        let first = archive(+            dir.path(),+            "wrapped-first.zip",+            &[+                ("unrelated-empty-directory/", b""),+                ("photocraft-0.10.0-windows-x64-portable/photocraft.exe", b"first"),+                ("photocraft-0.10.0-windows-x64-portable/lib/runtime.dll", b"runtime"),+            ],+        );+        let record = install(app, &first, "0.10.0", None, &paths, Some(&parent), false, &mut |_| Ok(())).unwrap();+        let app_root = std::fs::canonicalize(parent.join("photocraft")).unwrap();+        assert_eq!(record.path, app_root.join("photocraft.exe"));+        assert_eq!(std::fs::read(&record.path).unwrap(), b"first");+        assert!(app_root.join("lib/runtime.dll").exists());+        assert!(!app_root.join("photocraft-0.10.0-windows-x64-portable").exists());+        assert!(!app_root.join("unrelated-empty-directory").exists());++        let personal = app_root.join("settings.json");+        std::fs::write(&personal, b"keep").unwrap();+        let next = archive(+            dir.path(),+            "wrapped-next.zip",+            &[+                ("photocraft-0.11.0-windows-x64-portable/photocraft.exe", b"second"),+                ("photocraft-0.11.0-windows-x64-portable/new.dll", b"new"),+            ],+        );+        let updated = install(app, &next, "0.11.0", Some(&record), &paths, None, false, &mut |_| Ok(())).unwrap();+        assert_eq!(updated.path, record.path);+        assert_eq!(std::fs::read(&updated.path).unwrap(), b"second");+        assert!(!app_root.join("lib/runtime.dll").exists());+        assert!(app_root.join("new.dll").exists());+        assert_eq!(std::fs::read(&personal).unwrap(), b"keep");+        remove(app, &updated).unwrap();+        assert!(!updated.path.exists());+        assert_eq!(std::fs::read(personal).unwrap(), b"keep");+    }+     #[test]     fn a_failed_manifest_commit_restores_the_previous_program() {         let dir = tempfile::tempdir().unwrap();