diff --git a/docs/ENGINES.md b/docs/ENGINES.md index a7131cae9..b945a9b7e 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -1552,7 +1552,7 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep | Format | Read | Write | |---|---|---| -| ZIP, JAR, WBPZ | 7-Zip | ZIP in-process (System.IO.Compression): UTF-8 names with the language-encoding flag, times, empty folders | +| ZIP, JAR, WBPZ, ZXP | 7-Zip | ZIP in-process (System.IO.Compression): UTF-8 names with the language-encoding flag, times, empty folders | | Minecraft MCPACK, MCADDON, MCWORLD, MCTEMPLATE; Modrinth MRPACK | 7-Zip (they are ZIPs) | ZIP in-process, with the marker file at the top (see below) | | 7Z | 7-Zip | 7-Zip | | TAR | 7-Zip | in-process (System.Formats.Tar, PAX: UTF-8 names, times) | @@ -1579,6 +1579,10 @@ bundled with the installer), never a 7-Zip installed on the PC. It runs as a sep an archive or a folder whose only top folder holds the marker gets that folder's content, not the folder (`GamePacks.Root`); an add-on (MCADDON, several packs side by side) is packed as it is. The presets (MCPACK … MRPACK) are in the palette only. +- **Adobe extension packages** (`.zxp`, #1382, read only like JAR): the panels and plug-ins of After Effects, Premiere + Pro, Photoshop and the other Creative Cloud apps are signed ZIPs (`mimetype`, the extension's files and + `META-INF/signatures.xml`). They extract and convert to every archive target with the signature file inside. + Nothing writes `.zxp`: Adobe's apps install only packages signed with the developer's certificate (ZXPSignCmd). - **Compress into one archive** (`Archive.CombineIntoOne`, the "One ZIP" preset): `JobQueue` hands all dropped files, of any type and unconverted, to `IFileCombiner` (implemented by this engine). The archive is named after the first file with the output pattern; files with the same name become `name (2).ext`. ZIP, TAR and TAR.GZ need no 7-Zip. diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index 8d72fec5e..e34c092ec 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -79,7 +79,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow "fods", "fodp", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", "textile", "man", "jsonl", "yaml", "toml", "har", "plist", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "bik", "h265", "h264", "ac3", "caf", "mp2", "dss", "wv", "tta", "w64", "eac3", "ape", "tak", "dsf", "dff", - "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "mcpack", "mcaddon", + "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "zxp", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", "cab", "cpio", "deb", "rpm", "dwg", "ttf", "otf", "woff", "woff2", "ass", "ssa", "ttml", "scc", "glb", "gltf", "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm", "xm", "it" @@ -107,7 +107,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow ([ "ppm", "icns", "qoi", "cur", "xpm", "xbm", "pam", "pfm", "miff", "fodg", "fodt", "fods", "fodp", "man", "jsonl", "yaml", "toml", "har", "plist", "m4s", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", - "lz", "lzma", "jar", "cab", "cpio", "deb", "rpm" + "lz", "lzma", "jar", "zxp", "cab", "cpio", "deb", "rpm" ], ["developer"]), // Markup sources, papers and outlines, maintenance-doc drawings (CGM), Calibre's ebook formats. (["cgm", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "textile", "htmlz", "txtz"], ["writing"]), diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 8b5fce3cc..3d74d1b61 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -323,6 +323,9 @@ public static class FormatRegistry new("lzma", "LZMA", FormatCategory.Archive, ["lzma"], Writable: false), new("z", "Z", FormatCategory.Archive, ["z"], Writable: false), new("jar", "JAR", FormatCategory.Archive, ["jar"], Writable: false), + // Adobe extension packages for After Effects, Premiere Pro, Photoshop … (#1382): signed ZIPs. Never a target, + // since installing one needs the signature its developer makes with Adobe's ZXPSignCmd. + new("zxp", "ZXP (Adobe extension)", FormatCategory.Archive, ["zxp"], Writable: false), // Minecraft Bedrock packs, add-ons, worlds and world templates, and Modrinth modpacks (#824, #898): ZIP archives // under their own extensions, written with their marker file (manifest.json, level.dat, modrinth.index.json) at // the top. diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 93ed6bb72..fe3cf6989 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -170,7 +170,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest public sealed class AppSettings { /// Current schema version. Increase when the format changes and add a migration. - public const int CurrentSchemaVersion = 81; + public const int CurrentSchemaVersion = 82; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d6257a71f..8aa34f154 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1109,6 +1109,10 @@ public static bool ApplyToLibrary(int from, List presets, List? s /// Formats read by 7-Zip. public static readonly IReadOnlyList SevenZipFormats = [ - "zip", "jar", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", + "zip", "jar", "zxp", "7z", "rar", "tar", "tgz", "tbz2", "txz", "tar7z", "tz", "gz", "bz2", "xz", "lzma", "z", "cab", "cpio", "deb", "rpm", "dmg", "iso", "img", "lha", "arj", "split", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", ]; diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index ec804e976..6a0da3a90 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -36,7 +36,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "s3m", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", "ssa", "stl", "sub", "swf", "tak", "tar", "tar7z", "tbz2", "tcr", "tex", "textile", "tgz", "tlz", "toml", "tta", "ttf", "ttml", "txtz", "txz", "typ", "tz", "usm", "vob", "voc", "w64", "wbmp", "wmf", "woff", "woff2", - "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xm", "xpm", "xz", "yaml", "z" + "wpd", "wtv", "wv", "xbm", "xcf", "xlt", "xm", "xpm", "xz", "yaml", "z", "zxp" ]; public static TheoryData HiddenWithOneFieldOff => new() @@ -51,7 +51,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "developer", [ "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", - "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" + "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml", "zxp" ] }, { "writing", ["adoc", "cgm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, { "video-pro", [ @@ -78,7 +78,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "developer", [ "bz2", "cab", "caf", "chm", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "ipynb", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", - "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" + "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml", "zxp" ] }, { "writing", ["adoc", "cgm", "chm", "dbk", "htmlz", "mediawiki", "org", "rst", "tex", "textile", "txtz", "typ"] }, { "video-pro", [ @@ -145,7 +145,7 @@ public void Grade_counts_match_the_published_table() { [FormatGrade.Everyday] = 19, [FormatGrade.General] = 97, - [FormatGrade.Specialist] = 117, + [FormatGrade.Specialist] = 118, [FormatGrade.Legacy] = 52, }, counts); diff --git a/tests/Filee.Core.Tests/ZxpFormatTests.cs b/tests/Filee.Core.Tests/ZxpFormatTests.cs new file mode 100644 index 000000000..547c2e78f --- /dev/null +++ b/tests/Filee.Core.Tests/ZxpFormatTests.cs @@ -0,0 +1,76 @@ +// Adobe extension packages (.zxp, #1382): a read-only archive on the archives donut, learned by existing libraries. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class ZxpFormatTests +{ + [Fact] + public void Zxp_is_an_archive_filee_reads_but_never_writes() + { + var zxp = FormatRegistry.Detect("Panel.ZXP"); + Assert.NotNull(zxp); + Assert.Equal(("zxp", FormatCategory.Archive, false), (zxp.Id, zxp.Category, zxp.Writable)); + Assert.Equal("archives", ProfileSelector.Select(BuiltInData.CreateProfiles(), ["Panel.zxp"])?.Id); + } + + [Fact] + public void Version_81_libraries_learn_zxp_once() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var archives = profiles.Single(p => p.Id == "archives"); + archives.Extensions.Remove("zxp"); + + Assert.True(SettingsMigrations.ApplyToLibrary(81, presets, profiles)); + + Assert.Contains("zxp", archives.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(81, presets, profiles)); + } + + [Fact] + public void A_version_81_library_leaves_zxp_on_the_users_own_donut() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var archives = profiles.Single(p => p.Id == "archives"); + archives.Extensions.Remove("zxp"); + profiles.Insert(0, new ToolbarProfile { Id = "my-extensions", Name = "Extensions", Extensions = ["ZXP"] }); + + Assert.False(SettingsMigrations.ApplyToLibrary(81, presets, profiles)); + + Assert.DoesNotContain("zxp", archives.Extensions, StringComparer.OrdinalIgnoreCase); + Assert.Equal("my-extensions", ProfileSelector.Select(profiles, ["panel.zxp"])?.Id); + } + + [Fact] + public void A_saved_version_81_library_learns_zxp_on_the_next_start_only() + { + using var dir = new TempDir(); + var store = new UserDataStore(dir.Path); + store.Load(); + store.Settings.SchemaVersion = 81; + foreach (var profile in store.Profiles) + profile.Extensions.Remove("zxp"); + store.SaveLibrary(); + store.SaveSettings(); + + var upgraded = new UserDataStore(dir.Path); + upgraded.Load(); + var archives = upgraded.Profiles.Single(p => p.Id == "archives").Extensions; + Assert.Contains("zxp", archives); + Assert.Equal(AppSettings.CurrentSchemaVersion, upgraded.Settings.SchemaVersion); + + // The user takes it off the archives donut again: the start after that leaves it off. + archives.Remove("zxp"); + upgraded.SaveLibrary(); + upgraded.SaveSettings(); + var reloaded = new UserDataStore(dir.Path); + reloaded.Load(); + Assert.DoesNotContain("zxp", reloaded.Profiles.Single(p => p.Id == "archives").Extensions); + } +} diff --git a/tests/Filee.Engines.Tests/ArchiveIsoTests.cs b/tests/Filee.Engines.Tests/ArchiveIsoTests.cs index af984afd5..cef114909 100644 --- a/tests/Filee.Engines.Tests/ArchiveIsoTests.cs +++ b/tests/Filee.Engines.Tests/ArchiveIsoTests.cs @@ -62,6 +62,7 @@ public void Every_supported_archive_has_a_direct_iso_route(string format) [InlineData("alz")] [InlineData("egg")] [InlineData("jar")] + [InlineData("zxp")] public async Task Archives_become_iso_with_nested_unicode_files_and_empty_directories(string format) { Assert.SkipUnless(SevenZip.Locate() is not null, "7-Zip is required."); @@ -76,7 +77,7 @@ public async Task Archives_become_iso_with_nested_unicode_files_and_empty_direct Directory.CreateDirectory(Path.Combine(tree, "nested")); Directory.CreateDirectory(Path.Combine(tree, "empty")); File.WriteAllText(Path.Combine(tree, "nested", "한글.txt"), "contents"); - await new ArchiveConverter().CombineAsync(Directory.GetFileSystemEntries(tree), input, format == "jar" ? "zip" : format, + await new ArchiveConverter().CombineAsync(Directory.GetFileSystemEntries(tree), input, format is "jar" or "zxp" ? "zip" : format, new Preset(), _root, null, TestContext.Current.CancellationToken); } var original = File.ReadAllBytes(input); diff --git a/tests/Filee.Engines.Tests/ArchiveTests.cs b/tests/Filee.Engines.Tests/ArchiveTests.cs index b042433d5..0e50e5b44 100644 --- a/tests/Filee.Engines.Tests/ArchiveTests.cs +++ b/tests/Filee.Engines.Tests/ArchiveTests.cs @@ -38,6 +38,7 @@ private async Task ConvertOkAsync(string source, Preset preset) [Theory] [InlineData("zip")] [InlineData("jar")] + [InlineData("zxp")] [InlineData("7z")] [InlineData("rar")] [InlineData("tgz")] @@ -829,4 +830,46 @@ public async Task A_wbpz_that_is_no_archive_fails() Assert.Equal(JobState.Failed, job.State); Assert.Empty(job.Outputs); } + + // ───────────────────────── Adobe extension packages (issue #1382) ───────────────────────── + + [Fact] + public void A_zxp_is_read_but_never_written() + { + var planner = fx.Catalog.CreatePlanner(["archive"]); + Assert.Null(planner.Plan("zip", "zxp")); + Assert.Null(planner.Plan("7z", "zxp")); + } + + [Fact] + public async Task An_adobe_zxp_extracts_and_converts_to_zip_with_its_signature_file() + { + SkipWithout7Zip(); + var input = Path.Combine(fx.NewFolder(), "panel.zxp"); + // The layout ZXPSignCmd writes: the mimetype first, the extension's files, the signature in META-INF. + var expected = new Dictionary + { + ["mimetype"] = Encoding.ASCII.GetBytes("application/vnd.adobe.air-ucf-package+zip"), + ["CSXS/manifest.xml"] = Encoding.UTF8.GetBytes(""), + ["client/index.html"] = Encoding.UTF8.GetBytes("패널"), + ["META-INF/signatures.xml"] = Encoding.UTF8.GetBytes(""), + }; + using (var archive = ZipFile.Open(input, ZipArchiveMode.Create)) + foreach (var (name, bytes) in expected) + { + using var entry = archive.CreateEntry(name, name == "mimetype" ? CompressionLevel.NoCompression : CompressionLevel.Optimal).Open(); + entry.Write(bytes); + } + var original = await File.ReadAllBytesAsync(input, Ct); + + var folder = (await ConvertOkAsync(input, Extract)).Outputs.Single(); + foreach (var (name, bytes) in expected) + Assert.Equal(bytes, await File.ReadAllBytesAsync(Path.Combine(folder, name), Ct)); + + var zip = (await ConvertOkAsync(input, new Preset { TargetFormat = "zip" })).Outputs.Single(); + Assert.EndsWith("panel.zip", zip); + using var output = ZipFile.OpenRead(zip); + Assert.Equal(expected.Keys.Order(), output.Entries.Where(e => !e.FullName.EndsWith('/')).Select(e => e.FullName).Order()); + Assert.Equal(original, await File.ReadAllBytesAsync(input, Ct)); + } }