diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/CgbiPngTests.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/CgbiPngTests.cs new file mode 100644 index 000000000..da3dd224b --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/CgbiPngTests.cs @@ -0,0 +1,56 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuickLook.Plugin.AppViewer.PackageParsers.Ipa; +using System.Drawing; +using System.IO; + +namespace QuickLook.Plugin.AppViewer.Tests; + +[TestClass] +public class CgbiPngTests +{ + [TestMethod] + public void ConvertToStandardPng_RestoresRedAndBlueChannels() + { + byte[] cgbi = CgbiSample.Create(2, 2, 7, 139, 237, 255); + + Assert.IsTrue(CgbiPng.IsCgbi(cgbi)); + + byte[] png = CgbiPng.ConvertToStandardPng(cgbi); + + Assert.IsFalse(CgbiPng.IsCgbi(png)); + using var stream = new MemoryStream(png); + using var bitmap = new Bitmap(stream); + Color color = bitmap.GetPixel(0, 0); + Assert.AreEqual(237, color.R); + Assert.AreEqual(139, color.G); + Assert.AreEqual(7, color.B); + Assert.AreEqual(255, color.A); + } + + [TestMethod] + public void ConvertToStandardPng_LeavesRegularPngUntouched() + { + byte[] png = System.Convert.FromBase64String( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="); + + Assert.IsFalse(CgbiPng.IsCgbi(png)); + Assert.AreSame(png, CgbiPng.ConvertToStandardPng(png)); + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/CgbiSample.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/CgbiSample.cs new file mode 100644 index 000000000..f1bd5a99f --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/CgbiSample.cs @@ -0,0 +1,113 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using ICSharpCode.SharpZipLib.Checksum; +using ICSharpCode.SharpZipLib.Zip.Compression; +using ICSharpCode.SharpZipLib.Zip.Compression.Streams; +using System; +using System.IO; +using System.Text; + +namespace QuickLook.Plugin.AppViewer.Tests; + +internal static class CgbiSample +{ + private static readonly byte[] Signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + public static byte[] Create(int width, int height, byte blue, byte green, byte red, byte alpha) + { + int rowBytes = width * 4; + byte[] raw = new byte[(rowBytes + 1) * height]; + + for (int y = 0; y < height; y++) + { + int row = y * (rowBytes + 1) + 1; + for (int x = 0; x < width; x++) + { + int pixel = row + x * 4; + raw[pixel] = blue; + raw[pixel + 1] = green; + raw[pixel + 2] = red; + raw[pixel + 3] = alpha; + } + } + + byte[] compressed; + using (var buffer = new MemoryStream()) + { + using (DeflaterOutputStream deflater = new(buffer, new Deflater(Deflater.BEST_COMPRESSION, true)) + { + IsStreamOwner = false, + }) + { + deflater.Write(raw, 0, raw.Length); + } + + compressed = buffer.ToArray(); + } + + using var output = new MemoryStream(); + output.Write(Signature, 0, Signature.Length); + + WriteChunk(output, "CgBI", []); + + byte[] header = new byte[13]; + WriteInt32BigEndian(header, 0, width); + WriteInt32BigEndian(header, 4, height); + header[8] = 8; + header[9] = 6; + WriteChunk(output, "IHDR", header); + + WriteChunk(output, "IDAT", compressed); + WriteChunk(output, "IEND", []); + + return output.ToArray(); + } + + public static void WriteChunk(Stream stream, string type, byte[] data) + { + byte[] typeBytes = Encoding.ASCII.GetBytes(type); + WriteInt32BigEndian(stream, data.Length); + stream.Write(typeBytes, 0, typeBytes.Length); + stream.Write(data, 0, data.Length); + + Crc32 crc = new(); + crc.Update(typeBytes); + if (data.Length > 0) + { + crc.Update(data); + } + + WriteInt32BigEndian(stream, unchecked((int)crc.Value)); + } + + private static void WriteInt32BigEndian(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)value; + } + + private static void WriteInt32BigEndian(Stream stream, int value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/IpaReaderTests.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/IpaReaderTests.cs new file mode 100644 index 000000000..b36748606 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/IpaReaderTests.cs @@ -0,0 +1,106 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using ICSharpCode.SharpZipLib.Zip; +using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuickLook.Plugin.AppViewer.PackageParsers.Ipa; +using System; +using System.Drawing; +using System.IO; +using System.Text; + +namespace QuickLook.Plugin.AppViewer.Tests; + +[TestClass] +public class IpaReaderTests +{ + private const string InfoPlist = """ + + + + + CFBundleDisplayName + Sample + CFBundleShortVersionString + 1.2.3 + CFBundleVersion + 123 + CFBundleIdentifier + com.example.sample + MinimumOSVersion + 6.1 + UIDeviceFamily + + 1 + 2 + + + CFBundleIconFiles + + AppIcon57x57 + AppIcon60x60 + + + + """; + + [TestMethod] + public void Parse_ReadsInfoPlistWithCommentsAndConvertsCgbiIcon() + { + byte[] cgbi = CgbiSample.Create(2, 2, 7, 139, 237, 255); + + using var stream = new MemoryStream(); + using (var zip = new ZipOutputStream(stream) + { + IsStreamOwner = false, + }) + { + AddEntry(zip, "Payload/Sample.app/Info.plist", Encoding.UTF8.GetBytes(InfoPlist)); + AddEntry(zip, "Payload/Sample.app/AppIcon60x60@2x.png", cgbi); + } + + stream.Position = 0; + IpaReader reader = new(stream); + + Assert.AreEqual("Sample", reader.DisplayName); + Assert.AreEqual("1.2.3", reader.ShortVersionString); + Assert.AreEqual("123", reader.Version); + Assert.AreEqual("com.example.sample", reader.Identifier); + Assert.AreEqual("iPhone, iPad", reader.DeviceFamily); + Assert.IsNotNull(reader.Icon); + + using var iconStream = new MemoryStream(reader.Icon); + using var bitmap = new Bitmap(iconStream); + Color color = bitmap.GetPixel(0, 0); + Assert.AreEqual(237, color.R); + Assert.AreEqual(139, color.G); + Assert.AreEqual(7, color.B); + } + + private static void AddEntry(ZipOutputStream zip, string name, byte[] data) + { + ZipEntry entry = new(name) + { + Size = data.Length, + DateTime = new DateTime(2020, 1, 1), + }; + + zip.PutNextEntry(entry); + zip.Write(data, 0, data.Length); + zip.CloseEntry(); + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/PlistTests.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/PlistTests.cs new file mode 100644 index 000000000..75aae8e50 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/PlistTests.cs @@ -0,0 +1,56 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using Microsoft.VisualStudio.TestTools.UnitTesting; +using QuickLook.Plugin.AppViewer.PackageParsers.Ipa; +using System.Collections.Generic; +using System.Text; + +namespace QuickLook.Plugin.AppViewer.Tests; + +[TestClass] +public class PlistTests +{ + [TestMethod] + public void ReadPlist_IgnoresXmlComments() + { + const string xml = """ + + + + + + CFBundleDisplayName + Test App + + CFBundleIconFiles + + + Icon-57.png + Icon-180.png + + + + """; + + var result = (Dictionary)Plist.ReadPlist(Encoding.UTF8.GetBytes(xml)); + + Assert.AreEqual("Test App", result["CFBundleDisplayName"]); + var icons = (List)result["CFBundleIconFiles"]; + CollectionAssert.AreEqual(new object[] { "Icon-57.png", "Icon-180.png" }, icons); + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/QuickLook.Plugin.AppViewer.Tests.csproj b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/QuickLook.Plugin.AppViewer.Tests.csproj new file mode 100644 index 000000000..e50eb6e5d --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer.Tests/QuickLook.Plugin.AppViewer.Tests.csproj @@ -0,0 +1,28 @@ + + + + net462 + false + latest + + + + + + + + + + + + + + + + + + + + + + diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/CgbiPng.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/CgbiPng.cs new file mode 100644 index 000000000..28df7ae01 --- /dev/null +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/CgbiPng.cs @@ -0,0 +1,329 @@ +// Copyright © 2017-2026 QL-Win Contributors +// +// This file is part of QuickLook program. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +using ICSharpCode.SharpZipLib.Checksum; +using ICSharpCode.SharpZipLib.Zip.Compression; +using ICSharpCode.SharpZipLib.Zip.Compression.Streams; +using System; +using System.IO; +using System.Text; + +namespace QuickLook.Plugin.AppViewer.PackageParsers.Ipa; + +/// +/// Converts Apple's CgBI PNG variant into a standard PNG. +/// CgBI files store premultiplied BGRA pixels and compress the image data as raw deflate, +/// which regular PNG decoders interpret as ABGR and therefore render with swapped colors. +/// +public static class CgbiPng +{ + private static readonly byte[] Signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + public static bool IsCgbi(byte[] data) + { + if (data == null || data.Length < 20 || !HasSignature(data)) + { + return false; + } + + return Encoding.ASCII.GetString(data, 12, 4) == "CgBI"; + } + + public static byte[] ConvertToStandardPng(byte[] data) + { + if (!IsCgbi(data)) + { + return data; + } + + int width = 0; + int height = 0; + int bitDepth = 0; + int colorType = -1; + int interlace = 0; + using var idat = new MemoryStream(); + + int position = 8; + while (position + 12 <= data.Length) + { + int length = ReadInt32BigEndian(data, position); + if (length < 0 || (long)position + 12 + length > data.Length) + { + return data; + } + + string type = Encoding.ASCII.GetString(data, position + 4, 4); + int dataStart = position + 8; + + if (type == "IEND") + { + break; + } + + if (type == "IHDR") + { + if (length < 13) + { + return data; + } + + width = ReadInt32BigEndian(data, dataStart); + height = ReadInt32BigEndian(data, dataStart + 4); + bitDepth = data[dataStart + 8]; + colorType = data[dataStart + 9]; + interlace = data[dataStart + 12]; + } + else if (type == "IDAT") + { + idat.Write(data, dataStart, length); + } + + position = dataStart + length + 4; + } + + // Only the 8-bit non-interlaced RGB/RGBA layouts produced by Apple's pngcrush are handled. + if (width <= 0 || height <= 0 || bitDepth != 8 || (colorType != 2 && colorType != 6) || interlace != 0) + { + return data; + } + + int bytesPerPixel = colorType == 6 ? 4 : 3; + long rowBytesLong = (long)width * bytesPerPixel; + long filteredLength = (rowBytesLong + 1) * height; + if (rowBytesLong > int.MaxValue || filteredLength > int.MaxValue) + { + return data; + } + + int rowBytes = (int)rowBytesLong; + byte[] filtered = new byte[(int)filteredLength]; + + try + { + Inflater inflater = new(true); + byte[] compressed = idat.ToArray(); + inflater.SetInput(compressed); + + int totalRead = 0; + while (totalRead < filtered.Length) + { + int read = inflater.Inflate(filtered, totalRead, filtered.Length - totalRead); + if (read == 0) + { + break; + } + + totalRead += read; + } + + if (totalRead < filtered.Length) + { + return data; + } + } + catch (Exception) + { + return data; + } + + byte[] pixels = Unfilter(filtered, width, height, bytesPerPixel); + if (pixels == null) + { + return data; + } + + byte[] rgba = SwapChannels(pixels, bytesPerPixel); + return EncodePng(rgba, width, height, colorType, rowBytes); + } + + private static byte[] Unfilter(byte[] filtered, int width, int height, int bytesPerPixel) + { + int rowBytes = width * bytesPerPixel; + byte[] pixels = new byte[rowBytes * height]; + + for (int y = 0; y < height; y++) + { + int source = y * (rowBytes + 1); + int filter = filtered[source]; + int destination = y * rowBytes; + int previous = destination - rowBytes; + source++; + + for (int x = 0; x < rowBytes; x++) + { + int raw = filtered[source + x]; + int left = x >= bytesPerPixel ? pixels[destination + x - bytesPerPixel] : 0; + int up = y > 0 ? pixels[previous + x] : 0; + int upLeft = y > 0 && x >= bytesPerPixel ? pixels[previous + x - bytesPerPixel] : 0; + + int value = filter switch + { + 0 => raw, + 1 => raw + left, + 2 => raw + up, + 3 => raw + ((left + up) >> 1), + 4 => raw + Paeth(left, up, upLeft), + _ => -1, + }; + + if (value < 0) + { + return null; + } + + pixels[destination + x] = (byte)value; + } + } + + return pixels; + } + + private static byte[] SwapChannels(byte[] pixels, int bytesPerPixel) + { + byte[] rgba = new byte[pixels.Length]; + + for (int i = 0; i < pixels.Length; i += bytesPerPixel) + { + int blue = pixels[i]; + int green = pixels[i + 1]; + int red = pixels[i + 2]; + + if (bytesPerPixel == 4) + { + int alpha = pixels[i + 3]; + if (alpha > 0 && alpha < 255) + { + red = Math.Min(255, (red * 255 + alpha / 2) / alpha); + green = Math.Min(255, (green * 255 + alpha / 2) / alpha); + blue = Math.Min(255, (blue * 255 + alpha / 2) / alpha); + } + + rgba[i] = (byte)red; + rgba[i + 1] = (byte)green; + rgba[i + 2] = (byte)blue; + rgba[i + 3] = (byte)alpha; + } + else + { + rgba[i] = (byte)red; + rgba[i + 1] = (byte)green; + rgba[i + 2] = (byte)blue; + } + } + + return rgba; + } + + private static byte[] EncodePng(byte[] pixels, int width, int height, int colorType, int rowBytes) + { + using var output = new MemoryStream(); + output.Write(Signature, 0, Signature.Length); + + byte[] header = new byte[13]; + WriteInt32BigEndian(header, 0, width); + WriteInt32BigEndian(header, 4, height); + header[8] = 8; + header[9] = (byte)colorType; + WriteChunk(output, "IHDR", header); + + using (var raw = new MemoryStream()) + { + using (DeflaterOutputStream deflater = new(raw, new Deflater(Deflater.BEST_COMPRESSION)) + { + IsStreamOwner = false, + }) + { + for (int y = 0; y < height; y++) + { + deflater.WriteByte(0); + deflater.Write(pixels, y * rowBytes, rowBytes); + } + } + + WriteChunk(output, "IDAT", raw.ToArray()); + } + + WriteChunk(output, "IEND", []); + return output.ToArray(); + } + + private static void WriteChunk(Stream stream, string type, byte[] data) + { + byte[] typeBytes = Encoding.ASCII.GetBytes(type); + WriteInt32BigEndian(stream, data.Length); + stream.Write(typeBytes, 0, typeBytes.Length); + stream.Write(data, 0, data.Length); + + Crc32 crc = new(); + crc.Update(typeBytes); + if (data.Length > 0) + { + crc.Update(data); + } + + WriteInt32BigEndian(stream, unchecked((int)crc.Value)); + } + + private static bool HasSignature(byte[] data) + { + for (int i = 0; i < Signature.Length; i++) + { + if (data[i] != Signature[i]) + { + return false; + } + } + + return true; + } + + private static int ReadInt32BigEndian(byte[] data, int offset) + { + return (data[offset] << 24) | (data[offset + 1] << 16) | (data[offset + 2] << 8) | data[offset + 3]; + } + + private static void WriteInt32BigEndian(byte[] buffer, int offset, int value) + { + buffer[offset] = (byte)(value >> 24); + buffer[offset + 1] = (byte)(value >> 16); + buffer[offset + 2] = (byte)(value >> 8); + buffer[offset + 3] = (byte)value; + } + + private static void WriteInt32BigEndian(Stream stream, int value) + { + stream.WriteByte((byte)(value >> 24)); + stream.WriteByte((byte)(value >> 16)); + stream.WriteByte((byte)(value >> 8)); + stream.WriteByte((byte)value); + } + + private static int Paeth(int left, int up, int upLeft) + { + int estimate = left + up - upLeft; + int distanceLeft = Math.Abs(estimate - left); + int distanceUp = Math.Abs(estimate - up); + int distanceUpLeft = Math.Abs(estimate - upLeft); + + if (distanceLeft <= distanceUp && distanceLeft <= distanceUpLeft) + { + return left; + } + + return distanceUp <= distanceUpLeft ? up : upLeft; + } +} diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/IpaReader.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/IpaReader.cs index f329e1041..e8d5c35b1 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/IpaReader.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/IpaReader.cs @@ -187,7 +187,7 @@ private void Open(Stream stream) if (fileName.StartsWith(IconName)) { using var s = new BinaryReader(zip.GetInputStream(entry)); - Icon = s.ReadBytes((int)entry.Size); + Icon = CgbiPng.ConvertToStandardPng(s.ReadBytes((int)entry.Size)); break; } } diff --git a/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/Plist.cs b/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/Plist.cs index f57cb43ec..d5b78a648 100644 --- a/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/Plist.cs +++ b/QuickLook.Plugin/QuickLook.Plugin.AppViewer/PackageParsers/Ipa/Plist.cs @@ -20,6 +20,7 @@ using System.Collections.Generic; using System.Globalization; using System.IO; +using System.Linq; using System.Text; using System.Xml; @@ -199,10 +200,20 @@ public static byte[] WriteBinary(object value) private static object ReadXml(XmlDocument xml) { - XmlNode rootNode = xml.DocumentElement.ChildNodes[0]; + XmlNode rootNode = GetElementChildren(xml.DocumentElement).FirstOrDefault(); + if (rootNode == null) + { + throw new ApplicationException("Plist root node is missing"); + } + return Parse(rootNode); } + private static IEnumerable GetElementChildren(XmlNode node) + { + return node.ChildNodes.Cast().Where(child => child.NodeType == XmlNodeType.Element); + } + private static object ReadBinary(byte[] data) { offsetTable.Clear(); @@ -229,7 +240,7 @@ private static object ReadBinary(byte[] data) private static Dictionary ParseDictionary(XmlNode node) { - XmlNodeList children = node.ChildNodes; + List children = [.. GetElementChildren(node)]; if (children.Count % 2 != 0) { throw new DataMisalignedException("Dictionary elements must have an even number of child nodes"); @@ -264,7 +275,7 @@ private static List ParseArray(XmlNode node) { List array = []; - foreach (XmlNode child in node.ChildNodes) + foreach (XmlNode child in GetElementChildren(node)) { object result = Parse(child); if (result != null)