From e13596185109475351659f4bf3f81adcf68add20 Mon Sep 17 00:00:00 2001 From: Henry Stamerjohann Date: Mon, 27 Apr 2026 12:00:02 +0200 Subject: [PATCH 1/6] Add .vpptoken quicklook --- PiquePreview/Info.plist | 19 ++++++++++ PiquePreview/PreviewProvider.swift | 56 +++++++++++++++++++++++++++--- PiqueTests/FileFormatTests.swift | 1 + Shared/FileReader.swift | 45 ++++++++++++++++++++++++ Shared/SyntaxHighlighter.swift | 2 +- 5 files changed, 118 insertions(+), 5 deletions(-) diff --git a/PiquePreview/Info.plist b/PiquePreview/Info.plist index 41fb62c..ff35c73 100644 --- a/PiquePreview/Info.plist +++ b/PiquePreview/Info.plist @@ -114,6 +114,23 @@ + + UTTypeIdentifier + io.macadmins.pique.vpptoken + UTTypeDescription + Apple VPP / Apps and Books Token + UTTypeConformsTo + + public.data + + UTTypeTagSpecification + + public.filename-extension + + vpptoken + + + NSExtension @@ -145,6 +162,7 @@ net.daringfireball.markdown io.macadmins.pique.hcl io.macadmins.pique.recipe + io.macadmins.pique.vpptoken com.apple.terminal.shell-script QLFileExtensions @@ -184,6 +202,7 @@ tfvars hcl recipe + vpptoken QLPreviewWidth 1024 diff --git a/PiquePreview/PreviewProvider.swift b/PiquePreview/PreviewProvider.swift index bd6744f..d373e44 100644 --- a/PiquePreview/PreviewProvider.swift +++ b/PiquePreview/PreviewProvider.swift @@ -44,8 +44,11 @@ class PreviewProvider: NSViewController, QLPreviewingController { // Step 2: Convert binary plist to XML text let text: String - if FileReader.isBinaryPlist(data), - let xml = FileReader.convertBinaryPlistToXMLString(data) { + if url.pathExtension.lowercased() == "vpptoken", + let json = FileReader.decodeVPPToken(data) { + text = json + } else if FileReader.isBinaryPlist(data), + let xml = FileReader.convertBinaryPlistToXMLString(data) { text = xml } else { text = FileReader.decodeToString(data) @@ -61,7 +64,12 @@ class PreviewProvider: NSViewController, QLPreviewingController { case .dark: isDark = true } - let html = SyntaxHighlighter.highlight(text, format: format, darkMode: isDark) + var html = SyntaxHighlighter.highlight(text, format: format, darkMode: isDark) + if url.pathExtension.lowercased() == "vpptoken", + let info = FileReader.vppTokenInfo(data) { + let banner = PreviewProvider.vppTokenBanner(info: info, dark: isDark) + html = html.replacingOccurrences(of: "", with: "\(banner)") + } logger.info("Preview for \(url.lastPathComponent, privacy: .public)") @@ -89,10 +97,50 @@ class PreviewProvider: NSViewController, QLPreviewingController { } } + /// Builds an HTML expiration banner for a VPP token, colour-coded by remaining days. + /// Expired → red, ≤30 days → amber, otherwise green. Org name is included when present. + private static func vppTokenBanner(info: FileReader.VPPTokenInfo, dark: Bool) -> String { + let dateFmt = DateFormatter() + dateFmt.dateFormat = "yyyy-MM-dd" + dateFmt.timeZone = TimeZone(secondsFromGMT: 0) + + let title: String + let bg: String + let fg: String + if let exp = info.expDate { + let cal = Calendar(identifier: .gregorian) + let today = cal.startOfDay(for: Date()) + let expDay = cal.startOfDay(for: exp) + let days = cal.dateComponents([.day], from: today, to: expDay).day ?? 0 + let dateStr = dateFmt.string(from: exp) + if days < 0 { + title = "EXPIRED \(-days) day\(-days == 1 ? "" : "s") ago — \(dateStr)" + bg = dark ? "#5a1a1a" : "#fde2e2"; fg = dark ? "#ffb4b4" : "#8a1a1a" + } else if days == 0 { + title = "Expires today — \(dateStr)" + bg = dark ? "#5a1a1a" : "#fde2e2"; fg = dark ? "#ffb4b4" : "#8a1a1a" + } else if days <= 30 { + title = "Expires in \(days) day\(days == 1 ? "" : "s") — \(dateStr)" + bg = dark ? "#5a4a1a" : "#fff4d6"; fg = dark ? "#ffd98a" : "#8a5a00" + } else { + title = "Expires in \(days) days — \(dateStr)" + bg = dark ? "#1a4a2a" : "#dff5e1"; fg = dark ? "#a8e6b8" : "#1a5a2a" + } + } else { + title = "Expiration date not readable" + bg = dark ? "#3a3a3c" : "#eeeeee"; fg = dark ? "#d0d0d0" : "#555555" + } + + let org = (info.orgName?.isEmpty == false) ? " · \(info.orgName!)" : "" + return """ +
\(title)\(org)
+ """ + } + /// Maps a file extension to a format group name matching AppearanceSettings keys. private static func formatName(for ext: String) -> String { switch ext.lowercased() { - case "json", "ndjson", "jsonl": return "JSON" + case "json", "ndjson", "jsonl", "vpptoken": return "JSON" case "yaml", "yml": return "YAML" case "toml", "lock": return "TOML" case "xml", "recipe": return "XML" diff --git a/PiqueTests/FileFormatTests.swift b/PiqueTests/FileFormatTests.swift index 144746f..39fd41b 100644 --- a/PiqueTests/FileFormatTests.swift +++ b/PiqueTests/FileFormatTests.swift @@ -9,6 +9,7 @@ final class FileFormatTests: XCTestCase { XCTAssertEqual(FileFormat(pathExtension: "json"), .json) XCTAssertEqual(FileFormat(pathExtension: "ndjson"), .json) XCTAssertEqual(FileFormat(pathExtension: "jsonl"), .json) + XCTAssertEqual(FileFormat(pathExtension: "vpptoken"), .json) } func testYAML() { diff --git a/Shared/FileReader.swift b/Shared/FileReader.swift index fe3fba6..39691e2 100644 --- a/Shared/FileReader.swift +++ b/Shared/FileReader.swift @@ -81,6 +81,51 @@ enum FileReader { return String(data: xmlData, encoding: .utf8) } + // MARK: - VPP / Apps & Books service token + + /// Metadata extracted from a `.vpptoken` file for use in the preview banner. + struct VPPTokenInfo { + let expDate: Date? + let orgName: String? + } + + /// Returns the parsed expiration date and org name from a base64-wrapped VPP token. + /// Returns nil if the file is not a valid token envelope. + static func vppTokenInfo(_ data: Data) -> VPPTokenInfo? { + let trimmed = decodeToString(data).trimmingCharacters(in: .whitespacesAndNewlines) + guard let decoded = Data(base64Encoded: trimmed, options: .ignoreUnknownCharacters), + let object = try? JSONSerialization.jsonObject(with: decoded), + let dict = object as? [String: Any] + else { return nil } + + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + let exp = (dict["expDate"] as? String).flatMap(formatter.date(from:)) + let org = dict["orgName"] as? String + return VPPTokenInfo(expDate: exp, orgName: org) + } + + /// Decodes a base64-wrapped VPP service token to pretty-printed JSON. + /// The `token` field is masked because it is a bearer credential. + static func decodeVPPToken(_ data: Data) -> String? { + let trimmed = decodeToString(data).trimmingCharacters(in: .whitespacesAndNewlines) + guard let decoded = Data(base64Encoded: trimmed, options: .ignoreUnknownCharacters), + let object = try? JSONSerialization.jsonObject(with: decoded), + var dict = object as? [String: Any] + else { return nil } + + if let token = dict["token"] as? String, !token.isEmpty { + let preview = token.prefix(8) + dict["token"] = "\(preview)…" + } + + guard let pretty = try? JSONSerialization.data( + withJSONObject: dict, + options: [.prettyPrinted, .sortedKeys] + ) else { return nil } + return String(data: pretty, encoding: .utf8) + } + enum FileReaderError: LocalizedError { case fileTooLarge(UInt64) diff --git a/Shared/SyntaxHighlighter.swift b/Shared/SyntaxHighlighter.swift index 84839d9..3fd386a 100644 --- a/Shared/SyntaxHighlighter.swift +++ b/Shared/SyntaxHighlighter.swift @@ -11,7 +11,7 @@ enum FileFormat { init?(pathExtension: String) { switch pathExtension.lowercased() { - case "json", "ndjson", "jsonl": self = .json + case "json", "ndjson", "jsonl", "vpptoken": self = .json case "yaml", "yml": self = .yaml case "toml", "lock": self = .toml case "xml", "recipe": self = .xml From aef66029fbaff6f1571a33441a9c5fc4eda3bbdd Mon Sep 17 00:00:00 2001 From: Henry Stamerjohann Date: Mon, 27 Apr 2026 12:11:51 +0200 Subject: [PATCH 2/6] Add wrapping FlowLayout This should address #10 --- Pique/ContentView.swift | 66 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/Pique/ContentView.swift b/Pique/ContentView.swift index 191ad5c..04da329 100644 --- a/Pique/ContentView.swift +++ b/Pique/ContentView.swift @@ -31,7 +31,7 @@ struct ContentView: View { Text("QuickLook previews for config files") .foregroundStyle(.secondary) - HStack(spacing: 16) { + FlowLayout(spacing: 12, rowSpacing: 10) { ForEach(formats, id: \.0) { name, icon, color in Label(name, systemImage: icon) .font(.caption.bold()) @@ -39,8 +39,10 @@ struct ContentView: View { .padding(.vertical, 6) .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 6)) .foregroundStyle(color) + .fixedSize() } } + .frame(maxWidth: .infinity) Text("Select a supported file in Finder and press Space to preview.") .font(.callout) @@ -67,6 +69,68 @@ struct ContentView: View { } } +/// Wraps subviews onto multiple rows when they don't fit on one line. +/// Each subview keeps its natural width; rows wrap on overflow. +struct FlowLayout: Layout { + var spacing: CGFloat = 12 + var rowSpacing: CGFloat = 10 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let maxWidth = proposal.width ?? .infinity + let rows = computeRows(subviews: subviews, maxWidth: maxWidth) + let height = rows.map { $0.height }.reduce(0, +) + + CGFloat(max(0, rows.count - 1)) * rowSpacing + let widest = rows.map { $0.width }.max() ?? 0 + return CGSize(width: min(widest, maxWidth), height: height) + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let rows = computeRows(subviews: subviews, maxWidth: bounds.width) + var y = bounds.minY + for row in rows { + let xStart = bounds.minX + (bounds.width - row.width) / 2 + var x = xStart + for index in row.indices { + let size = subviews[index].sizeThatFits(.unspecified) + subviews[index].place( + at: CGPoint(x: x, y: y + (row.height - size.height) / 2), + anchor: .topLeading, + proposal: ProposedViewSize(size) + ) + x += size.width + spacing + } + y += row.height + rowSpacing + } + } + + private struct Row { + var indices: [Int] + var width: CGFloat + var height: CGFloat + } + + private func computeRows(subviews: Subviews, maxWidth: CGFloat) -> [Row] { + var rows: [Row] = [] + var current = Row(indices: [], width: 0, height: 0) + for (i, subview) in subviews.enumerated() { + let size = subview.sizeThatFits(.unspecified) + let projected = current.indices.isEmpty + ? size.width + : current.width + spacing + size.width + if projected > maxWidth, !current.indices.isEmpty { + rows.append(current) + current = Row(indices: [i], width: size.width, height: size.height) + } else { + current.indices.append(i) + current.width = projected + current.height = max(current.height, size.height) + } + } + if !current.indices.isEmpty { rows.append(current) } + return rows + } +} + #Preview { ContentView() } From b03432755f36c9d0d47911b7ef987ba342e3393e Mon Sep 17 00:00:00 2001 From: Henry Stamerjohann Date: Mon, 27 Apr 2026 13:06:31 +0200 Subject: [PATCH 3/6] Add VPP token chip, simplify layout and VPP decode Add a "VPP token" format chip and replace the custom FlowLayout with a simpler two-row approach (formatChipsRow + HStacks) to render format chips. Remove the FlowLayout implementation. Update FileReader.decodeVPPToken to pretty-print the decoded JSON directly (no longer redacting the token field). Also add *.profraw to .gitignore to ignore profiling output. --- .gitignore | 1 + Pique/ContentView.swift | 86 ++++++++--------------------------------- Shared/FileReader.swift | 11 +----- 3 files changed, 20 insertions(+), 78 deletions(-) diff --git a/.gitignore b/.gitignore index 406cdd6..3ed4fdf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Xcode build/ DerivedData/ +*.profraw *.xcuserdata *.xcworkspace/xcuserdata/ xcuserdata/ diff --git a/Pique/ContentView.swift b/Pique/ContentView.swift index 04da329..4f4ce89 100644 --- a/Pique/ContentView.swift +++ b/Pique/ContentView.swift @@ -14,6 +14,7 @@ struct ContentView: View { ("TOML", "doc.text", Color.blue), ("XML", "doc.text", Color.green), ("mobileconfig", "lock.doc", Color.red), + ("VPP token", "key.fill", Color.yellow), ("Shell", "terminal", Color.mint), ("Python", "chevron.left.forwardslash.chevron.right", Color.cyan), ("HCL", "doc.text", Color.indigo), @@ -31,18 +32,10 @@ struct ContentView: View { Text("QuickLook previews for config files") .foregroundStyle(.secondary) - FlowLayout(spacing: 12, rowSpacing: 10) { - ForEach(formats, id: \.0) { name, icon, color in - Label(name, systemImage: icon) - .font(.caption.bold()) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 6)) - .foregroundStyle(color) - .fixedSize() - } + VStack(spacing: 10) { + formatChipsRow(formats.prefix(5)) + formatChipsRow(formats.dropFirst(5)) } - .frame(maxWidth: .infinity) Text("Select a supported file in Finder and press Space to preview.") .font(.callout) @@ -67,67 +60,22 @@ struct ContentView: View { SettingsView() } } -} - -/// Wraps subviews onto multiple rows when they don't fit on one line. -/// Each subview keeps its natural width; rows wrap on overflow. -struct FlowLayout: Layout { - var spacing: CGFloat = 12 - var rowSpacing: CGFloat = 10 - - func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { - let maxWidth = proposal.width ?? .infinity - let rows = computeRows(subviews: subviews, maxWidth: maxWidth) - let height = rows.map { $0.height }.reduce(0, +) - + CGFloat(max(0, rows.count - 1)) * rowSpacing - let widest = rows.map { $0.width }.max() ?? 0 - return CGSize(width: min(widest, maxWidth), height: height) - } - - func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { - let rows = computeRows(subviews: subviews, maxWidth: bounds.width) - var y = bounds.minY - for row in rows { - let xStart = bounds.minX + (bounds.width - row.width) / 2 - var x = xStart - for index in row.indices { - let size = subviews[index].sizeThatFits(.unspecified) - subviews[index].place( - at: CGPoint(x: x, y: y + (row.height - size.height) / 2), - anchor: .topLeading, - proposal: ProposedViewSize(size) - ) - x += size.width + spacing - } - y += row.height + rowSpacing - } - } - - private struct Row { - var indices: [Int] - var width: CGFloat - var height: CGFloat - } - private func computeRows(subviews: Subviews, maxWidth: CGFloat) -> [Row] { - var rows: [Row] = [] - var current = Row(indices: [], width: 0, height: 0) - for (i, subview) in subviews.enumerated() { - let size = subview.sizeThatFits(.unspecified) - let projected = current.indices.isEmpty - ? size.width - : current.width + spacing + size.width - if projected > maxWidth, !current.indices.isEmpty { - rows.append(current) - current = Row(indices: [i], width: size.width, height: size.height) - } else { - current.indices.append(i) - current.width = projected - current.height = max(current.height, size.height) + @ViewBuilder + private func formatChipsRow(_ entries: S) -> some View + where S.Element == (String, String, Color) { + HStack(spacing: 12) { + ForEach(Array(entries), id: \.0) { name, icon, color in + Label(name, systemImage: icon) + .font(.caption.bold()) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(color.opacity(0.15), in: RoundedRectangle(cornerRadius: 6)) + .foregroundStyle(color) } } - if !current.indices.isEmpty { rows.append(current) } - return rows } } diff --git a/Shared/FileReader.swift b/Shared/FileReader.swift index 39691e2..feb66fa 100644 --- a/Shared/FileReader.swift +++ b/Shared/FileReader.swift @@ -106,21 +106,14 @@ enum FileReader { } /// Decodes a base64-wrapped VPP service token to pretty-printed JSON. - /// The `token` field is masked because it is a bearer credential. static func decodeVPPToken(_ data: Data) -> String? { let trimmed = decodeToString(data).trimmingCharacters(in: .whitespacesAndNewlines) guard let decoded = Data(base64Encoded: trimmed, options: .ignoreUnknownCharacters), - let object = try? JSONSerialization.jsonObject(with: decoded), - var dict = object as? [String: Any] + let object = try? JSONSerialization.jsonObject(with: decoded) else { return nil } - if let token = dict["token"] as? String, !token.isEmpty { - let preview = token.prefix(8) - dict["token"] = "\(preview)…" - } - guard let pretty = try? JSONSerialization.data( - withJSONObject: dict, + withJSONObject: object, options: [.prettyPrinted, .sortedKeys] ) else { return nil } return String(data: pretty, encoding: .utf8) From 7187a93db0cf9e1d5f3e71b4ce722e39294ef155 Mon Sep 17 00:00:00 2001 From: Henry Stamerjohann Date: Mon, 27 Apr 2026 14:21:31 +0200 Subject: [PATCH 4/6] Add initial autopkg .recipe.plist --- PiquePreview/PreviewProvider.swift | 23 +++- PiqueTests/FileFormatTests.swift | 6 +- PiqueTests/FileReaderTests.swift | 85 +++++++++++++++ Shared/FileReader.swift | 169 +++++++++++++++++++++++++++++ Shared/SyntaxHighlighter.swift | 4 +- 5 files changed, 278 insertions(+), 9 deletions(-) diff --git a/PiquePreview/PreviewProvider.swift b/PiquePreview/PreviewProvider.swift index d373e44..d2b4d37 100644 --- a/PiquePreview/PreviewProvider.swift +++ b/PiquePreview/PreviewProvider.swift @@ -34,7 +34,13 @@ class PreviewProvider: NSViewController, QLPreviewingController { func preparePreviewOfFile(at url: URL, completionHandler handler: @escaping (Error?) -> Void) { do { var data = try FileReader.readData(url: url) - let format = FileFormat(pathExtension: url.pathExtension) ?? .json + let extLower = url.pathExtension.lowercased() + let isAutoPkgRecipe = extLower == "recipe" + || url.lastPathComponent.lowercased().hasSuffix(".recipe.plist") + // Recipe overrides (.recipe.plist) keep .plist extension on disk but render as YAML. + let format: FileFormat = isAutoPkgRecipe + ? .yaml + : (FileFormat(pathExtension: url.pathExtension) ?? .json) // Step 1: Strip CMS signature from signed mobileconfig files if format == .mobileconfig, FileReader.isCMSEnvelope(data), @@ -42,11 +48,14 @@ class PreviewProvider: NSViewController, QLPreviewingController { data = inner } - // Step 2: Convert binary plist to XML text + // Step 2: Convert source bytes to displayable text let text: String - if url.pathExtension.lowercased() == "vpptoken", + if extLower == "vpptoken", let json = FileReader.decodeVPPToken(data) { text = json + } else if isAutoPkgRecipe, + let yaml = FileReader.convertRecipeToYAMLString(data) { + text = yaml } else if FileReader.isBinaryPlist(data), let xml = FileReader.convertBinaryPlistToXMLString(data) { text = xml @@ -54,7 +63,9 @@ class PreviewProvider: NSViewController, QLPreviewingController { text = FileReader.decodeToString(data) } - let formatName = PreviewProvider.formatName(for: url.pathExtension) + let formatName = isAutoPkgRecipe + ? "YAML" + : PreviewProvider.formatName(for: url.pathExtension) let isDark: Bool switch AppearanceSettings.override(forFormat: formatName) { case .system: @@ -141,9 +152,9 @@ class PreviewProvider: NSViewController, QLPreviewingController { private static func formatName(for ext: String) -> String { switch ext.lowercased() { case "json", "ndjson", "jsonl", "vpptoken": return "JSON" - case "yaml", "yml": return "YAML" + case "yaml", "yml", "recipe": return "YAML" case "toml", "lock": return "TOML" - case "xml", "recipe": return "XML" + case "xml": return "XML" case "mobileconfig", "plist": return "mobileconfig" case "sh", "bash", "zsh", "ksh", "dash", "rc", "command": return "Shell" case "ps1", "psm1", "psd1": return "PowerShell" diff --git a/PiqueTests/FileFormatTests.swift b/PiqueTests/FileFormatTests.swift index 39fd41b..f229dcc 100644 --- a/PiqueTests/FileFormatTests.swift +++ b/PiqueTests/FileFormatTests.swift @@ -24,7 +24,11 @@ final class FileFormatTests: XCTestCase { func testXML() { XCTAssertEqual(FileFormat(pathExtension: "xml"), .xml) - XCTAssertEqual(FileFormat(pathExtension: "recipe"), .xml) + } + + func testRecipeMapsToYAML() { + // .recipe files are XML plists on disk but rendered as YAML for readability + XCTAssertEqual(FileFormat(pathExtension: "recipe"), .yaml) } func testMobileconfig() { diff --git a/PiqueTests/FileReaderTests.swift b/PiqueTests/FileReaderTests.swift index 6787fe0..f302903 100644 --- a/PiqueTests/FileReaderTests.swift +++ b/PiqueTests/FileReaderTests.swift @@ -199,4 +199,89 @@ final class FileReaderTests: XCTestCase { XCTAssertTrue(xmlString!.contains("PayloadType")) XCTAssertTrue(xmlString!.contains("Configuration")) } + + // MARK: - YAML emitter + + func testYAMLEmitsScalarString() { + XCTAssertEqual(YAMLEmitter.emit("hello"), "hello\n") + } + + func testYAMLQuotesNumberLikeString() { + XCTAssertEqual(YAMLEmitter.emit("1.0"), "\"1.0\"\n") + } + + func testYAMLQuotesBoolLikeString() { + XCTAssertEqual(YAMLEmitter.emit("yes"), "\"yes\"\n") + XCTAssertEqual(YAMLEmitter.emit("Off"), "\"Off\"\n") + } + + func testYAMLEmitsBoolsFromNSNumber() { + XCTAssertEqual(YAMLEmitter.emit(NSNumber(value: true)), "true\n") + XCTAssertEqual(YAMLEmitter.emit(NSNumber(value: false)), "false\n") + } + + func testYAMLEmitsIntegers() { + XCTAssertEqual(YAMLEmitter.emit(NSNumber(value: 42)), "42\n") + } + + func testYAMLEmitsMultilineStringAsBlockScalar() { + let out = YAMLEmitter.emit(["Description": "Line one\n\nLine three"]) + XCTAssertTrue(out.contains("Description: |-"), "expected block scalar; got:\n\(out)") + XCTAssertTrue(out.contains("Line one")) + XCTAssertTrue(out.contains("Line three")) + XCTAssertFalse(out.contains("\\n")) // never escape inside block scalar + } + + func testYAMLEmitsNestedDict() { + let out = YAMLEmitter.emit(["Input": ["NAME": "1Password CLI"]]) + XCTAssertTrue(out.contains("Input:\n")) + XCTAssertTrue(out.contains(" NAME: 1Password CLI")) + } + + func testYAMLEmitsArrayOfDicts() { + let plist: [String: Any] = [ + "Process": [ + ["Processor": "URLDownloader"], + ["Processor": "EndOfCheckPhase"], + ] + ] + let out = YAMLEmitter.emit(plist) + XCTAssertTrue(out.contains("Process:\n")) + XCTAssertTrue(out.contains(" - Processor: URLDownloader")) + XCTAssertTrue(out.contains(" - Processor: EndOfCheckPhase")) + } + + func testYAMLRecipeKeyOrderingPutsDescriptionFirst() { + let plist: [String: Any] = [ + "Process": [], + "Identifier": "com.example.recipe", + "Description": "Test", + ] + let out = YAMLEmitter.emit(plist, recipe: true) + let descIdx = out.range(of: "Description")!.lowerBound + let identIdx = out.range(of: "Identifier")!.lowerBound + let procIdx = out.range(of: "Process")!.lowerBound + XCTAssertLessThan(descIdx, identIdx) + XCTAssertLessThan(identIdx, procIdx) + } + + func testRecipeDataConvertsToYAML() { + let xml = """ + + + + Identifiercom.example.foo + Process + + ProcessorURLDownloader + + + + """ + let yaml = FileReader.convertRecipeToYAMLString(xml.data(using: .utf8)!) + XCTAssertNotNil(yaml) + XCTAssertTrue(yaml!.contains("Identifier: com.example.foo")) + XCTAssertTrue(yaml!.contains("Process:")) + XCTAssertTrue(yaml!.contains("- Processor: URLDownloader")) + } } diff --git a/Shared/FileReader.swift b/Shared/FileReader.swift index feb66fa..4df8bed 100644 --- a/Shared/FileReader.swift +++ b/Shared/FileReader.swift @@ -119,6 +119,16 @@ enum FileReader { return String(data: pretty, encoding: .utf8) } + // MARK: - AutoPkg recipe (XML plist → YAML) + + /// Parses an XML plist recipe and emits a YAML representation for preview. + /// Returns nil when the data isn't a valid plist. + static func convertRecipeToYAMLString(_ data: Data) -> String? { + guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) + else { return nil } + return YAMLEmitter.emit(plist, recipe: true) + } + enum FileReaderError: LocalizedError { case fileTooLarge(UInt64) @@ -131,3 +141,162 @@ enum FileReader { } } } + +// MARK: - YAML emitter + +/// Emits a Foundation property list (`[String: Any]`, `[Any]`, `String`, `NSNumber`, `Date`, `Data`) +/// as YAML 1.2 text. Tailored for AutoPkg recipe presentation: literal block scalars for multi-line +/// strings, AutoPkg-canonical key order for the top-level recipe dict, and stable alphabetical +/// ordering elsewhere. +enum YAMLEmitter { + /// Top-level keys appear in this order when `recipe` is true. Anything not listed + /// falls through to alphabetical order. + static let recipeKeyOrder = [ + "Description", "Identifier", "ParentRecipe", "MinimumVersion", "Input", "Process" + ] + + static func emit(_ root: Any, recipe: Bool = false) -> String { + var out = "" + write(root, indent: 0, leadOnSameLine: false, recipeRoot: recipe, into: &out) + if !out.hasSuffix("\n") { out += "\n" } + return out + } + + private static func write(_ value: Any, indent: Int, leadOnSameLine: Bool, recipeRoot: Bool, into out: inout String) { + switch value { + case let dict as [String: Any]: + writeDict(dict, indent: indent, leadOnSameLine: leadOnSameLine, recipeRoot: recipeRoot, into: &out) + case let arr as [Any]: + writeArray(arr, indent: indent, into: &out) + case let str as String: + writeString(str, indent: indent, into: &out) + case let num as NSNumber: + writeNumber(num, into: &out) + case let date as Date: + let fmt = ISO8601DateFormatter() + fmt.formatOptions = [.withInternetDateTime] + out += fmt.string(from: date) + case let data as Data: + out += "!!binary " + data.base64EncodedString() + default: + out += "null" + } + } + + private static func writeDict(_ dict: [String: Any], indent: Int, leadOnSameLine: Bool, recipeRoot: Bool, into out: inout String) { + if dict.isEmpty { + out += "{}\n" + return + } + let pad = String(repeating: " ", count: indent) + let keys = orderedKeys(Array(dict.keys), preferredFirst: recipeRoot ? recipeKeyOrder : []) + for (i, key) in keys.enumerated() { + let keyPrefix = (i == 0 && leadOnSameLine) ? "" : pad + let keyStr = formatScalarKey(key) + let value = dict[key]! + if let str = value as? String, str.contains("\n") { + out += "\(keyPrefix)\(keyStr): " + writeString(str, indent: indent + 1, into: &out) + } else if isInlineScalar(value) { + out += "\(keyPrefix)\(keyStr): " + write(value, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) + out += "\n" + } else if let arr = value as? [Any], arr.isEmpty { + out += "\(keyPrefix)\(keyStr): []\n" + } else if let d = value as? [String: Any], d.isEmpty { + out += "\(keyPrefix)\(keyStr): {}\n" + } else { + out += "\(keyPrefix)\(keyStr):\n" + write(value, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) + } + } + } + + private static func writeArray(_ arr: [Any], indent: Int, into out: inout String) { + if arr.isEmpty { + out += String(repeating: " ", count: indent) + "[]\n" + return + } + let pad = String(repeating: " ", count: indent) + for item in arr { + if let dict = item as? [String: Any], !dict.isEmpty { + out += "\(pad)- " + writeDict(dict, indent: indent + 1, leadOnSameLine: true, recipeRoot: false, into: &out) + } else if isInlineScalar(item) { + out += "\(pad)- " + write(item, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) + out += "\n" + } else { + out += "\(pad)-\n" + write(item, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) + } + } + } + + /// Multi-line strings render as a `|-` literal block scalar (preserves newlines, strips + /// trailing blank lines). Single-line strings render plain unless they need quoting. + private static func writeString(_ str: String, indent: Int, into out: inout String) { + if str.contains("\n") { + let pad = String(repeating: " ", count: indent) + out += "|-\n" + let lines = str.split(separator: "\n", omittingEmptySubsequences: false) + for line in lines { + out += line.isEmpty ? "\n" : "\(pad)\(line)\n" + } + return + } + if needsQuoting(str) { + out += "\"\(escapeForDoubleQuoted(str))\"" + } else { + out += str + } + } + + private static func writeNumber(_ num: NSNumber, into out: inout String) { + if CFGetTypeID(num) == CFBooleanGetTypeID() { + out += num.boolValue ? "true" : "false" + } else { + out += "\(num)" + } + } + + private static func formatScalarKey(_ key: String) -> String { + needsQuoting(key) ? "\"\(escapeForDoubleQuoted(key))\"" : key + } + + private static func isInlineScalar(_ v: Any) -> Bool { + switch v { + case is NSNumber, is Date, is Data: return true + case let s as String: return !s.contains("\n") + default: return false + } + } + + /// Strings matching YAML reserved tokens, looking like numbers, or containing + /// structural characters need double-quoting to round-trip correctly. + private static func needsQuoting(_ str: String) -> Bool { + if str.isEmpty { return true } + if let first = str.first, "!&*-?{[}]|>%@`#,\"'".contains(first) { return true } + if str.first?.isWhitespace == true || str.last?.isWhitespace == true { return true } + if str.contains(": ") || str.contains(" #") { return true } + let reserved = #"^(true|false|yes|no|on|off|null|~|-?\d+(\.\d+)?([eE][+-]?\d+)?)$"# + if str.range(of: reserved, options: [.regularExpression, .caseInsensitive]) != nil { + return true + } + return false + } + + private static func escapeForDoubleQuoted(_ str: String) -> String { + str.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + .replacingOccurrences(of: "\n", with: "\\n") + .replacingOccurrences(of: "\t", with: "\\t") + } + + private static func orderedKeys(_ keys: [String], preferredFirst: [String]) -> [String] { + let keySet = Set(keys) + let preferred = preferredFirst.filter { keySet.contains($0) } + let remaining = keys.filter { !preferred.contains($0) }.sorted() + return preferred + remaining + } +} diff --git a/Shared/SyntaxHighlighter.swift b/Shared/SyntaxHighlighter.swift index 3fd386a..06dbcfd 100644 --- a/Shared/SyntaxHighlighter.swift +++ b/Shared/SyntaxHighlighter.swift @@ -12,9 +12,9 @@ enum FileFormat { init?(pathExtension: String) { switch pathExtension.lowercased() { case "json", "ndjson", "jsonl", "vpptoken": self = .json - case "yaml", "yml": self = .yaml + case "yaml", "yml", "recipe": self = .yaml case "toml", "lock": self = .toml - case "xml", "recipe": self = .xml + case "xml": self = .xml case "mobileconfig", "plist": self = .mobileconfig case "sh", "bash", "zsh", "ksh", "dash", "rc", "command": self = .shell case "ps1", "psm1", "psd1": self = .powershell From 8e993b1bac23718d4dd1ce7f164b8fb63e73a7ae Mon Sep 17 00:00:00 2001 From: Henry Stamerjohann Date: Mon, 27 Apr 2026 17:01:07 +0200 Subject: [PATCH 5/6] Add improved AutoPKg visualization --- PiquePreview/PreviewProvider.swift | 15 +- PiqueTests/FileFormatTests.swift | 6 +- PiqueTests/FileReaderTests.swift | 132 +++++----- Shared/FileReader.swift | 168 ------------ Shared/SyntaxHighlighter.swift | 406 ++++++++++++++++++++++++++++- 5 files changed, 475 insertions(+), 252 deletions(-) diff --git a/PiquePreview/PreviewProvider.swift b/PiquePreview/PreviewProvider.swift index d2b4d37..f18e963 100644 --- a/PiquePreview/PreviewProvider.swift +++ b/PiquePreview/PreviewProvider.swift @@ -35,11 +35,15 @@ class PreviewProvider: NSViewController, QLPreviewingController { do { var data = try FileReader.readData(url: url) let extLower = url.pathExtension.lowercased() + let nameLower = url.lastPathComponent.lowercased() + // .recipe (XML), .recipe.plist (override), and .recipe.yaml (YAML recipe) + // all flow through the structured renderer. Pathological extensions like + // .yaml/.plist alone are NOT treated as recipes — the suffix matters. let isAutoPkgRecipe = extLower == "recipe" - || url.lastPathComponent.lowercased().hasSuffix(".recipe.plist") - // Recipe overrides (.recipe.plist) keep .plist extension on disk but render as YAML. + || nameLower.hasSuffix(".recipe.plist") + || nameLower.hasSuffix(".recipe.yaml") let format: FileFormat = isAutoPkgRecipe - ? .yaml + ? .recipe : (FileFormat(pathExtension: url.pathExtension) ?? .json) // Step 1: Strip CMS signature from signed mobileconfig files @@ -53,9 +57,6 @@ class PreviewProvider: NSViewController, QLPreviewingController { if extLower == "vpptoken", let json = FileReader.decodeVPPToken(data) { text = json - } else if isAutoPkgRecipe, - let yaml = FileReader.convertRecipeToYAMLString(data) { - text = yaml } else if FileReader.isBinaryPlist(data), let xml = FileReader.convertBinaryPlistToXMLString(data) { text = xml @@ -64,7 +65,7 @@ class PreviewProvider: NSViewController, QLPreviewingController { } let formatName = isAutoPkgRecipe - ? "YAML" + ? "AutoPkg" : PreviewProvider.formatName(for: url.pathExtension) let isDark: Bool switch AppearanceSettings.override(forFormat: formatName) { diff --git a/PiqueTests/FileFormatTests.swift b/PiqueTests/FileFormatTests.swift index f229dcc..19af558 100644 --- a/PiqueTests/FileFormatTests.swift +++ b/PiqueTests/FileFormatTests.swift @@ -26,9 +26,9 @@ final class FileFormatTests: XCTestCase { XCTAssertEqual(FileFormat(pathExtension: "xml"), .xml) } - func testRecipeMapsToYAML() { - // .recipe files are XML plists on disk but rendered as YAML for readability - XCTAssertEqual(FileFormat(pathExtension: "recipe"), .yaml) + func testRecipeIsItsOwnFormat() { + // .recipe files are XML plists but get a custom structured renderer + XCTAssertEqual(FileFormat(pathExtension: "recipe"), .recipe) } func testMobileconfig() { diff --git a/PiqueTests/FileReaderTests.swift b/PiqueTests/FileReaderTests.swift index f302903..c164d91 100644 --- a/PiqueTests/FileReaderTests.swift +++ b/PiqueTests/FileReaderTests.swift @@ -200,88 +200,80 @@ final class FileReaderTests: XCTestCase { XCTAssertTrue(xmlString!.contains("Configuration")) } - // MARK: - YAML emitter - - func testYAMLEmitsScalarString() { - XCTAssertEqual(YAMLEmitter.emit("hello"), "hello\n") - } - - func testYAMLQuotesNumberLikeString() { - XCTAssertEqual(YAMLEmitter.emit("1.0"), "\"1.0\"\n") - } - - func testYAMLQuotesBoolLikeString() { - XCTAssertEqual(YAMLEmitter.emit("yes"), "\"yes\"\n") - XCTAssertEqual(YAMLEmitter.emit("Off"), "\"Off\"\n") - } - - func testYAMLEmitsBoolsFromNSNumber() { - XCTAssertEqual(YAMLEmitter.emit(NSNumber(value: true)), "true\n") - XCTAssertEqual(YAMLEmitter.emit(NSNumber(value: false)), "false\n") + // MARK: - Recipe YAML parser + + func testRecipeYAMLParsesScalarsAndNestedMapping() { + let yaml = """ + Identifier: com.example.foo + Description: Hello world + Input: + NAME: Foo + """ + let dict = RecipeYAMLParser.parse(yaml) + XCTAssertEqual(dict?["Identifier"] as? String, "com.example.foo") + XCTAssertEqual(dict?["Description"] as? String, "Hello world") + XCTAssertEqual((dict?["Input"] as? [String: Any])?["NAME"] as? String, "Foo") } - func testYAMLEmitsIntegers() { - XCTAssertEqual(YAMLEmitter.emit(NSNumber(value: 42)), "42\n") + func testRecipeYAMLParsesSequenceAtSameIndentAsParent() { + // recipe-robot emits Process: with `- ` at column 0; this is the bug + // that existed in v1 of the parser. + let yaml = """ + Process: + - Processor: Alpha + - Processor: Beta + """ + let dict = RecipeYAMLParser.parse(yaml) + let process = dict?["Process"] as? [Any] + XCTAssertEqual(process?.count, 2) + XCTAssertEqual((process?[0] as? [String: Any])?["Processor"] as? String, "Alpha") + XCTAssertEqual((process?[1] as? [String: Any])?["Processor"] as? String, "Beta") } - func testYAMLEmitsMultilineStringAsBlockScalar() { - let out = YAMLEmitter.emit(["Description": "Line one\n\nLine three"]) - XCTAssertTrue(out.contains("Description: |-"), "expected block scalar; got:\n\(out)") - XCTAssertTrue(out.contains("Line one")) - XCTAssertTrue(out.contains("Line three")) - XCTAssertFalse(out.contains("\\n")) // never escape inside block scalar + func testRecipeYAMLParsesProcessStepWithArguments() { + let yaml = """ + Process: + - Processor: AppPkgCreator + Arguments: + app_path: '%RECIPE_CACHE_DIR%/%NAME%.app' + """ + let dict = RecipeYAMLParser.parse(yaml) + let step = (dict?["Process"] as? [Any])?.first as? [String: Any] + XCTAssertEqual(step?["Processor"] as? String, "AppPkgCreator") + let args = step?["Arguments"] as? [String: Any] + XCTAssertEqual(args?["app_path"] as? String, "%RECIPE_CACHE_DIR%/%NAME%.app") } - func testYAMLEmitsNestedDict() { - let out = YAMLEmitter.emit(["Input": ["NAME": "1Password CLI"]]) - XCTAssertTrue(out.contains("Input:\n")) - XCTAssertTrue(out.contains(" NAME: 1Password CLI")) + func testRecipeYAMLSingleQuotedNumberStaysString() { + let dict = RecipeYAMLParser.parse("MinimumVersion: '2.3'") + // Single-quoted '2.3' must be preserved as a string, not converted to 2.3 + XCTAssertEqual(dict?["MinimumVersion"] as? String, "2.3") } - func testYAMLEmitsArrayOfDicts() { - let plist: [String: Any] = [ - "Process": [ - ["Processor": "URLDownloader"], - ["Processor": "EndOfCheckPhase"], - ] - ] - let out = YAMLEmitter.emit(plist) - XCTAssertTrue(out.contains("Process:\n")) - XCTAssertTrue(out.contains(" - Processor: URLDownloader")) - XCTAssertTrue(out.contains(" - Processor: EndOfCheckPhase")) + func testRecipeYAMLBareNumberBecomesNumber() { + let dict = RecipeYAMLParser.parse("MinimumVersion: 2.3") + XCTAssertEqual(dict?["MinimumVersion"] as? Double, 2.3) } - func testYAMLRecipeKeyOrderingPutsDescriptionFirst() { - let plist: [String: Any] = [ - "Process": [], - "Identifier": "com.example.recipe", - "Description": "Test", - ] - let out = YAMLEmitter.emit(plist, recipe: true) - let descIdx = out.range(of: "Description")!.lowerBound - let identIdx = out.range(of: "Identifier")!.lowerBound - let procIdx = out.range(of: "Process")!.lowerBound - XCTAssertLessThan(descIdx, identIdx) - XCTAssertLessThan(identIdx, procIdx) + func testRecipeYAMLIgnoresLineComments() { + let yaml = """ + # heading comment + Identifier: com.example.foo # trailing comment + """ + let dict = RecipeYAMLParser.parse(yaml) + XCTAssertEqual(dict?["Identifier"] as? String, "com.example.foo") } - func testRecipeDataConvertsToYAML() { - let xml = """ - - - - Identifiercom.example.foo - Process - - ProcessorURLDownloader - - - + func testRecipeYAMLProcessStepWithoutArguments() { + // Raycast.pkg.recipe.yaml has `- Processor: AppPkgCreator` with no Arguments + let yaml = """ + Process: + - Processor: AppPkgCreator """ - let yaml = FileReader.convertRecipeToYAMLString(xml.data(using: .utf8)!) - XCTAssertNotNil(yaml) - XCTAssertTrue(yaml!.contains("Identifier: com.example.foo")) - XCTAssertTrue(yaml!.contains("Process:")) - XCTAssertTrue(yaml!.contains("- Processor: URLDownloader")) + let dict = RecipeYAMLParser.parse(yaml) + let step = (dict?["Process"] as? [Any])?.first as? [String: Any] + XCTAssertEqual(step?["Processor"] as? String, "AppPkgCreator") + XCTAssertNil(step?["Arguments"]) } + } diff --git a/Shared/FileReader.swift b/Shared/FileReader.swift index 4df8bed..9935d5d 100644 --- a/Shared/FileReader.swift +++ b/Shared/FileReader.swift @@ -119,16 +119,6 @@ enum FileReader { return String(data: pretty, encoding: .utf8) } - // MARK: - AutoPkg recipe (XML plist → YAML) - - /// Parses an XML plist recipe and emits a YAML representation for preview. - /// Returns nil when the data isn't a valid plist. - static func convertRecipeToYAMLString(_ data: Data) -> String? { - guard let plist = try? PropertyListSerialization.propertyList(from: data, format: nil) - else { return nil } - return YAMLEmitter.emit(plist, recipe: true) - } - enum FileReaderError: LocalizedError { case fileTooLarge(UInt64) @@ -142,161 +132,3 @@ enum FileReader { } } -// MARK: - YAML emitter - -/// Emits a Foundation property list (`[String: Any]`, `[Any]`, `String`, `NSNumber`, `Date`, `Data`) -/// as YAML 1.2 text. Tailored for AutoPkg recipe presentation: literal block scalars for multi-line -/// strings, AutoPkg-canonical key order for the top-level recipe dict, and stable alphabetical -/// ordering elsewhere. -enum YAMLEmitter { - /// Top-level keys appear in this order when `recipe` is true. Anything not listed - /// falls through to alphabetical order. - static let recipeKeyOrder = [ - "Description", "Identifier", "ParentRecipe", "MinimumVersion", "Input", "Process" - ] - - static func emit(_ root: Any, recipe: Bool = false) -> String { - var out = "" - write(root, indent: 0, leadOnSameLine: false, recipeRoot: recipe, into: &out) - if !out.hasSuffix("\n") { out += "\n" } - return out - } - - private static func write(_ value: Any, indent: Int, leadOnSameLine: Bool, recipeRoot: Bool, into out: inout String) { - switch value { - case let dict as [String: Any]: - writeDict(dict, indent: indent, leadOnSameLine: leadOnSameLine, recipeRoot: recipeRoot, into: &out) - case let arr as [Any]: - writeArray(arr, indent: indent, into: &out) - case let str as String: - writeString(str, indent: indent, into: &out) - case let num as NSNumber: - writeNumber(num, into: &out) - case let date as Date: - let fmt = ISO8601DateFormatter() - fmt.formatOptions = [.withInternetDateTime] - out += fmt.string(from: date) - case let data as Data: - out += "!!binary " + data.base64EncodedString() - default: - out += "null" - } - } - - private static func writeDict(_ dict: [String: Any], indent: Int, leadOnSameLine: Bool, recipeRoot: Bool, into out: inout String) { - if dict.isEmpty { - out += "{}\n" - return - } - let pad = String(repeating: " ", count: indent) - let keys = orderedKeys(Array(dict.keys), preferredFirst: recipeRoot ? recipeKeyOrder : []) - for (i, key) in keys.enumerated() { - let keyPrefix = (i == 0 && leadOnSameLine) ? "" : pad - let keyStr = formatScalarKey(key) - let value = dict[key]! - if let str = value as? String, str.contains("\n") { - out += "\(keyPrefix)\(keyStr): " - writeString(str, indent: indent + 1, into: &out) - } else if isInlineScalar(value) { - out += "\(keyPrefix)\(keyStr): " - write(value, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) - out += "\n" - } else if let arr = value as? [Any], arr.isEmpty { - out += "\(keyPrefix)\(keyStr): []\n" - } else if let d = value as? [String: Any], d.isEmpty { - out += "\(keyPrefix)\(keyStr): {}\n" - } else { - out += "\(keyPrefix)\(keyStr):\n" - write(value, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) - } - } - } - - private static func writeArray(_ arr: [Any], indent: Int, into out: inout String) { - if arr.isEmpty { - out += String(repeating: " ", count: indent) + "[]\n" - return - } - let pad = String(repeating: " ", count: indent) - for item in arr { - if let dict = item as? [String: Any], !dict.isEmpty { - out += "\(pad)- " - writeDict(dict, indent: indent + 1, leadOnSameLine: true, recipeRoot: false, into: &out) - } else if isInlineScalar(item) { - out += "\(pad)- " - write(item, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) - out += "\n" - } else { - out += "\(pad)-\n" - write(item, indent: indent + 1, leadOnSameLine: false, recipeRoot: false, into: &out) - } - } - } - - /// Multi-line strings render as a `|-` literal block scalar (preserves newlines, strips - /// trailing blank lines). Single-line strings render plain unless they need quoting. - private static func writeString(_ str: String, indent: Int, into out: inout String) { - if str.contains("\n") { - let pad = String(repeating: " ", count: indent) - out += "|-\n" - let lines = str.split(separator: "\n", omittingEmptySubsequences: false) - for line in lines { - out += line.isEmpty ? "\n" : "\(pad)\(line)\n" - } - return - } - if needsQuoting(str) { - out += "\"\(escapeForDoubleQuoted(str))\"" - } else { - out += str - } - } - - private static func writeNumber(_ num: NSNumber, into out: inout String) { - if CFGetTypeID(num) == CFBooleanGetTypeID() { - out += num.boolValue ? "true" : "false" - } else { - out += "\(num)" - } - } - - private static func formatScalarKey(_ key: String) -> String { - needsQuoting(key) ? "\"\(escapeForDoubleQuoted(key))\"" : key - } - - private static func isInlineScalar(_ v: Any) -> Bool { - switch v { - case is NSNumber, is Date, is Data: return true - case let s as String: return !s.contains("\n") - default: return false - } - } - - /// Strings matching YAML reserved tokens, looking like numbers, or containing - /// structural characters need double-quoting to round-trip correctly. - private static func needsQuoting(_ str: String) -> Bool { - if str.isEmpty { return true } - if let first = str.first, "!&*-?{[}]|>%@`#,\"'".contains(first) { return true } - if str.first?.isWhitespace == true || str.last?.isWhitespace == true { return true } - if str.contains(": ") || str.contains(" #") { return true } - let reserved = #"^(true|false|yes|no|on|off|null|~|-?\d+(\.\d+)?([eE][+-]?\d+)?)$"# - if str.range(of: reserved, options: [.regularExpression, .caseInsensitive]) != nil { - return true - } - return false - } - - private static func escapeForDoubleQuoted(_ str: String) -> String { - str.replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - .replacingOccurrences(of: "\n", with: "\\n") - .replacingOccurrences(of: "\t", with: "\\t") - } - - private static func orderedKeys(_ keys: [String], preferredFirst: [String]) -> [String] { - let keySet = Set(keys) - let preferred = preferredFirst.filter { keySet.contains($0) } - let remaining = keys.filter { !preferred.contains($0) }.sorted() - return preferred + remaining - } -} diff --git a/Shared/SyntaxHighlighter.swift b/Shared/SyntaxHighlighter.swift index 06dbcfd..425f315 100644 --- a/Shared/SyntaxHighlighter.swift +++ b/Shared/SyntaxHighlighter.swift @@ -6,13 +6,14 @@ import Foundation enum FileFormat { - case json, yaml, toml, xml, mobileconfig, shell, powershell, python, ruby, go, rust, javascript, - markdown, hcl + case json, yaml, toml, xml, mobileconfig, recipe, shell, powershell, python, ruby, go, rust, + javascript, markdown, hcl init?(pathExtension: String) { switch pathExtension.lowercased() { case "json", "ndjson", "jsonl", "vpptoken": self = .json - case "yaml", "yml", "recipe": self = .yaml + case "yaml", "yml": self = .yaml + case "recipe": self = .recipe case "toml", "lock": self = .toml case "xml": self = .xml case "mobileconfig", "plist": self = .mobileconfig @@ -37,6 +38,11 @@ enum SyntaxHighlighter { return html } } + if format == .recipe, let data = source.data(using: .utf8) { + if let html = renderRecipe(data, dark: darkMode) { + return html + } + } if format == .json, let data = source.data(using: .utf8), let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], isAppleConfigProfile(json) @@ -50,7 +56,7 @@ enum SyntaxHighlighter { case .json: tokens = tokenizeJSON(source) case .yaml: tokens = tokenizeYAML(source) case .toml: tokens = tokenizeTOML(source) - case .xml, .mobileconfig: tokens = tokenizeXML(source) + case .xml, .mobileconfig, .recipe: tokens = tokenizeXML(source) case .shell: tokens = tokenizeShell(source) case .powershell: tokens = tokenizePowerShell(source) case .python: tokens = tokenizePython(source) @@ -293,6 +299,173 @@ enum SyntaxHighlighter { return wrapMobileconfigHTML(h, t: t) } + // MARK: - AutoPkg Recipe Renderer + + /// Dispatches recipe rendering based on whether the file is XML plist or YAML. + private static func renderRecipe(_ data: Data, dark: Bool) -> String? { + guard let rawSource = String(data: data, encoding: .utf8) else { return nil } + let head = rawSource.prefix(256).lowercased() + let isYAML = !head.contains(" String { + let t = dark ? Theme.dark : Theme.light + var h = "" + + let identifier = plist["Identifier"] as? String ?? "Untitled Recipe" + let description = plist["Description"] as? String + let parentRecipe = plist["ParentRecipe"] as? String + let minVersion = plist["MinimumVersion"] as? String + let input = plist["Input"] as? [String: Any] ?? [:] + let process = plist["Process"] as? [[String: Any]] ?? [] + let trustInfo = plist["ParentRecipeTrustInfo"] as? [String: Any] + let isOverride = trustInfo != nil + + let (typeText, typeColor) = recipeTypeBadge(identifier: identifier, isOverride: isOverride, t: t) + + // Display name: prefer Input.NAME, fallback to last segment of Identifier. + let displayName = (input["NAME"] as? String).flatMap { $0.isEmpty ? nil : $0 } + ?? identifier.split(separator: ".").last.map(String.init) + ?? identifier + + // ── Header card ── + h += "" + h += "
" + h += "\(typeText.uppercased())
" + h += "\(esc(displayName))" + h += "
\(esc(identifier))" + if let desc = description, !desc.isEmpty { + let descHTML = esc(desc).replacingOccurrences(of: "\n", with: "
") + h += "

\(descHTML)" + } + h += "
" + + // ── Recipe metadata (Parent + MinimumVersion) ── + var metaRows: [(String, String)] = [] + if let p = parentRecipe { + metaRows.append(("Parent Recipe", + "\(esc(p))")) + } + if let m = minVersion { + metaRows.append(("Minimum Version", + "\(esc(m))")) + } + if !metaRows.isEmpty { + h += sectionHeader("Recipe", t: t) + h += groupStart(t) + for (i, row) in metaRows.enumerated() { + h += cellRow(row.0, row.1, t: t, last: i == metaRows.count - 1) + } + h += groupEnd() + } + + // ── Input variables ── + if !input.isEmpty { + h += sectionHeader("Input", t: t) + renderRecipeDictGroup(input, t: t, into: &h) + } + + // ── Process steps ── + if !process.isEmpty { + h += sectionHeader("Process", t: t) + for (idx, step) in process.enumerated() { + let processor = step["Processor"] as? String ?? "Unknown Processor" + let arguments = step["Arguments"] as? [String: Any] ?? [:] + + h += "" + h += "
" + h += "STEP \(idx + 1)
" + h += "\(esc(processor))" + h += "
" + + if !arguments.isEmpty { + renderRecipeDictGroup(arguments, t: t, into: &h) + } + } + } + + // ── Trust info (overrides only) ── + if let trust = trustInfo, !trust.isEmpty { + h += sectionHeader("Parent Recipe Trust Info", t: t) + h += renderComplexValue(trust, t: t) + } + + // ── Source panel (XML or YAML) ── + let sourceLabel = isYAML ? "YAML SOURCE" : "XML SOURCE" + h += "" + h += "" + h += "
\(thinLine(t))
" + h += "\(sourceLabel)" + h += "
" + h += "" + h += "
" + let sourceTokens = isYAML ? tokenizeYAML(rawSource) : tokenizeXML(rawSource) + h += "
\(renderTokens(sourceTokens))
" + h += "
" + + return wrapMobileconfigHTML(h, t: t) + } + + /// Renders a dict as a table of rows, splitting simple/complex values like + /// the mobileconfig settings group does. + private static func renderRecipeDictGroup(_ dict: [String: Any], t: Theme, into h: inout String) { + let keys = dict.keys.sorted() + let simple = keys.filter { isSimple(dict[$0]!) && !isLongString(dict[$0]!) } + let complex = keys.filter { !isSimple(dict[$0]!) || isLongString(dict[$0]!) } + + if !simple.isEmpty { + h += groupStart(t) + for (i, k) in simple.enumerated() { + h += cellRow(k, inlineValue(dict[k]!, key: k, t: t), + t: t, last: i == simple.count - 1) + } + h += groupEnd() + } + for k in complex { + h += sectionHeader(k, t: t) + if let str = dict[k] as? String, str.contains("\n") { + // Multi-line scripts/strings get a Menlo block treatment + h += "" + h += "
" + h += "
\(esc(str))
" + h += "
" + } else { + h += renderComplexValue(dict[k]!, t: t) + } + } + } + + /// Type badge based on the recipe identifier (e.g. `com.x.download.Foo` → "Download Recipe"). + /// Overrides are detected by the presence of `ParentRecipeTrustInfo`. + private static func recipeTypeBadge(identifier: String, isOverride: Bool, t: Theme) -> (String, String) { + if isOverride { + return ("Recipe Override", t.scopeUser) + } + let parts = identifier.lowercased().split(separator: ".").map(String.init) + if parts.contains("download") { return ("Download Recipe", t.scopeDevice) } + if parts.contains("pkg") { return ("Pkg Recipe", t.scopeUser) } + if parts.contains("munki") { return ("Munki Recipe", "#10b981") } + if parts.contains("install") { return ("Install Recipe", "#a855f7") } + if parts.contains("jamf") || parts.contains("jss") { + return ("Jamf Recipe", "#ef4444") + } + if parts.contains("intune") { return ("Intune Recipe", "#0ea5e9") } + return ("AutoPkg Recipe", t.muted) + } + // MARK: - JSON Profile Detection & Renderer /// Check if a JSON dictionary looks like an Apple configuration profile @@ -1513,3 +1686,228 @@ enum SyntaxHighlighter { """ } } + +// MARK: - Recipe YAML parser + +/// Parses a constrained YAML subset suitable for AutoPkg recipe files. +/// Supports: block-style mappings, block-style sequences of mappings or scalars, +/// plain scalars, single/double-quoted strings, and `#` line comments. +/// NOT supported: flow style (`{}`/`[]`), anchors, aliases, tags, multi-document streams, +/// or block scalars (`|`/`>`). Returns nil when the input doesn't look like a mapping. +enum RecipeYAMLParser { + fileprivate struct Token { + let indent: Int + let content: String + } + + static func parse(_ source: String) -> [String: Any]? { + let tokens = tokenize(source) + guard !tokens.isEmpty else { return nil } + var index = 0 + let result = parseMapping(tokens: tokens, index: &index, indent: tokens[0].indent) + return result.isEmpty ? nil : result + } + + fileprivate static func tokenize(_ source: String) -> [Token] { + var s = source + if s.hasPrefix("\u{FEFF}") { s.removeFirst() } + var tokens: [Token] = [] + for line in s.components(separatedBy: "\n") { + var indent = 0 + var i = line.startIndex + while i < line.endIndex, line[i] == " " { + indent += 1 + i = line.index(after: i) + } + let rest = String(line[i...]) + let trimmed = rest.trimmingCharacters(in: .whitespaces) + if trimmed.isEmpty || trimmed.hasPrefix("#") { continue } + tokens.append(Token(indent: indent, content: stripInlineComment(rest))) + } + return tokens + } + + fileprivate static func parseMapping(tokens: [Token], index: inout Int, indent: Int) -> [String: Any] { + var dict: [String: Any] = [:] + while index < tokens.count { + let tok = tokens[index] + if tok.indent < indent { break } + if tok.indent > indent { index += 1; continue } + guard let colon = findUnquotedColon(in: tok.content) else { + index += 1 + continue + } + let key = unquote(String(tok.content[..= indent { + dict[key] = parseSequence(tokens: tokens, index: &index, indent: next.indent) + } else if next.indent > indent { + dict[key] = parseMapping(tokens: tokens, index: &index, indent: next.indent) + } else { + dict[key] = NSNull() + } + } else { + dict[key] = NSNull() + } + } else { + dict[key] = parseScalar(valuePart) + } + } + return dict + } + + fileprivate static func parseSequence(tokens: [Token], index: inout Int, indent: Int) -> [Any] { + var arr: [Any] = [] + while index < tokens.count { + let tok = tokens[index] + if tok.indent < indent { break } + if tok.indent > indent { index += 1; continue } + guard tok.content.hasPrefix("- ") || tok.content == "-" else { break } + + let after = tok.content == "-" + ? "" + : String(tok.content.dropFirst(2)).trimmingCharacters(in: .whitespaces) + + if after.isEmpty { + index += 1 + if index < tokens.count, tokens[index].indent > indent { + let next = tokens[index] + if next.content.hasPrefix("- ") { + arr.append(parseSequence(tokens: tokens, index: &index, indent: next.indent)) + } else { + arr.append(parseMapping(tokens: tokens, index: &index, indent: next.indent)) + } + } else { + arr.append(NSNull()) + } + } else if let colon = findUnquotedColon(in: after) { + // Inline mapping: "- Key: value" — continuation keys live at indent + 2 + let firstKey = unquote(String(after[.. indent { + sub[firstKey] = parseMapping( + tokens: tokens, index: &index, indent: tokens[index].indent) + } else { + sub[firstKey] = NSNull() + } + } else { + sub[firstKey] = parseScalar(firstVal) + } + let continueIndent = indent + 2 + while index < tokens.count, tokens[index].indent >= continueIndent { + let cont = tokens[index] + if cont.indent != continueIndent { index += 1; continue } + if cont.content.hasPrefix("- ") || cont.content == "-" { break } + guard let c2 = findUnquotedColon(in: cont.content) else { + index += 1 + continue + } + let k = unquote(String(cont.content[.. continueIndent { + sub[k] = parseMapping( + tokens: tokens, index: &index, indent: tokens[index].indent) + } else { + sub[k] = NSNull() + } + } else { + sub[k] = parseScalar(v) + } + } + arr.append(sub) + } else { + arr.append(parseScalar(after)) + index += 1 + } + } + return arr + } + + fileprivate static func parseScalar(_ s: String) -> Any { + if s.isEmpty || s == "null" || s == "Null" || s == "NULL" || s == "~" { return NSNull() } + if let q = unwrapQuoted(s) { return q } + if s == "true" || s == "True" || s == "TRUE" { return true } + if s == "false" || s == "False" || s == "FALSE" { return false } + if let i = Int(s) { return i } + if let d = Double(s) { return d } + return s + } + + fileprivate static func unquote(_ s: String) -> String { + unwrapQuoted(s) ?? s + } + + fileprivate static func unwrapQuoted(_ s: String) -> String? { + guard s.count >= 2 else { return nil } + if s.first == "'" && s.last == "'" { + return String(s.dropFirst().dropLast()) + .replacingOccurrences(of: "''", with: "'") + } + if s.first == "\"" && s.last == "\"" { + let inner = String(s.dropFirst().dropLast()) + return inner + .replacingOccurrences(of: "\\n", with: "\n") + .replacingOccurrences(of: "\\t", with: "\t") + .replacingOccurrences(of: "\\\"", with: "\"") + .replacingOccurrences(of: "\\\\", with: "\\") + } + return nil + } + + /// Finds a `:` that's outside any quoted span and followed by EOL or whitespace + /// (the YAML rule for a key/value separator). + fileprivate static func findUnquotedColon(in s: String) -> String.Index? { + var inSingle = false + var inDouble = false + var i = s.startIndex + while i < s.endIndex { + let ch = s[i] + if ch == "'" && !inDouble { inSingle.toggle() } + else if ch == "\"" && !inSingle { inDouble.toggle() } + else if ch == ":" && !inSingle && !inDouble { + let next = s.index(after: i) + if next == s.endIndex || s[next] == " " || s[next] == "\t" { + return i + } + } + i = s.index(after: i) + } + return nil + } + + fileprivate static func stripInlineComment(_ s: String) -> String { + var inSingle = false + var inDouble = false + var prev: Character = " " + var i = s.startIndex + while i < s.endIndex { + let ch = s[i] + if ch == "'" && !inDouble { inSingle.toggle() } + else if ch == "\"" && !inSingle { inDouble.toggle() } + else if ch == "#" && !inSingle && !inDouble && prev == " " { + return String(s[.. Date: Mon, 27 Apr 2026 17:50:38 +0200 Subject: [PATCH 6/6] Switch to release Icons --- Pique.xcodeproj/project.pbxproj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Pique.xcodeproj/project.pbxproj b/Pique.xcodeproj/project.pbxproj index cc9dae0..4b3720c 100644 --- a/Pique.xcodeproj/project.pbxproj +++ b/Pique.xcodeproj/project.pbxproj @@ -521,7 +521,7 @@ CF100000000000AR /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_APPICON_NAME = AppIconRelease; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Pique/Pique.entitlements; "CODE_SIGN_IDENTITY[sdk=macosx*]" = "Developer ID Application";