Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# Xcode
build/
DerivedData/
*.profraw
*.xcuserdata
*.xcworkspace/xcuserdata/
xcuserdata/
Expand Down
2 changes: 1 addition & 1 deletion Pique.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
30 changes: 21 additions & 9 deletions Pique/ContentView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -31,15 +32,9 @@ struct ContentView: View {
Text("QuickLook previews for config files")
.foregroundStyle(.secondary)

HStack(spacing: 16) {
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)
}
VStack(spacing: 10) {
formatChipsRow(formats.prefix(5))
formatChipsRow(formats.dropFirst(5))
}

Text("Select a supported file in Finder and press Space to preview.")
Expand All @@ -65,6 +60,23 @@ struct ContentView: View {
SettingsView()
}
}

@ViewBuilder
private func formatChipsRow<S: Sequence>(_ 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)
}
}
}
}

#Preview {
Expand Down
19 changes: 19 additions & 0 deletions PiquePreview/Info.plist
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,23 @@
</array>
</dict>
</dict>
<dict>
<key>UTTypeIdentifier</key>
<string>io.macadmins.pique.vpptoken</string>
<key>UTTypeDescription</key>
<string>Apple VPP / Apps and Books Token</string>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>vpptoken</string>
</array>
</dict>
</dict>
</array>
<key>NSExtension</key>
<dict>
Expand Down Expand Up @@ -145,6 +162,7 @@
<string>net.daringfireball.markdown</string>
<string>io.macadmins.pique.hcl</string>
<string>io.macadmins.pique.recipe</string>
<string>io.macadmins.pique.vpptoken</string>
<string>com.apple.terminal.shell-script</string>
</array>
<key>QLFileExtensions</key>
Expand Down Expand Up @@ -184,6 +202,7 @@
<string>tfvars</string>
<string>hcl</string>
<string>recipe</string>
<string>vpptoken</string>
</array>
<key>QLPreviewWidth</key>
<real>1024</real>
Expand Down
78 changes: 69 additions & 9 deletions PiquePreview/PreviewProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,24 +34,39 @@ 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 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"
|| nameLower.hasSuffix(".recipe.plist")
|| nameLower.hasSuffix(".recipe.yaml")
let format: FileFormat = isAutoPkgRecipe
? .recipe
: (FileFormat(pathExtension: url.pathExtension) ?? .json)

// Step 1: Strip CMS signature from signed mobileconfig files
if format == .mobileconfig, FileReader.isCMSEnvelope(data),
let inner = FileReader.stripCMSSignature(from: data) {
data = inner
}

// Step 2: Convert binary plist to XML text
// Step 2: Convert source bytes to displayable text
let text: String
if FileReader.isBinaryPlist(data),
let xml = FileReader.convertBinaryPlistToXMLString(data) {
if extLower == "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)
}

let formatName = PreviewProvider.formatName(for: url.pathExtension)
let formatName = isAutoPkgRecipe
? "AutoPkg"
: PreviewProvider.formatName(for: url.pathExtension)
let isDark: Bool
switch AppearanceSettings.override(forFormat: formatName) {
case .system:
Expand All @@ -61,7 +76,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: "<body>", with: "<body>\(banner)")
}

logger.info("Preview for \(url.lastPathComponent, privacy: .public)")

Expand Down Expand Up @@ -89,13 +109,53 @@ 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 """
<div style="background:\(bg);color:\(fg);font:600 13px/1.4 -apple-system,system-ui,sans-serif;padding:10px 14px;margin:0 0 12px 0;border-radius:6px;">\(title)\(org)</div>
"""
}

/// 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 "yaml", "yml": return "YAML"
case "json", "ndjson", "jsonl", "vpptoken": return "JSON"
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"
Expand Down
7 changes: 6 additions & 1 deletion PiqueTests/FileFormatTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -23,7 +24,11 @@ final class FileFormatTests: XCTestCase {

func testXML() {
XCTAssertEqual(FileFormat(pathExtension: "xml"), .xml)
XCTAssertEqual(FileFormat(pathExtension: "recipe"), .xml)
}

func testRecipeIsItsOwnFormat() {
// .recipe files are XML plists but get a custom structured renderer
XCTAssertEqual(FileFormat(pathExtension: "recipe"), .recipe)
}

func testMobileconfig() {
Expand Down
77 changes: 77 additions & 0 deletions PiqueTests/FileReaderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,4 +199,81 @@ final class FileReaderTests: XCTestCase {
XCTAssertTrue(xmlString!.contains("PayloadType"))
XCTAssertTrue(xmlString!.contains("Configuration"))
}

// 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 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 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 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 testRecipeYAMLBareNumberBecomesNumber() {
let dict = RecipeYAMLParser.parse("MinimumVersion: 2.3")
XCTAssertEqual(dict?["MinimumVersion"] as? Double, 2.3)
}

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 testRecipeYAMLProcessStepWithoutArguments() {
// Raycast.pkg.recipe.yaml has `- Processor: AppPkgCreator` with no Arguments
let yaml = """
Process:
- Processor: AppPkgCreator
"""
let dict = RecipeYAMLParser.parse(yaml)
let step = (dict?["Process"] as? [Any])?.first as? [String: Any]
XCTAssertEqual(step?["Processor"] as? String, "AppPkgCreator")
XCTAssertNil(step?["Arguments"])
}

}
39 changes: 39 additions & 0 deletions Shared/FileReader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,44 @@ 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.
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)
else { return nil }

guard let pretty = try? JSONSerialization.data(
withJSONObject: object,
options: [.prettyPrinted, .sortedKeys]
) else { return nil }
return String(data: pretty, encoding: .utf8)
}

enum FileReaderError: LocalizedError {
case fileTooLarge(UInt64)

Expand All @@ -93,3 +131,4 @@ enum FileReader {
}
}
}

Loading
Loading