diff --git a/MinecraftPackagePreviewExtension/Base.lproj/PreviewViewController.xib b/MinecraftPackagePreviewExtension/Base.lproj/PreviewViewController.xib
new file mode 100644
index 0000000..e5fd87f
--- /dev/null
+++ b/MinecraftPackagePreviewExtension/Base.lproj/PreviewViewController.xib
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/MinecraftPackagePreviewExtension/Info.plist b/MinecraftPackagePreviewExtension/Info.plist
new file mode 100644
index 0000000..ae62132
--- /dev/null
+++ b/MinecraftPackagePreviewExtension/Info.plist
@@ -0,0 +1,27 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionAttributes
+
+ QLIsDataBasedPreview
+
+ QLSupportedContentTypes
+
+ us.b-wells.minecraft.mcworld
+ us.b-wells.minecraft.mcpack
+ us.b-wells.minecraft.mctemplate
+ us.b-wells.minecraft.mcaddon
+
+ QLSupportsSearchableItems
+
+
+ NSExtensionPointIdentifier
+ com.apple.quicklook.preview
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).PreviewViewController
+
+
+
diff --git a/MinecraftPackagePreviewExtension/MinecraftPackageThumbnailRenderer.swift b/MinecraftPackagePreviewExtension/MinecraftPackageThumbnailRenderer.swift
new file mode 100644
index 0000000..68c73eb
--- /dev/null
+++ b/MinecraftPackagePreviewExtension/MinecraftPackageThumbnailRenderer.swift
@@ -0,0 +1,185 @@
+//
+// MinecraftPackageThumbnailRenderer.swift
+// MinecraftPackagePreviewExtension
+//
+
+import AppKit
+import Foundation
+
+enum MinecraftPackageThumbnailRenderer {
+ nonisolated static func makeThumbnail(
+ for inspection: MinecraftPackageInspector.InspectionResult,
+ size: CGSize,
+ scale: CGFloat = 1
+ ) -> CGImage? {
+ let pixelSize = CGSize(
+ width: max(size.width * scale, 1),
+ height: max(size.height * scale, 1)
+ )
+
+ guard
+ let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: max(Int(pixelSize.width.rounded(.up)), 1),
+ pixelsHigh: max(Int(pixelSize.height.rounded(.up)), 1),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )
+ else {
+ return nil
+ }
+
+ bitmap.size = pixelSize
+
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ return nil
+ }
+
+ NSGraphicsContext.current = context
+ let rect = CGRect(origin: .zero, size: pixelSize)
+
+ if let iconURL = inspection.iconURL,
+ let iconImage = NSImage(contentsOf: iconURL) {
+ drawSquareThumbnail(iconImage, in: rect)
+ } else {
+ drawFallbackThumbnail(for: inspection.contentType, in: rect)
+ }
+
+ context.flushGraphics()
+ return bitmap.cgImage
+ }
+
+ nonisolated private static func drawSquareThumbnail(_ image: NSImage, in rect: CGRect) {
+ NSColor.clear.setFill()
+ rect.fill()
+
+ let cornerRadius = min(rect.width, rect.height) * 0.10
+ let clipRect = rect.insetBy(dx: max(rect.width * 0.02, 1), dy: max(rect.height * 0.02, 1))
+ let clipPath = NSBezierPath(
+ roundedRect: clipRect,
+ xRadius: cornerRadius,
+ yRadius: cornerRadius
+ )
+ clipPath.addClip()
+
+ let imageSize = image.size
+ guard imageSize.width > 0, imageSize.height > 0 else {
+ image.draw(in: clipRect)
+ return
+ }
+
+ let scale = max(clipRect.width / imageSize.width, clipRect.height / imageSize.height)
+ let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
+ let drawRect = CGRect(
+ x: clipRect.midX - drawSize.width / 2,
+ y: clipRect.midY - drawSize.height / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+
+ image.draw(in: drawRect)
+
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ let borderPath = NSBezierPath(
+ roundedRect: clipRect.insetBy(dx: 0.5, dy: 0.5),
+ xRadius: max(cornerRadius - 0.5, 0),
+ yRadius: max(cornerRadius - 0.5, 0)
+ )
+ borderPath.lineWidth = max(1, rect.width * 0.01)
+ borderPath.stroke()
+ }
+
+ nonisolated private static func drawFallbackThumbnail(
+ for contentType: MinecraftContentType,
+ in rect: CGRect
+ ) {
+ let path = NSBezierPath(
+ roundedRect: rect,
+ xRadius: rect.width * 0.10,
+ yRadius: rect.width * 0.10
+ )
+
+ let gradient = NSGradient(colors: backgroundColors(for: contentType))
+ gradient?.draw(in: path, angle: -45)
+
+ let badgeRect = rect.insetBy(dx: rect.width * 0.18, dy: rect.height * 0.18)
+ drawVoxelBadge(in: badgeRect)
+
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ path.lineWidth = max(1, rect.width * 0.01)
+ path.stroke()
+ }
+
+ nonisolated private static func drawVoxelBadge(in rect: CGRect) {
+ let topFace = NSBezierPath()
+ topFace.move(to: CGPoint(x: rect.midX, y: rect.maxY))
+ topFace.line(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ topFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ topFace.line(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ topFace.close()
+ NSColor(calibratedRed: 0.78, green: 0.94, blue: 0.63, alpha: 1).setFill()
+ topFace.fill()
+
+ let leftFace = NSBezierPath()
+ leftFace.move(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ leftFace.line(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.18))
+ leftFace.close()
+ NSColor(calibratedRed: 0.34, green: 0.61, blue: 0.24, alpha: 1).setFill()
+ leftFace.fill()
+
+ let rightFace = NSBezierPath()
+ rightFace.move(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ rightFace.line(to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.18))
+ rightFace.close()
+ NSColor(calibratedRed: 0.14, green: 0.34, blue: 0.13, alpha: 1).setFill()
+ rightFace.fill()
+
+ NSColor.black.withAlphaComponent(0.18).setStroke()
+ [topFace, leftFace, rightFace].forEach {
+ $0.lineWidth = max(1, rect.width * 0.02)
+ $0.stroke()
+ }
+ }
+
+ nonisolated private static func backgroundColors(for contentType: MinecraftContentType) -> [NSColor] {
+ switch contentType {
+ case .world:
+ return [
+ NSColor(calibratedRed: 0.18, green: 0.48, blue: 0.25, alpha: 1),
+ NSColor(calibratedRed: 0.47, green: 0.75, blue: 0.31, alpha: 1)
+ ]
+ case .behaviorPack:
+ return [
+ NSColor(calibratedRed: 0.58, green: 0.29, blue: 0.15, alpha: 1),
+ NSColor(calibratedRed: 0.86, green: 0.60, blue: 0.22, alpha: 1)
+ ]
+ case .resourcePack:
+ return [
+ NSColor(calibratedRed: 0.12, green: 0.38, blue: 0.66, alpha: 1),
+ NSColor(calibratedRed: 0.31, green: 0.68, blue: 0.85, alpha: 1)
+ ]
+ case .skinPack:
+ return [
+ NSColor(calibratedRed: 0.53, green: 0.19, blue: 0.43, alpha: 1),
+ NSColor(calibratedRed: 0.84, green: 0.39, blue: 0.62, alpha: 1)
+ ]
+ case .worldTemplate:
+ return [
+ NSColor(calibratedRed: 0.23, green: 0.23, blue: 0.54, alpha: 1),
+ NSColor(calibratedRed: 0.53, green: 0.52, blue: 0.89, alpha: 1)
+ ]
+ }
+ }
+}
diff --git a/MinecraftPackagePreviewExtension/PreviewProvider.swift b/MinecraftPackagePreviewExtension/PreviewProvider.swift
new file mode 100644
index 0000000..01d0d8b
--- /dev/null
+++ b/MinecraftPackagePreviewExtension/PreviewProvider.swift
@@ -0,0 +1,54 @@
+//
+// PreviewProvider.swift
+// MinecraftPackagePreviewExtension
+//
+// Created by John Burwell on 2026-05-27.
+//
+
+import Cocoa
+import Quartz
+
+class PreviewProvider: QLPreviewProvider, QLPreviewingController {
+
+
+ /*
+ Use a QLPreviewProvider to provide data-based previews.
+
+ To set up your extension as a data-based preview extension:
+
+ - Modify the extension's Info.plist by setting
+ QLIsDataBasedPreview
+
+
+ - Add the supported content types to QLSupportedContentTypes array in the extension's Info.plist.
+
+ - Change the NSExtensionPrincipalClass to this class.
+ e.g.
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).PreviewProvider
+
+ - Implement providePreview(for:)
+ */
+
+ func providePreview(for request: QLFilePreviewRequest) async throws -> QLPreviewReply {
+
+ //You can create a QLPreviewReply in several ways, depending on the format of the data you want to return.
+ //To return Data of a supported content type:
+
+ let contentType = UTType.plainText // replace with your data type
+
+ let reply = QLPreviewReply.init(dataOfContentType: contentType, contentSize: CGSize.init(width: 800, height: 800)) { (replyToUpdate : QLPreviewReply) in
+
+ let data = Data("Hello world".utf8)
+
+ //setting the stringEncoding for text and html data is optional and defaults to String.Encoding.utf8
+ replyToUpdate.stringEncoding = .utf8
+
+ //initialize your data here
+
+ return data
+ }
+
+ return reply
+ }
+}
diff --git a/MinecraftPackagePreviewExtension/PreviewViewController.swift b/MinecraftPackagePreviewExtension/PreviewViewController.swift
new file mode 100644
index 0000000..e7b8d2f
--- /dev/null
+++ b/MinecraftPackagePreviewExtension/PreviewViewController.swift
@@ -0,0 +1,114 @@
+//
+// PreviewViewController.swift
+// MinecraftPackagePreviewExtension
+//
+// Created by John Burwell on 2026-05-27.
+//
+
+import Cocoa
+import OSLog
+import Quartz
+
+final class PreviewViewController: NSViewController, QLPreviewingController {
+ private let logger = Logger(
+ subsystem: "us.b-wells.World-Manager-for-Minecraft",
+ category: "PreviewExtension"
+ )
+
+ private var currentInspection: MinecraftPackageInspector.InspectionResult?
+ private let imageView = NSImageView()
+ private let scrollView = NSScrollView()
+
+ override func loadView() {
+ let rootView = NSView(frame: NSRect(x: 0, y: 0, width: 900, height: 700))
+ rootView.wantsLayer = true
+ rootView.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
+
+ imageView.translatesAutoresizingMaskIntoConstraints = false
+ imageView.imageScaling = .scaleProportionallyUpOrDown
+ imageView.imageAlignment = .alignCenter
+
+ let containerView = NSView()
+ containerView.translatesAutoresizingMaskIntoConstraints = false
+ containerView.addSubview(imageView)
+
+ scrollView.translatesAutoresizingMaskIntoConstraints = false
+ scrollView.drawsBackground = false
+ scrollView.hasVerticalScroller = true
+ scrollView.hasHorizontalScroller = true
+ scrollView.documentView = containerView
+
+ rootView.addSubview(scrollView)
+ view = rootView
+
+ NSLayoutConstraint.activate([
+ scrollView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor),
+ scrollView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor),
+ scrollView.topAnchor.constraint(equalTo: rootView.topAnchor),
+ scrollView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor),
+
+ containerView.leadingAnchor.constraint(equalTo: imageView.leadingAnchor, constant: -32),
+ containerView.trailingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: 32),
+ containerView.topAnchor.constraint(equalTo: imageView.topAnchor, constant: -32),
+ containerView.bottomAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 32),
+
+ imageView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor),
+ imageView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor)
+ ])
+ }
+
+ func preparePreviewOfFile(at url: URL) async throws {
+ logger.notice("Preview extension hit: \(url.path, privacy: .public)")
+
+ if let currentInspection {
+ MinecraftPackageInspector.cleanup(currentInspection)
+ self.currentInspection = nil
+ }
+
+ let inspection = try MinecraftPackageInspector.inspectArchive(at: url)
+ let image = makePreviewImage(for: inspection)
+
+ await MainActor.run {
+ imageView.image = image
+ updateImageConstraints(for: image)
+ }
+
+ currentInspection = inspection
+ }
+
+ private func makePreviewImage(for inspection: MinecraftPackageInspector.InspectionResult) -> NSImage? {
+ if let iconURL = inspection.iconURL,
+ let image = NSImage(contentsOf: iconURL) {
+ return image
+ }
+
+ guard let cgImage = MinecraftPackageThumbnailRenderer.makeThumbnail(
+ for: inspection,
+ size: CGSize(width: 480, height: 480),
+ scale: NSScreen.main?.backingScaleFactor ?? 2
+ ) else {
+ return nil
+ }
+
+ return NSImage(cgImage: cgImage, size: NSSize(width: 480, height: 480))
+ }
+
+ private func updateImageConstraints(for image: NSImage?) {
+ imageView.constraints.forEach { imageView.removeConstraint($0) }
+
+ guard let image, image.size.width > 0, image.size.height > 0 else {
+ return
+ }
+
+ NSLayoutConstraint.activate([
+ imageView.widthAnchor.constraint(equalToConstant: image.size.width),
+ imageView.heightAnchor.constraint(equalToConstant: image.size.height)
+ ])
+ }
+
+ deinit {
+ if let currentInspection {
+ MinecraftPackageInspector.cleanup(currentInspection)
+ }
+ }
+}
diff --git a/MinecraftPackageThumbnailExtension/Info.plist b/MinecraftPackageThumbnailExtension/Info.plist
new file mode 100644
index 0000000..29d8902
--- /dev/null
+++ b/MinecraftPackageThumbnailExtension/Info.plist
@@ -0,0 +1,25 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionAttributes
+
+ QLSupportedContentTypes
+
+ us.b-wells.minecraft.mcworld
+ us.b-wells.minecraft.mcpack
+ us.b-wells.minecraft.mctemplate
+ us.b-wells.minecraft.mcaddon
+
+ QLThumbnailMinimumDimension
+ 0
+
+ NSExtensionPointIdentifier
+ com.apple.quicklook.thumbnail
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).ThumbnailProvider
+
+
+
diff --git a/MinecraftPackageThumbnailExtension/MinecraftPackageThumbnailRenderer.swift b/MinecraftPackageThumbnailExtension/MinecraftPackageThumbnailRenderer.swift
new file mode 100644
index 0000000..8e53188
--- /dev/null
+++ b/MinecraftPackageThumbnailExtension/MinecraftPackageThumbnailRenderer.swift
@@ -0,0 +1,185 @@
+//
+// MinecraftPackageThumbnailRenderer.swift
+// MinecraftPackageThumbnailExtension
+//
+
+import AppKit
+import Foundation
+
+enum MinecraftPackageThumbnailRenderer {
+ nonisolated static func makeThumbnail(
+ for inspection: MinecraftPackageInspector.InspectionResult,
+ size: CGSize,
+ scale: CGFloat = 1
+ ) -> CGImage? {
+ let pixelSize = CGSize(
+ width: max(size.width * scale, 1),
+ height: max(size.height * scale, 1)
+ )
+
+ guard
+ let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: max(Int(pixelSize.width.rounded(.up)), 1),
+ pixelsHigh: max(Int(pixelSize.height.rounded(.up)), 1),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )
+ else {
+ return nil
+ }
+
+ bitmap.size = pixelSize
+
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ return nil
+ }
+
+ NSGraphicsContext.current = context
+ let rect = CGRect(origin: .zero, size: pixelSize)
+
+ if let iconURL = inspection.iconURL,
+ let iconImage = NSImage(contentsOf: iconURL) {
+ drawSquareThumbnail(iconImage, in: rect)
+ } else {
+ drawFallbackThumbnail(for: inspection.contentType, in: rect)
+ }
+
+ context.flushGraphics()
+ return bitmap.cgImage
+ }
+
+ nonisolated private static func drawSquareThumbnail(_ image: NSImage, in rect: CGRect) {
+ NSColor.clear.setFill()
+ rect.fill()
+
+ let cornerRadius = min(rect.width, rect.height) * 0.10
+ let clipRect = rect.insetBy(dx: max(rect.width * 0.02, 1), dy: max(rect.height * 0.02, 1))
+ let clipPath = NSBezierPath(
+ roundedRect: clipRect,
+ xRadius: cornerRadius,
+ yRadius: cornerRadius
+ )
+ clipPath.addClip()
+
+ let imageSize = image.size
+ guard imageSize.width > 0, imageSize.height > 0 else {
+ image.draw(in: clipRect)
+ return
+ }
+
+ let scale = max(clipRect.width / imageSize.width, clipRect.height / imageSize.height)
+ let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
+ let drawRect = CGRect(
+ x: clipRect.midX - drawSize.width / 2,
+ y: clipRect.midY - drawSize.height / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+
+ image.draw(in: drawRect)
+
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ let borderPath = NSBezierPath(
+ roundedRect: clipRect.insetBy(dx: 0.5, dy: 0.5),
+ xRadius: max(cornerRadius - 0.5, 0),
+ yRadius: max(cornerRadius - 0.5, 0)
+ )
+ borderPath.lineWidth = max(1, rect.width * 0.01)
+ borderPath.stroke()
+ }
+
+ nonisolated private static func drawFallbackThumbnail(
+ for contentType: MinecraftContentType,
+ in rect: CGRect
+ ) {
+ let path = NSBezierPath(
+ roundedRect: rect,
+ xRadius: rect.width * 0.10,
+ yRadius: rect.width * 0.10
+ )
+
+ let gradient = NSGradient(colors: backgroundColors(for: contentType))
+ gradient?.draw(in: path, angle: -45)
+
+ let badgeRect = rect.insetBy(dx: rect.width * 0.18, dy: rect.height * 0.18)
+ drawVoxelBadge(in: badgeRect)
+
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ path.lineWidth = max(1, rect.width * 0.01)
+ path.stroke()
+ }
+
+ nonisolated private static func drawVoxelBadge(in rect: CGRect) {
+ let topFace = NSBezierPath()
+ topFace.move(to: CGPoint(x: rect.midX, y: rect.maxY))
+ topFace.line(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ topFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ topFace.line(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ topFace.close()
+ NSColor(calibratedRed: 0.78, green: 0.94, blue: 0.63, alpha: 1).setFill()
+ topFace.fill()
+
+ let leftFace = NSBezierPath()
+ leftFace.move(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ leftFace.line(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.18))
+ leftFace.close()
+ NSColor(calibratedRed: 0.34, green: 0.61, blue: 0.24, alpha: 1).setFill()
+ leftFace.fill()
+
+ let rightFace = NSBezierPath()
+ rightFace.move(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ rightFace.line(to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.18))
+ rightFace.close()
+ NSColor(calibratedRed: 0.14, green: 0.34, blue: 0.13, alpha: 1).setFill()
+ rightFace.fill()
+
+ NSColor.black.withAlphaComponent(0.18).setStroke()
+ [topFace, leftFace, rightFace].forEach {
+ $0.lineWidth = max(1, rect.width * 0.02)
+ $0.stroke()
+ }
+ }
+
+ nonisolated private static func backgroundColors(for contentType: MinecraftContentType) -> [NSColor] {
+ switch contentType {
+ case .world:
+ return [
+ NSColor(calibratedRed: 0.18, green: 0.48, blue: 0.25, alpha: 1),
+ NSColor(calibratedRed: 0.47, green: 0.75, blue: 0.31, alpha: 1)
+ ]
+ case .behaviorPack:
+ return [
+ NSColor(calibratedRed: 0.58, green: 0.29, blue: 0.15, alpha: 1),
+ NSColor(calibratedRed: 0.86, green: 0.60, blue: 0.22, alpha: 1)
+ ]
+ case .resourcePack:
+ return [
+ NSColor(calibratedRed: 0.12, green: 0.38, blue: 0.66, alpha: 1),
+ NSColor(calibratedRed: 0.31, green: 0.68, blue: 0.85, alpha: 1)
+ ]
+ case .skinPack:
+ return [
+ NSColor(calibratedRed: 0.53, green: 0.19, blue: 0.43, alpha: 1),
+ NSColor(calibratedRed: 0.84, green: 0.39, blue: 0.62, alpha: 1)
+ ]
+ case .worldTemplate:
+ return [
+ NSColor(calibratedRed: 0.23, green: 0.23, blue: 0.54, alpha: 1),
+ NSColor(calibratedRed: 0.53, green: 0.52, blue: 0.89, alpha: 1)
+ ]
+ }
+ }
+}
diff --git a/MinecraftPackageThumbnailExtension/ThumbnailProvider.swift b/MinecraftPackageThumbnailExtension/ThumbnailProvider.swift
new file mode 100644
index 0000000..23c7672
--- /dev/null
+++ b/MinecraftPackageThumbnailExtension/ThumbnailProvider.swift
@@ -0,0 +1,53 @@
+//
+// ThumbnailProvider.swift
+// MinecraftPackageThumbnailExtension
+//
+// Created by John Burwell on 2026-05-27.
+//
+
+import AppKit
+import Foundation
+import OSLog
+import QuickLookThumbnailing
+
+final class ThumbnailProvider: QLThumbnailProvider {
+ private let logger = Logger(
+ subsystem: "us.b-wells.World-Manager-for-Minecraft",
+ category: "ThumbnailExtension"
+ )
+
+ override func provideThumbnail(
+ for request: QLFileThumbnailRequest,
+ _ handler: @escaping (QLThumbnailReply?, Error?) -> Void
+ ) {
+ logger.notice("Thumbnail extension hit: \(request.fileURL.path, privacy: .public)")
+
+ do {
+ let inspection = try MinecraftPackageInspector.inspectArchive(at: request.fileURL)
+ defer { MinecraftPackageInspector.cleanup(inspection) }
+
+ guard let image = MinecraftPackageThumbnailRenderer.makeThumbnail(
+ for: inspection,
+ size: request.maximumSize,
+ scale: request.scale
+ ) else {
+ handler(nil, CocoaError(.fileReadCorruptFile))
+ return
+ }
+
+ let size = request.maximumSize
+ let reply = QLThumbnailReply(contextSize: size) { context in
+ let rect = CGRect(origin: .zero, size: size)
+ context.clear(rect)
+ context.draw(image, in: rect)
+ return true
+ }
+
+ logger.notice("Thumbnail extension produced image for: \(request.fileURL.lastPathComponent, privacy: .public)")
+ handler(reply, nil)
+ } catch {
+ logger.error("Thumbnail extension failed for \(request.fileURL.path, privacy: .public): \(String(describing: error), privacy: .public)")
+ handler(nil, error)
+ }
+ }
+}
diff --git a/Scripts/generate_document_icon.swift b/Scripts/generate_document_icon.swift
new file mode 100644
index 0000000..7f00abe
--- /dev/null
+++ b/Scripts/generate_document_icon.swift
@@ -0,0 +1,145 @@
+import AppKit
+import Foundation
+
+let fileManager = FileManager.default
+let currentDirectoryURL = URL(fileURLWithPath: fileManager.currentDirectoryPath, isDirectory: true)
+let iconsetURL = currentDirectoryURL
+ .appendingPathComponent("World Manager for Minecraft/MinecraftVoxelDocument.iconset", isDirectory: true)
+let singleIconURL = currentDirectoryURL
+ .appendingPathComponent("World Manager for Minecraft/MinecraftVoxelDocument.png")
+
+let outputSizes: [(name: String, pixels: Int)] = [
+ ("icon_16x16.png", 16),
+ ("icon_16x16@2x.png", 32),
+ ("icon_32x32.png", 32),
+ ("icon_32x32@2x.png", 64),
+ ("icon_128x128.png", 128),
+ ("icon_128x128@2x.png", 256),
+ ("icon_256x256.png", 256),
+ ("icon_256x256@2x.png", 512),
+ ("icon_512x512.png", 512),
+ ("icon_512x512@2x.png", 1024)
+]
+
+try? fileManager.removeItem(at: iconsetURL)
+try fileManager.createDirectory(at: iconsetURL, withIntermediateDirectories: true)
+
+for output in outputSizes {
+ let image = try makeDocumentIcon(pixels: output.pixels)
+ let destinationURL = iconsetURL.appendingPathComponent(output.name)
+ try writePNG(image: image, to: destinationURL)
+}
+
+let primaryIcon = try makeDocumentIcon(pixels: 1024)
+try writePNG(image: primaryIcon, to: singleIconURL)
+
+func makeDocumentIcon(pixels: Int) throws -> NSImage {
+ guard let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: pixels,
+ pixelsHigh: pixels,
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ ) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+
+ bitmap.size = NSSize(width: pixels, height: pixels)
+
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+
+ NSGraphicsContext.current = context
+
+ let size = CGFloat(pixels)
+ let rect = CGRect(origin: .zero, size: CGSize(width: size, height: size))
+ drawBackground(in: rect)
+ drawVoxelBadge(in: rect.insetBy(dx: size * 0.20, dy: size * 0.18))
+ drawBorder(in: rect)
+ context.flushGraphics()
+
+ let image = NSImage(size: NSSize(width: pixels, height: pixels))
+ image.addRepresentation(bitmap)
+ return image
+}
+
+func drawBackground(in rect: CGRect) {
+ let path = NSBezierPath(
+ roundedRect: rect.insetBy(dx: 1, dy: 1),
+ xRadius: rect.width * 0.20,
+ yRadius: rect.height * 0.20
+ )
+
+ let gradient = NSGradient(colors: [
+ NSColor(calibratedRed: 0.18, green: 0.48, blue: 0.25, alpha: 1),
+ NSColor(calibratedRed: 0.47, green: 0.75, blue: 0.31, alpha: 1)
+ ])
+ gradient?.draw(in: path, angle: -45)
+}
+
+func drawVoxelBadge(in rect: CGRect) {
+ let topFace = NSBezierPath()
+ topFace.move(to: CGPoint(x: rect.midX, y: rect.maxY))
+ topFace.line(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ topFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ topFace.line(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ topFace.close()
+ NSColor(calibratedRed: 0.78, green: 0.94, blue: 0.63, alpha: 1).setFill()
+ topFace.fill()
+
+ let leftFace = NSBezierPath()
+ leftFace.move(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ leftFace.line(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.18))
+ leftFace.close()
+ NSColor(calibratedRed: 0.34, green: 0.61, blue: 0.24, alpha: 1).setFill()
+ leftFace.fill()
+
+ let rightFace = NSBezierPath()
+ rightFace.move(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ rightFace.line(to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.18))
+ rightFace.close()
+ NSColor(calibratedRed: 0.14, green: 0.34, blue: 0.13, alpha: 1).setFill()
+ rightFace.fill()
+
+ NSColor.black.withAlphaComponent(0.18).setStroke()
+ [topFace, leftFace, rightFace].forEach {
+ $0.lineWidth = max(1, rect.width * 0.02)
+ $0.stroke()
+ }
+}
+
+func drawBorder(in rect: CGRect) {
+ let path = NSBezierPath(
+ roundedRect: rect.insetBy(dx: 1, dy: 1),
+ xRadius: rect.width * 0.20,
+ yRadius: rect.height * 0.20
+ )
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ path.lineWidth = max(1, rect.width * 0.01)
+ path.stroke()
+}
+
+func writePNG(image: NSImage, to url: URL) throws {
+ guard
+ let tiffData = image.tiffRepresentation,
+ let bitmap = NSBitmapImageRep(data: tiffData),
+ let pngData = bitmap.representation(using: .png, properties: [:])
+ else {
+ throw CocoaError(.fileWriteUnknown)
+ }
+
+ try pngData.write(to: url)
+}
diff --git a/World Manager for Minecraft.xcodeproj/project.pbxproj b/World Manager for Minecraft.xcodeproj/project.pbxproj
index f3c846d..71d8f6e 100644
--- a/World Manager for Minecraft.xcodeproj/project.pbxproj
+++ b/World Manager for Minecraft.xcodeproj/project.pbxproj
@@ -6,6 +6,14 @@
objectVersion = 77;
objects = {
+/* Begin PBXBuildFile section */
+ 52C72BCA2FC7314E009928CB /* QuickLookThumbnailing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */; };
+ 52C72BCB2FC7314E009928CB /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52C72BB12FC72940009928CB /* Quartz.framework */; };
+ 52C72BD22FC7314E009928CB /* MinecraftPackageThumbnailExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+ 52C72BDC2FC73171009928CB /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52C72BB12FC72940009928CB /* Quartz.framework */; };
+ 52C72BE82FC73171009928CB /* MinecraftPackagePreviewExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
+/* End PBXBuildFile section */
+
/* Begin PBXContainerItemProxy section */
5218F9162FC4C9F100CAF7B7 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
@@ -21,17 +29,96 @@
remoteGlobalIDString = 5218F9052FC4C9EF00CAF7B7;
remoteInfo = "World Manager for Minecraft";
};
+ 52C72BD02FC7314E009928CB /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 52C72BC72FC7314D009928CB;
+ remoteInfo = MinecraftPackageThumbnailExtension;
+ };
+ 52C72BE62FC73171009928CB /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = 52C72BDA2FC73171009928CB;
+ remoteInfo = MinecraftPackagePreviewExtension;
+ };
/* End PBXContainerItemProxy section */
+/* Begin PBXCopyFilesBuildPhase section */
+ 52C72BC32FC72940009928CB /* Embed Foundation Extensions */ = {
+ isa = PBXCopyFilesBuildPhase;
+ buildActionMask = 2147483647;
+ dstPath = "";
+ dstSubfolderSpec = 13;
+ files = (
+ 52C72BE82FC73171009928CB /* MinecraftPackagePreviewExtension.appex in Embed Foundation Extensions */,
+ 52C72BD22FC7314E009928CB /* MinecraftPackageThumbnailExtension.appex in Embed Foundation Extensions */,
+ );
+ name = "Embed Foundation Extensions";
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+/* End PBXCopyFilesBuildPhase section */
+
/* Begin PBXFileReference section */
5218F9062FC4C9EF00CAF7B7 /* World Manager for Minecraft.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "World Manager for Minecraft.app"; sourceTree = BUILT_PRODUCTS_DIR; };
5218F9152FC4C9F100CAF7B7 /* World Manager for MinecraftTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "World Manager for MinecraftTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "World Manager for MinecraftUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
+ 52C72BB12FC72940009928CB /* Quartz.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Quartz.framework; path = System/Library/Frameworks/Quartz.framework; sourceTree = SDKROOT; };
+ 52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MinecraftPackageThumbnailExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
+ 52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookThumbnailing.framework; path = System/Library/Frameworks/QuickLookThumbnailing.framework; sourceTree = SDKROOT; };
+ 52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MinecraftPackagePreviewExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
+/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
+ 52C72BD32FC7314E009928CB /* Exceptions for "MinecraftPackageThumbnailExtension" folder in "MinecraftPackageThumbnailExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */;
+ };
+ 52C72BE92FC73171009928CB /* Exceptions for "MinecraftPackagePreviewExtension" folder in "MinecraftPackagePreviewExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Info.plist,
+ );
+ target = 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */;
+ };
+ 52C72BEF2FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackageThumbnailExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Models/MinecraftContentItem.swift,
+ QuickLook/MinecraftPackageTypes.swift,
+ Services/BedrockLevelMetadataDecoder.swift,
+ Services/MinecraftContentMetadataReader.swift,
+ Services/MinecraftPackageInspector.swift,
+ Services/ZipArchiveReader.swift,
+ );
+ target = 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */;
+ };
+ 52C72BF02FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackagePreviewExtension" target */ = {
+ isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
+ membershipExceptions = (
+ Models/MinecraftContentItem.swift,
+ QuickLook/MinecraftPackageQuickLookModel.swift,
+ QuickLook/MinecraftPackageTypes.swift,
+ Services/BedrockLevelMetadataDecoder.swift,
+ Services/MinecraftContentMetadataReader.swift,
+ Services/MinecraftPackageInspector.swift,
+ Services/ZipArchiveReader.swift,
+ );
+ target = 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */;
+ };
+/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
+
/* Begin PBXFileSystemSynchronizedRootGroup section */
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */ = {
isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 52C72BEF2FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackageThumbnailExtension" target */,
+ 52C72BF02FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackagePreviewExtension" target */,
+ );
path = "World Manager for Minecraft";
sourceTree = "";
};
@@ -45,6 +132,22 @@
path = "World Manager for MinecraftUITests";
sourceTree = "";
};
+ 52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 52C72BD32FC7314E009928CB /* Exceptions for "MinecraftPackageThumbnailExtension" folder in "MinecraftPackageThumbnailExtension" target */,
+ );
+ path = MinecraftPackageThumbnailExtension;
+ sourceTree = "";
+ };
+ 52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */ = {
+ isa = PBXFileSystemSynchronizedRootGroup;
+ exceptions = (
+ 52C72BE92FC73171009928CB /* Exceptions for "MinecraftPackagePreviewExtension" folder in "MinecraftPackagePreviewExtension" target */,
+ );
+ path = MinecraftPackagePreviewExtension;
+ sourceTree = "";
+ };
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@@ -69,6 +172,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 52C72BC52FC7314D009928CB /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 52C72BCA2FC7314E009928CB /* QuickLookThumbnailing.framework in Frameworks */,
+ 52C72BCB2FC7314E009928CB /* Quartz.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 52C72BD82FC73171009928CB /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 52C72BDC2FC73171009928CB /* Quartz.framework in Frameworks */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@@ -78,6 +198,9 @@
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
5218F9182FC4C9F100CAF7B7 /* World Manager for MinecraftTests */,
5218F9222FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */,
+ 52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */,
+ 52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */,
+ 52C72BB02FC72940009928CB /* Frameworks */,
5218F9072FC4C9EF00CAF7B7 /* Products */,
);
sourceTree = "";
@@ -88,10 +211,21 @@
5218F9062FC4C9EF00CAF7B7 /* World Manager for Minecraft.app */,
5218F9152FC4C9F100CAF7B7 /* World Manager for MinecraftTests.xctest */,
5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */,
+ 52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */,
+ 52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */,
);
name = Products;
sourceTree = "";
};
+ 52C72BB02FC72940009928CB /* Frameworks */ = {
+ isa = PBXGroup;
+ children = (
+ 52C72BB12FC72940009928CB /* Quartz.framework */,
+ 52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */,
+ );
+ name = Frameworks;
+ sourceTree = "";
+ };
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -102,10 +236,13 @@
5218F9022FC4C9EF00CAF7B7 /* Sources */,
5218F9032FC4C9EF00CAF7B7 /* Frameworks */,
5218F9042FC4C9EF00CAF7B7 /* Resources */,
+ 52C72BC32FC72940009928CB /* Embed Foundation Extensions */,
);
buildRules = (
);
dependencies = (
+ 52C72BD12FC7314E009928CB /* PBXTargetDependency */,
+ 52C72BE72FC73171009928CB /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
@@ -163,6 +300,50 @@
productReference = 5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing";
};
+ 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 52C72BD42FC7314E009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackageThumbnailExtension" */;
+ buildPhases = (
+ 52C72BC42FC7314D009928CB /* Sources */,
+ 52C72BC52FC7314D009928CB /* Frameworks */,
+ 52C72BC62FC7314D009928CB /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */,
+ );
+ name = MinecraftPackageThumbnailExtension;
+ packageProductDependencies = (
+ );
+ productName = MinecraftPackageThumbnailExtension;
+ productReference = 52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
+ 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 52C72BEA2FC73171009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackagePreviewExtension" */;
+ buildPhases = (
+ 52C72BD72FC73171009928CB /* Sources */,
+ 52C72BD82FC73171009928CB /* Frameworks */,
+ 52C72BD92FC73171009928CB /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ );
+ fileSystemSynchronizedGroups = (
+ 52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */,
+ );
+ name = MinecraftPackagePreviewExtension;
+ packageProductDependencies = (
+ );
+ productName = MinecraftPackagePreviewExtension;
+ productReference = 52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */;
+ productType = "com.apple.product-type.app-extension";
+ };
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@@ -184,6 +365,12 @@
CreatedOnToolsVersion = 26.2;
TestTargetID = 5218F9052FC4C9EF00CAF7B7;
};
+ 52C72BC72FC7314D009928CB = {
+ CreatedOnToolsVersion = 26.2;
+ };
+ 52C72BDA2FC73171009928CB = {
+ CreatedOnToolsVersion = 26.2;
+ };
};
};
buildConfigurationList = 5218F9012FC4C9EF00CAF7B7 /* Build configuration list for PBXProject "World Manager for Minecraft" */;
@@ -203,6 +390,8 @@
5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
5218F9142FC4C9F100CAF7B7 /* World Manager for MinecraftTests */,
5218F91E2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */,
+ 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */,
+ 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */,
);
};
/* End PBXProject section */
@@ -229,6 +418,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 52C72BC62FC7314D009928CB /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 52C72BD92FC73171009928CB /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -253,6 +456,20 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 52C72BC42FC7314D009928CB /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
+ 52C72BD72FC73171009928CB /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
@@ -266,6 +483,16 @@
target = 5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */;
targetProxy = 5218F9202FC4C9F100CAF7B7 /* PBXContainerItemProxy */;
};
+ 52C72BD12FC7314E009928CB /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */;
+ targetProxy = 52C72BD02FC7314E009928CB /* PBXContainerItemProxy */;
+ };
+ 52C72BE72FC73171009928CB /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ target = 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */;
+ targetProxy = 52C72BE62FC73171009928CB /* PBXContainerItemProxy */;
+ };
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
@@ -399,6 +626,7 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "World-Manager-for-Minecraft-Info.plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -431,6 +659,7 @@
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = "World-Manager-for-Minecraft-Info.plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = (
@@ -527,6 +756,122 @@
};
name = Release;
};
+ 52C72BD52FC7314E009928CB /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "";
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MinecraftPackageThumbnailExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackageThumbnailExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackageThumbnailExtension";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 52C72BD62FC7314E009928CB /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "";
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MinecraftPackageThumbnailExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackageThumbnailExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackageThumbnailExtension";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
+ 52C72BEB2FC73171009928CB /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "";
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MinecraftPackagePreviewExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackagePreviewExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackagePreviewExtension";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Debug;
+ };
+ 52C72BEC2FC73171009928CB /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ CODE_SIGN_STYLE = Automatic;
+ CURRENT_PROJECT_VERSION = 1;
+ DEVELOPMENT_ASSET_PATHS = "";
+ ENABLE_APP_SANDBOX = YES;
+ ENABLE_USER_SELECTED_FILES = readonly;
+ GENERATE_INFOPLIST_FILE = YES;
+ INFOPLIST_FILE = MinecraftPackagePreviewExtension/Info.plist;
+ INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackagePreviewExtension;
+ INFOPLIST_KEY_NSHumanReadableCopyright = "";
+ LD_RUNPATH_SEARCH_PATHS = (
+ "$(inherited)",
+ "@executable_path/../Frameworks",
+ "@executable_path/../../../../Frameworks",
+ );
+ MARKETING_VERSION = 1.0;
+ PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackagePreviewExtension";
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SKIP_INSTALL = YES;
+ STRING_CATALOG_GENERATE_SYMBOLS = YES;
+ SWIFT_APPROACHABLE_CONCURRENCY = YES;
+ SWIFT_EMIT_LOC_STRINGS = YES;
+ SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
+ SWIFT_VERSION = 5.0;
+ };
+ name = Release;
+ };
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@@ -566,6 +911,24 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 52C72BD42FC7314E009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackageThumbnailExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 52C72BD52FC7314E009928CB /* Debug */,
+ 52C72BD62FC7314E009928CB /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
+ 52C72BEA2FC73171009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackagePreviewExtension" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 52C72BEB2FC73171009928CB /* Debug */,
+ 52C72BEC2FC73171009928CB /* Release */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
/* End XCConfigurationList section */
};
rootObject = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;
diff --git a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme
new file mode 100644
index 0000000..7816517
--- /dev/null
+++ b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackagePreviewExtension.xcscheme
@@ -0,0 +1,96 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme
new file mode 100644
index 0000000..bf51027
--- /dev/null
+++ b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/MinecraftPackageThumbnailExtension.xcscheme
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/World Manager for Minecraft.xcscheme b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/World Manager for Minecraft.xcscheme
new file mode 100644
index 0000000..084ae1c
--- /dev/null
+++ b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/World Manager for Minecraft.xcscheme
@@ -0,0 +1,78 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/World Manager for MinecraftTests.xcscheme b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/World Manager for MinecraftTests.xcscheme
new file mode 100644
index 0000000..10f64bc
--- /dev/null
+++ b/World Manager for Minecraft.xcodeproj/xcshareddata/xcschemes/World Manager for MinecraftTests.xcscheme
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/World Manager for Minecraft.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist b/World Manager for Minecraft.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist
index 1eb202a..583a924 100644
--- a/World Manager for Minecraft.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist
+++ b/World Manager for Minecraft.xcodeproj/xcuserdata/john.xcuserdatad/xcschemes/xcschememanagement.plist
@@ -4,11 +4,59 @@
SchemeUserState
+ MinecraftPackagePreviewExtension.xcscheme_^#shared#^_
+
+ orderHint
+ 1
+
+ MinecraftPackageThumbnailExtension.xcscheme_^#shared#^_
+
+ orderHint
+ 2
+
World Manager for Minecraft.xcscheme_^#shared#^_
+
+ orderHint
+ 4
+
+ World Manager for MinecraftTests.xcscheme_^#shared#^_
+
+ orderHint
+ 3
+
+ World Manager for MinecraftUITests.xcscheme_^#shared#^_
orderHint
0
+ SuppressBuildableAutocreation
+
+ 5218F9052FC4C9EF00CAF7B7
+
+ primary
+
+
+ 5218F9142FC4C9F100CAF7B7
+
+ primary
+
+
+ 52C72BAE2FC72940009928CB
+
+ primary
+
+
+ 52C72BC72FC7314D009928CB
+
+ primary
+
+
+ 52C72BDA2FC73171009928CB
+
+ primary
+
+
+
diff --git a/World Manager for Minecraft/ContentView.swift b/World Manager for Minecraft/ContentView.swift
index 390b22e..2ed5ec3 100644
--- a/World Manager for Minecraft/ContentView.swift
+++ b/World Manager for Minecraft/ContentView.swift
@@ -42,7 +42,7 @@ struct ContentView: View {
var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView(
- localSources: library.localSources,
+ sources: library.sidebarSources,
connectedDevices: library.connectedDevices,
selection: $selectedSidebarSelection,
footerState: library.sidebarFooterState,
@@ -58,14 +58,7 @@ struct ContentView: View {
removeSource(source.id)
},
revealFooterURLAction: revealURLInFinder(_:),
- filters: sidebarFilters(for:),
- matchedSource: { entry in
- guard let sourceID = entry.matchedSourceID else {
- return nil
- }
-
- return library.source(withID: sourceID)
- }
+ filters: sidebarFilters(for:)
)
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
} content: {
@@ -144,7 +137,13 @@ struct ContentView: View {
}
)
}
+ .task {
+ AppTerminationCoordinator.shared.register(library: library)
+ }
.disabled(library.isRestoringPersistedSources)
+ .onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
+ library.shutdown()
+ }
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_128x128.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_128x128.png
new file mode 100644
index 0000000..a6b2fbe
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_128x128.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_128x128@2x.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_128x128@2x.png
new file mode 100644
index 0000000..0f49bb1
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_128x128@2x.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_16x16.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_16x16.png
new file mode 100644
index 0000000..1ae1ded
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_16x16.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_16x16@2x.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_16x16@2x.png
new file mode 100644
index 0000000..5effbde
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_16x16@2x.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_256x256.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_256x256.png
new file mode 100644
index 0000000..0f49bb1
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_256x256.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_256x256@2x.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_256x256@2x.png
new file mode 100644
index 0000000..de2f973
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_256x256@2x.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_32x32.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_32x32.png
new file mode 100644
index 0000000..5effbde
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_32x32.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_32x32@2x.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_32x32@2x.png
new file mode 100644
index 0000000..cf047fd
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_32x32@2x.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_512x512.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_512x512.png
new file mode 100644
index 0000000..de2f973
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_512x512.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_512x512@2x.png b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_512x512@2x.png
new file mode 100644
index 0000000..76ffe2e
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.iconset/icon_512x512@2x.png differ
diff --git a/World Manager for Minecraft/MinecraftVoxelDocument.png b/World Manager for Minecraft/MinecraftVoxelDocument.png
new file mode 100644
index 0000000..76ffe2e
Binary files /dev/null and b/World Manager for Minecraft/MinecraftVoxelDocument.png differ
diff --git a/World Manager for Minecraft/Models/MinecraftContentItem.swift b/World Manager for Minecraft/Models/MinecraftContentItem.swift
index 3f8e647..74f3fce 100644
--- a/World Manager for Minecraft/Models/MinecraftContentItem.swift
+++ b/World Manager for Minecraft/Models/MinecraftContentItem.swift
@@ -125,6 +125,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
let collectionRootURL: URL
var displayName: String
var iconURL: URL?
+ var hasKnownIcon: Bool
var lastPlayedDate: Date?
var modifiedDate: Date?
var sizeBytes: Int64?
@@ -134,6 +135,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
var packReferences: [ContentPackReference]
var worldMetadata: WorldMetadata?
var metadataLoaded: Bool
+ var previewLoaded: Bool
var sizeLoaded: Bool
nonisolated init(
@@ -143,6 +145,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
collectionRootURL: URL,
displayName: String? = nil,
iconURL: URL? = nil,
+ hasKnownIcon: Bool = false,
lastPlayedDate: Date? = nil,
modifiedDate: Date? = nil,
sizeBytes: Int64? = nil,
@@ -152,6 +155,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
packReferences: [ContentPackReference] = [],
worldMetadata: WorldMetadata? = nil,
metadataLoaded: Bool = false,
+ previewLoaded: Bool = false,
sizeLoaded: Bool = false
) {
self.id = folderURL.standardizedFileURL
@@ -161,6 +165,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
self.collectionRootURL = collectionRootURL
self.displayName = displayName ?? folderName
self.iconURL = iconURL
+ self.hasKnownIcon = hasKnownIcon
self.lastPlayedDate = lastPlayedDate
self.modifiedDate = modifiedDate
self.sizeBytes = sizeBytes
@@ -170,6 +175,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
self.packReferences = packReferences
self.worldMetadata = worldMetadata
self.metadataLoaded = metadataLoaded
+ self.previewLoaded = previewLoaded
self.sizeLoaded = sizeLoaded
}
diff --git a/World Manager for Minecraft/Models/MinecraftSource.swift b/World Manager for Minecraft/Models/MinecraftSource.swift
index 93e5e7e..9f5223d 100644
--- a/World Manager for Minecraft/Models/MinecraftSource.swift
+++ b/World Manager for Minecraft/Models/MinecraftSource.swift
@@ -25,6 +25,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
var isScanning: Bool
var scanStatus: String
var scanError: String?
+ var scanDiagnostic: String?
+ var scanProgress: Double?
var indexedItemCount: Int
var indexedDetailCount: Int
var lastScanDate: Date?
@@ -61,6 +63,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
self.isScanning = false
self.scanStatus = ""
self.scanError = nil
+ self.scanDiagnostic = nil
+ self.scanProgress = nil
self.indexedItemCount = 0
self.indexedDetailCount = 0
self.lastScanDate = nil
diff --git a/World Manager for Minecraft/PreviewFixtures.swift b/World Manager for Minecraft/PreviewFixtures.swift
index 7e9a438..e45b283 100644
--- a/World Manager for Minecraft/PreviewFixtures.swift
+++ b/World Manager for Minecraft/PreviewFixtures.swift
@@ -290,7 +290,7 @@ struct SidebarColumnPreviewContainer: View {
var body: some View {
NavigationStack {
SourcesSidebarView(
- localSources: PreviewFixtures.allSources,
+ sources: PreviewFixtures.allSources,
connectedDevices: [],
selection: $selection,
footerState: PreviewFixtures.sidebarFooter,
@@ -300,8 +300,7 @@ struct SidebarColumnPreviewContainer: View {
rescanSourceAction: { _ in },
removeSourceAction: { _ in },
revealFooterURLAction: { _ in },
- filters: PreviewFixtures.sidebarFilters(for:),
- matchedSource: { _ in nil }
+ filters: PreviewFixtures.sidebarFilters(for:)
)
}
}
diff --git a/World Manager for Minecraft/QuickLook/MinecraftPackageQuickLookModel.swift b/World Manager for Minecraft/QuickLook/MinecraftPackageQuickLookModel.swift
new file mode 100644
index 0000000..e087088
--- /dev/null
+++ b/World Manager for Minecraft/QuickLook/MinecraftPackageQuickLookModel.swift
@@ -0,0 +1,89 @@
+//
+// MinecraftPackageQuickLookModel.swift
+// World Manager for Minecraft
+//
+// Created by OpenAI on 2026-05-26.
+//
+
+import Foundation
+
+struct MinecraftPackageQuickLookFact: Sendable, Hashable {
+ let label: String
+ let value: String
+}
+
+struct MinecraftPackageQuickLookModel: Sendable, Hashable {
+ let title: String
+ let subtitle: String
+ let facts: [MinecraftPackageQuickLookFact]
+}
+
+enum MinecraftPackageQuickLookModelBuilder {
+ nonisolated static func makeModel(from inspection: MinecraftPackageInspector.InspectionResult) -> MinecraftPackageQuickLookModel {
+ let subtitle = subtitle(for: inspection.contentType)
+ var facts: [MinecraftPackageQuickLookFact] = []
+
+ if let version = inspection.manifestMetadata?.version {
+ facts.append(MinecraftPackageQuickLookFact(label: "Version", value: version))
+ }
+
+ if let minimumEngineVersion = inspection.manifestMetadata?.minimumEngineVersion {
+ facts.append(MinecraftPackageQuickLookFact(label: "Minimum Engine", value: minimumEngineVersion))
+ }
+
+ if let uuid = inspection.manifestMetadata?.uuid {
+ facts.append(MinecraftPackageQuickLookFact(label: "UUID", value: uuid))
+ }
+
+ if let worldMetadata = inspection.worldMetadata {
+ if let gameMode = worldMetadata.gameMode {
+ facts.append(MinecraftPackageQuickLookFact(label: "Game Mode", value: gameMode))
+ }
+ if let difficulty = worldMetadata.difficulty {
+ facts.append(MinecraftPackageQuickLookFact(label: "Difficulty", value: difficulty))
+ }
+ if let lastPlayedDate = worldMetadata.lastPlayedDate {
+ facts.append(
+ MinecraftPackageQuickLookFact(
+ label: "Last Played",
+ value: formattedQuickLookDate(lastPlayedDate)
+ )
+ )
+ }
+ if let seed = worldMetadata.seed {
+ facts.append(MinecraftPackageQuickLookFact(label: "Seed", value: seed))
+ }
+ if let version = worldMetadata.lastOpenedWithVersion {
+ facts.append(MinecraftPackageQuickLookFact(label: "Bedrock Version", value: version))
+ }
+ }
+
+ return MinecraftPackageQuickLookModel(
+ title: inspection.displayName,
+ subtitle: subtitle,
+ facts: facts
+ )
+ }
+
+ nonisolated private static func subtitle(for contentType: MinecraftContentType) -> String {
+ switch contentType {
+ case .world:
+ return "Minecraft World"
+ case .behaviorPack:
+ return "Behavior Pack"
+ case .resourcePack:
+ return "Resource Pack"
+ case .skinPack:
+ return "Skin Pack"
+ case .worldTemplate:
+ return "World Template"
+ }
+ }
+
+ nonisolated private static func formattedQuickLookDate(_ date: Date) -> String {
+ let formatter = DateFormatter()
+ formatter.dateStyle = .medium
+ formatter.timeStyle = .short
+ return formatter.string(from: date)
+ }
+}
diff --git a/World Manager for Minecraft/QuickLook/MinecraftPackageThumbnailRenderer.swift b/World Manager for Minecraft/QuickLook/MinecraftPackageThumbnailRenderer.swift
new file mode 100644
index 0000000..9166131
--- /dev/null
+++ b/World Manager for Minecraft/QuickLook/MinecraftPackageThumbnailRenderer.swift
@@ -0,0 +1,187 @@
+//
+// MinecraftPackageThumbnailRenderer.swift
+// World Manager for Minecraft
+//
+// Created by OpenAI on 2026-05-26.
+//
+
+import AppKit
+import Foundation
+
+enum MinecraftPackageThumbnailRenderer {
+ nonisolated static func makeThumbnail(
+ for inspection: MinecraftPackageInspector.InspectionResult,
+ size: CGSize,
+ scale: CGFloat = 1
+ ) -> CGImage? {
+ let pixelSize = CGSize(
+ width: max(size.width * scale, 1),
+ height: max(size.height * scale, 1)
+ )
+
+ guard
+ let bitmap = NSBitmapImageRep(
+ bitmapDataPlanes: nil,
+ pixelsWide: max(Int(pixelSize.width.rounded(.up)), 1),
+ pixelsHigh: max(Int(pixelSize.height.rounded(.up)), 1),
+ bitsPerSample: 8,
+ samplesPerPixel: 4,
+ hasAlpha: true,
+ isPlanar: false,
+ colorSpaceName: .deviceRGB,
+ bytesPerRow: 0,
+ bitsPerPixel: 0
+ )
+ else {
+ return nil
+ }
+
+ bitmap.size = pixelSize
+
+ NSGraphicsContext.saveGraphicsState()
+ defer { NSGraphicsContext.restoreGraphicsState() }
+
+ guard let context = NSGraphicsContext(bitmapImageRep: bitmap) else {
+ return nil
+ }
+
+ NSGraphicsContext.current = context
+ let rect = CGRect(origin: .zero, size: pixelSize)
+
+ if let iconURL = inspection.iconURL,
+ let iconImage = NSImage(contentsOf: iconURL) {
+ drawSquareThumbnail(iconImage, in: rect)
+ } else {
+ drawFallbackThumbnail(for: inspection.contentType, in: rect)
+ }
+
+ context.flushGraphics()
+ return bitmap.cgImage
+ }
+
+ nonisolated private static func drawSquareThumbnail(_ image: NSImage, in rect: CGRect) {
+ NSColor.clear.setFill()
+ rect.fill()
+
+ let cornerRadius = min(rect.width, rect.height) * 0.10
+ let clipRect = rect.insetBy(dx: max(rect.width * 0.02, 1), dy: max(rect.height * 0.02, 1))
+ let clipPath = NSBezierPath(
+ roundedRect: clipRect,
+ xRadius: cornerRadius,
+ yRadius: cornerRadius
+ )
+ clipPath.addClip()
+
+ let imageSize = image.size
+ guard imageSize.width > 0, imageSize.height > 0 else {
+ image.draw(in: clipRect)
+ return
+ }
+
+ let scale = max(clipRect.width / imageSize.width, clipRect.height / imageSize.height)
+ let drawSize = CGSize(width: imageSize.width * scale, height: imageSize.height * scale)
+ let drawRect = CGRect(
+ x: clipRect.midX - drawSize.width / 2,
+ y: clipRect.midY - drawSize.height / 2,
+ width: drawSize.width,
+ height: drawSize.height
+ )
+
+ image.draw(in: drawRect)
+
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ let borderPath = NSBezierPath(
+ roundedRect: clipRect.insetBy(dx: 0.5, dy: 0.5),
+ xRadius: max(cornerRadius - 0.5, 0),
+ yRadius: max(cornerRadius - 0.5, 0)
+ )
+ borderPath.lineWidth = max(1, rect.width * 0.01)
+ borderPath.stroke()
+ }
+
+ nonisolated private static func drawFallbackThumbnail(
+ for contentType: MinecraftContentType,
+ in rect: CGRect
+ ) {
+ let path = NSBezierPath(
+ roundedRect: rect,
+ xRadius: rect.width * 0.10,
+ yRadius: rect.width * 0.10
+ )
+
+ let gradient = NSGradient(colors: backgroundColors(for: contentType))
+ gradient?.draw(in: path, angle: -45)
+
+ let badgeRect = rect.insetBy(dx: rect.width * 0.18, dy: rect.height * 0.18)
+ drawVoxelBadge(in: badgeRect)
+
+ NSColor.black.withAlphaComponent(0.10).setStroke()
+ path.lineWidth = max(1, rect.width * 0.01)
+ path.stroke()
+ }
+
+ nonisolated private static func drawVoxelBadge(in rect: CGRect) {
+ let topFace = NSBezierPath()
+ topFace.move(to: CGPoint(x: rect.midX, y: rect.maxY))
+ topFace.line(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ topFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ topFace.line(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ topFace.close()
+ NSColor(calibratedRed: 0.78, green: 0.94, blue: 0.63, alpha: 1).setFill()
+ topFace.fill()
+
+ let leftFace = NSBezierPath()
+ leftFace.move(to: CGPoint(x: rect.minX, y: rect.maxY - rect.height * 0.18))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ leftFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ leftFace.line(to: CGPoint(x: rect.minX, y: rect.minY + rect.height * 0.18))
+ leftFace.close()
+ NSColor(calibratedRed: 0.34, green: 0.61, blue: 0.24, alpha: 1).setFill()
+ leftFace.fill()
+
+ let rightFace = NSBezierPath()
+ rightFace.move(to: CGPoint(x: rect.maxX, y: rect.maxY - rect.height * 0.18))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.maxY - rect.height * 0.36))
+ rightFace.line(to: CGPoint(x: rect.midX, y: rect.minY))
+ rightFace.line(to: CGPoint(x: rect.maxX, y: rect.minY + rect.height * 0.18))
+ rightFace.close()
+ NSColor(calibratedRed: 0.14, green: 0.34, blue: 0.13, alpha: 1).setFill()
+ rightFace.fill()
+
+ NSColor.black.withAlphaComponent(0.18).setStroke()
+ [topFace, leftFace, rightFace].forEach {
+ $0.lineWidth = max(1, rect.width * 0.02)
+ $0.stroke()
+ }
+ }
+
+ nonisolated private static func backgroundColors(for contentType: MinecraftContentType) -> [NSColor] {
+ switch contentType {
+ case .world:
+ return [
+ NSColor(calibratedRed: 0.18, green: 0.48, blue: 0.25, alpha: 1),
+ NSColor(calibratedRed: 0.47, green: 0.75, blue: 0.31, alpha: 1)
+ ]
+ case .behaviorPack:
+ return [
+ NSColor(calibratedRed: 0.58, green: 0.29, blue: 0.15, alpha: 1),
+ NSColor(calibratedRed: 0.86, green: 0.60, blue: 0.22, alpha: 1)
+ ]
+ case .resourcePack:
+ return [
+ NSColor(calibratedRed: 0.12, green: 0.38, blue: 0.66, alpha: 1),
+ NSColor(calibratedRed: 0.31, green: 0.68, blue: 0.85, alpha: 1)
+ ]
+ case .skinPack:
+ return [
+ NSColor(calibratedRed: 0.53, green: 0.19, blue: 0.43, alpha: 1),
+ NSColor(calibratedRed: 0.84, green: 0.39, blue: 0.62, alpha: 1)
+ ]
+ case .worldTemplate:
+ return [
+ NSColor(calibratedRed: 0.23, green: 0.23, blue: 0.54, alpha: 1),
+ NSColor(calibratedRed: 0.53, green: 0.52, blue: 0.89, alpha: 1)
+ ]
+ }
+ }
+}
diff --git a/World Manager for Minecraft/QuickLook/MinecraftPackageTypes.swift b/World Manager for Minecraft/QuickLook/MinecraftPackageTypes.swift
new file mode 100644
index 0000000..04fc7a4
--- /dev/null
+++ b/World Manager for Minecraft/QuickLook/MinecraftPackageTypes.swift
@@ -0,0 +1,68 @@
+//
+// MinecraftPackageTypes.swift
+// World Manager for Minecraft
+//
+// Created by OpenAI on 2026-05-26.
+//
+
+import Foundation
+import UniformTypeIdentifiers
+
+struct MinecraftPackageTypeDefinition: Sendable, Hashable {
+ let contentType: MinecraftContentType?
+ let pathExtension: String
+ let utTypeIdentifier: String
+ let displayName: String
+}
+
+enum MinecraftPackageTypes {
+ nonisolated static let world = MinecraftPackageTypeDefinition(
+ contentType: .world,
+ pathExtension: "mcworld",
+ utTypeIdentifier: "us.b-wells.minecraft.mcworld",
+ displayName: "Minecraft World"
+ )
+
+ nonisolated static let pack = MinecraftPackageTypeDefinition(
+ contentType: nil,
+ pathExtension: "mcpack",
+ utTypeIdentifier: "us.b-wells.minecraft.mcpack",
+ displayName: "Minecraft Pack"
+ )
+
+ nonisolated static let template = MinecraftPackageTypeDefinition(
+ contentType: .worldTemplate,
+ pathExtension: "mctemplate",
+ utTypeIdentifier: "us.b-wells.minecraft.mctemplate",
+ displayName: "Minecraft World Template"
+ )
+
+ nonisolated static let addon = MinecraftPackageTypeDefinition(
+ contentType: nil,
+ pathExtension: "mcaddon",
+ utTypeIdentifier: "us.b-wells.minecraft.mcaddon",
+ displayName: "Minecraft Add-On"
+ )
+
+ nonisolated static let all: [MinecraftPackageTypeDefinition] = [
+ world,
+ pack,
+ template,
+ addon
+ ]
+
+ nonisolated static func definition(for pathExtension: String) -> MinecraftPackageTypeDefinition? {
+ all.first { $0.pathExtension == pathExtension.lowercased() }
+ }
+
+ nonisolated static func supportedContentType(for url: URL) -> UTType? {
+ definition(for: url.pathExtension).flatMap { UTType($0.utTypeIdentifier) }
+ }
+}
+
+extension UTType {
+ static let minecraftWorld = UTType(exportedAs: MinecraftPackageTypes.world.utTypeIdentifier, conformingTo: .zip)
+ static let minecraftPack = UTType(exportedAs: MinecraftPackageTypes.pack.utTypeIdentifier, conformingTo: .zip)
+ static let minecraftTemplate = UTType(exportedAs: MinecraftPackageTypes.template.utTypeIdentifier, conformingTo: .zip)
+ static let minecraftAddon = UTType(exportedAs: MinecraftPackageTypes.addon.utTypeIdentifier, conformingTo: .zip)
+}
diff --git a/World Manager for Minecraft/Services/ContentPackageExporter.swift b/World Manager for Minecraft/Services/ContentPackageExporter.swift
index 2bcbd9c..e41044d 100644
--- a/World Manager for Minecraft/Services/ContentPackageExporter.swift
+++ b/World Manager for Minecraft/Services/ContentPackageExporter.swift
@@ -176,6 +176,7 @@ enum ContentPackageExporter {
}
try await AppleMobileDeviceAccess.mirrorSubtree(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteItemPath,
destinationDirectoryURL: destinationURL
diff --git a/World Manager for Minecraft/Services/MinecraftContentMetadataReader.swift b/World Manager for Minecraft/Services/MinecraftContentMetadataReader.swift
new file mode 100644
index 0000000..f7aa022
--- /dev/null
+++ b/World Manager for Minecraft/Services/MinecraftContentMetadataReader.swift
@@ -0,0 +1,183 @@
+//
+// MinecraftContentMetadataReader.swift
+// World Manager for Minecraft
+//
+// Created by OpenAI on 2026-05-26.
+//
+
+import Foundation
+
+struct MinecraftManifestMetadata: Sendable, Hashable {
+ let name: String
+ let uuid: String?
+ let version: String?
+ let minimumEngineVersion: String?
+}
+
+enum MinecraftContentMetadataReader {
+ nonisolated static func displayName(
+ for directoryURL: URL,
+ contentType: MinecraftContentType,
+ fallbackName: String,
+ fileManager: FileManager = .default
+ ) -> String {
+ switch contentType {
+ case .world:
+ let levelNameURL = directoryURL.appendingPathComponent("levelname.txt")
+ guard
+ let name = try? String(contentsOf: levelNameURL, encoding: .utf8)
+ .trimmingCharacters(in: .whitespacesAndNewlines),
+ !name.isEmpty
+ else {
+ return fallbackName
+ }
+
+ return name
+ case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
+ if let manifestName = manifestMetadata(in: directoryURL, fileManager: fileManager)?.name {
+ return manifestName
+ }
+
+ return fallbackName
+ }
+ }
+
+ nonisolated static func iconURL(
+ for directoryURL: URL,
+ contentType: MinecraftContentType,
+ fileManager: FileManager = .default
+ ) -> URL? {
+ let candidateNames: [String]
+
+ switch contentType {
+ case .world:
+ candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
+ case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
+ candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
+ }
+
+ for candidateName in candidateNames {
+ let candidateURL = directoryURL.appendingPathComponent(candidateName)
+ if fileManager.fileExists(atPath: candidateURL.path) {
+ return candidateURL
+ }
+ }
+
+ return nil
+ }
+
+ nonisolated static func packIconURL(
+ in directoryURL: URL,
+ fileManager: FileManager = .default
+ ) -> URL? {
+ let candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
+
+ for candidateName in candidateNames {
+ let candidateURL = directoryURL.appendingPathComponent(candidateName)
+ if fileManager.fileExists(atPath: candidateURL.path) {
+ return candidateURL
+ }
+ }
+
+ return nil
+ }
+
+ nonisolated static func worldMetadata(
+ in directoryURL: URL,
+ fileManager: FileManager = .default
+ ) -> WorldMetadata? {
+ let levelDatURL = directoryURL.appendingPathComponent("level.dat")
+ guard fileManager.fileExists(atPath: levelDatURL.path) else {
+ return nil
+ }
+
+ return BedrockLevelMetadataDecoder.decode(fromLevelDatAt: levelDatURL)
+ }
+
+ nonisolated static func manifestMetadata(
+ in directoryURL: URL,
+ fileManager: FileManager = .default
+ ) -> MinecraftManifestMetadata? {
+ let manifestURL = directoryURL.appendingPathComponent("manifest.json")
+ guard
+ fileManager.fileExists(atPath: manifestURL.path),
+ let data = try? Data(contentsOf: manifestURL),
+ let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
+ let header = jsonObject["header"] as? [String: Any]
+ else {
+ return nil
+ }
+
+ let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
+ $0.isEmpty ? nil : $0
+ } ?? directoryURL.lastPathComponent
+
+ return MinecraftManifestMetadata(
+ name: name,
+ uuid: (header["uuid"] as? String)?.lowercased(),
+ version: versionString(from: header["version"]),
+ minimumEngineVersion: versionString(from: header["min_engine_version"])
+ )
+ }
+
+ nonisolated static func versionString(from value: Any?) -> String? {
+ if let versionString = value as? String, !versionString.isEmpty {
+ return versionString
+ }
+
+ if let versionArray = value as? [Any] {
+ let components = versionArray.compactMap { component -> String? in
+ if let intComponent = component as? Int {
+ return String(intComponent)
+ }
+ if let stringComponent = component as? String {
+ return stringComponent
+ }
+ return nil
+ }
+
+ return components.isEmpty ? nil : components.joined(separator: ".")
+ }
+
+ return nil
+ }
+
+ nonisolated static func inferredPackContentType(
+ for directoryURL: URL,
+ fileManager: FileManager = .default
+ ) -> MinecraftContentType {
+ let manifestURL = directoryURL.appendingPathComponent("manifest.json")
+ guard
+ fileManager.fileExists(atPath: manifestURL.path),
+ let data = try? Data(contentsOf: manifestURL),
+ let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
+ else {
+ return .behaviorPack
+ }
+
+ if let modules = jsonObject["modules"] as? [[String: Any]] {
+ let normalizedTypes = modules.compactMap { ($0["type"] as? String)?.lowercased() }
+ if normalizedTypes.contains("resources") || normalizedTypes.contains("client_data") {
+ return .resourcePack
+ }
+ if normalizedTypes.contains("skin_pack") {
+ return .skinPack
+ }
+ if normalizedTypes.contains("data") || normalizedTypes.contains("script") || normalizedTypes.contains("javascript") {
+ return .behaviorPack
+ }
+ }
+
+ if let metadata = jsonObject["metadata"] as? [String: Any],
+ let productType = (metadata["product_type"] as? String)?.lowercased() {
+ if productType.contains("skin") {
+ return .skinPack
+ }
+ if productType.contains("resource") {
+ return .resourcePack
+ }
+ }
+
+ return .behaviorPack
+ }
+}
diff --git a/World Manager for Minecraft/Services/MinecraftPackageInspector.swift b/World Manager for Minecraft/Services/MinecraftPackageInspector.swift
new file mode 100644
index 0000000..359233c
--- /dev/null
+++ b/World Manager for Minecraft/Services/MinecraftPackageInspector.swift
@@ -0,0 +1,255 @@
+//
+// MinecraftPackageInspector.swift
+// World Manager for Minecraft
+//
+// Created by OpenAI on 2026-05-26.
+//
+
+import Foundation
+
+enum MinecraftPackageInspector {
+ struct InspectionResult: Sendable {
+ let archiveURL: URL
+ let extractedRootURL: URL
+ let contentRootURL: URL
+ let contentType: MinecraftContentType
+ let displayName: String
+ let iconURL: URL?
+ let worldMetadata: WorldMetadata?
+ let manifestMetadata: MinecraftManifestMetadata?
+ }
+
+ enum InspectionError: LocalizedError {
+ case unsupportedFileType(String)
+ case invalidArchiveLayout
+
+ var errorDescription: String? {
+ switch self {
+ case .unsupportedFileType(let pathExtension):
+ return "Unsupported Minecraft package type: .\(pathExtension)"
+ case .invalidArchiveLayout:
+ return "The Minecraft package did not contain a valid world or pack layout."
+ }
+ }
+ }
+
+ nonisolated static let supportedPathExtensions: Set = [
+ "mcaddon",
+ "mcpack",
+ "mctemplate",
+ "mcworld"
+ ]
+
+ nonisolated static func inspectArchive(at archiveURL: URL) throws -> InspectionResult {
+ let normalizedArchiveURL = archiveURL.standardizedFileURL
+ let pathExtension = normalizedArchiveURL.pathExtension.lowercased()
+ guard supportedPathExtensions.contains(pathExtension) else {
+ throw InspectionError.unsupportedFileType(pathExtension)
+ }
+
+ let fileManager = FileManager.default
+ let extractionDirectoryURL = fileManager.temporaryDirectory
+ .appendingPathComponent("MinecraftPackageInspection", isDirectory: true)
+ .appendingPathComponent(UUID().uuidString, isDirectory: true)
+
+ try fileManager.createDirectory(at: extractionDirectoryURL, withIntermediateDirectories: true)
+
+ do {
+ let archive = try ZipArchiveReader(url: normalizedArchiveURL)
+ let contentRootURL = try materializedContentRootURL(
+ for: archive,
+ archivePathExtension: pathExtension,
+ in: extractionDirectoryURL,
+ fileManager: fileManager
+ )
+ let contentType = try resolvedContentType(
+ forArchivePathExtension: pathExtension,
+ contentRootURL: contentRootURL,
+ fileManager: fileManager
+ )
+ let displayName = MinecraftContentMetadataReader.displayName(
+ for: contentRootURL,
+ contentType: contentType,
+ fallbackName: normalizedArchiveURL.deletingPathExtension().lastPathComponent,
+ fileManager: fileManager
+ )
+ let worldMetadata: WorldMetadata? =
+ contentType == .world ? MinecraftContentMetadataReader.worldMetadata(in: contentRootURL, fileManager: fileManager) : nil
+ let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: contentRootURL, fileManager: fileManager)
+ let iconURL = MinecraftContentMetadataReader.iconURL(
+ for: contentRootURL,
+ contentType: contentType,
+ fileManager: fileManager
+ )
+
+ return InspectionResult(
+ archiveURL: normalizedArchiveURL,
+ extractedRootURL: extractionDirectoryURL,
+ contentRootURL: contentRootURL,
+ contentType: contentType,
+ displayName: displayName,
+ iconURL: iconURL,
+ worldMetadata: worldMetadata,
+ manifestMetadata: manifestMetadata
+ )
+ } catch {
+ try? fileManager.removeItem(at: extractionDirectoryURL)
+ throw error
+ }
+ }
+
+ nonisolated static func cleanup(_ result: InspectionResult) {
+ try? FileManager.default.removeItem(at: result.extractedRootURL)
+ }
+
+ nonisolated private static func resolvedContentType(
+ forArchivePathExtension pathExtension: String,
+ contentRootURL: URL,
+ fileManager: FileManager
+ ) throws -> MinecraftContentType {
+ switch pathExtension {
+ case "mcworld":
+ return .world
+ case "mctemplate":
+ return .worldTemplate
+ case "mcpack", "mcaddon":
+ return MinecraftContentMetadataReader.inferredPackContentType(for: contentRootURL, fileManager: fileManager)
+ default:
+ throw InspectionError.unsupportedFileType(pathExtension)
+ }
+ }
+
+ nonisolated private static func materializedContentRootURL(
+ for archive: ZipArchiveReader,
+ archivePathExtension: String,
+ in extractionDirectoryURL: URL,
+ fileManager: FileManager
+ ) throws -> URL {
+ let contentRootPath = try resolvedContentRootPath(
+ in: archive.entries,
+ archivePathExtension: archivePathExtension
+ )
+ let contentRootURL = extractionDirectoryURL.appendingPathComponent(contentRootPath, isDirectory: true)
+ try fileManager.createDirectory(at: contentRootURL, withIntermediateDirectories: true)
+
+ let requiredRelativePaths = requiredMetadataRelativePaths(
+ in: archive.entries,
+ contentRootPath: contentRootPath
+ )
+
+ for relativePath in requiredRelativePaths {
+ let fullPath = contentRootPath.isEmpty ? relativePath : "\(contentRootPath)/\(relativePath)"
+ guard let entry = archive.entry(named: fullPath) else {
+ continue
+ }
+
+ let destinationURL = contentRootURL.appendingPathComponent(relativePath)
+ try fileManager.createDirectory(at: destinationURL.deletingLastPathComponent(), withIntermediateDirectories: true)
+ try archive.extract(entry).write(to: destinationURL)
+ }
+
+ return contentRootURL
+ }
+
+ nonisolated private static func resolvedContentRootPath(
+ in entries: [ZipArchiveEntry],
+ archivePathExtension: String
+ ) throws -> String {
+ let filePaths = entries
+ .filter { !$0.isDirectory }
+ .map(\.path)
+
+ if containsContentMarkers(in: filePaths, prefix: "") {
+ return ""
+ }
+
+ let rootCandidates = Set(
+ filePaths.compactMap { path -> String? in
+ guard let firstComponent = path.split(separator: "/").first else {
+ return nil
+ }
+ return String(firstComponent)
+ }
+ ).sorted()
+
+ let matchingRoots = rootCandidates.filter { containsContentMarkers(in: filePaths, prefix: $0) }
+ guard matchingRoots.count == 1 else {
+ throw InspectionError.invalidArchiveLayout
+ }
+
+ let root = matchingRoots[0]
+ if archivePathExtension == "mcaddon" {
+ return root
+ }
+
+ return root
+ }
+
+ nonisolated private static func containsContentMarkers(in filePaths: [String], prefix: String) -> Bool {
+ let normalizedPrefix = prefix.isEmpty ? "" : prefix + "/"
+
+ let worldMarkers = ["level.dat", "levelname.txt"]
+ let packMarkers = ["manifest.json", "pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
+
+ if worldMarkers.contains(where: { filePaths.contains(normalizedPrefix + $0) }) {
+ return true
+ }
+
+ if filePaths.contains(normalizedPrefix + "db") || filePaths.contains(where: { $0.hasPrefix(normalizedPrefix + "db/") }) {
+ return true
+ }
+
+ return packMarkers.contains(where: { filePaths.contains(normalizedPrefix + $0) })
+ }
+
+ nonisolated private static func requiredMetadataRelativePaths(
+ in entries: [ZipArchiveEntry],
+ contentRootPath: String
+ ) -> [String] {
+ let fullPrefix = contentRootPath.isEmpty ? "" : contentRootPath + "/"
+ let candidateNames = [
+ "manifest.json",
+ "level.dat",
+ "levelname.txt",
+ "world_icon.jpeg",
+ "world_icon.jpg",
+ "world_icon.png",
+ "pack_icon.png",
+ "pack_icon.jpeg",
+ "pack_icon.jpg"
+ ]
+
+ let availablePaths = Set(entries.filter { !$0.isDirectory }.map(\.path))
+ return candidateNames.filter { availablePaths.contains(fullPrefix + $0) }
+ }
+
+ nonisolated private static func looksLikeMinecraftContentRoot(
+ _ directoryURL: URL,
+ fileManager: FileManager
+ ) -> Bool {
+ let worldMarkers = [
+ "level.dat",
+ "levelname.txt"
+ ]
+ let packMarkers = [
+ "manifest.json",
+ "pack_icon.png",
+ "pack_icon.jpeg",
+ "pack_icon.jpg"
+ ]
+
+ if worldMarkers.contains(where: { fileManager.fileExists(atPath: directoryURL.appendingPathComponent($0).path) }) {
+ return true
+ }
+
+ if fileManager.fileExists(atPath: directoryURL.appendingPathComponent("db", isDirectory: true).path) {
+ return true
+ }
+
+ if packMarkers.contains(where: { fileManager.fileExists(atPath: directoryURL.appendingPathComponent($0).path) }) {
+ return true
+ }
+
+ return false
+ }
+}
diff --git a/World Manager for Minecraft/Services/ScanNotificationService.swift b/World Manager for Minecraft/Services/ScanNotificationService.swift
new file mode 100644
index 0000000..2258d35
--- /dev/null
+++ b/World Manager for Minecraft/Services/ScanNotificationService.swift
@@ -0,0 +1,84 @@
+//
+// ScanNotificationService.swift
+// World Manager for Minecraft
+//
+// Created by Codex on 2026-05-27.
+//
+
+import AppKit
+import Foundation
+import UserNotifications
+
+@MainActor
+protocol ScanNotificationServicing: AnyObject {
+ func requestAuthorizationIfNeeded() async
+ func notifyScanCompleted(for source: MinecraftSource, duration: TimeInterval) async
+}
+
+@MainActor
+final class ScanNotificationService: NSObject, ScanNotificationServicing {
+ static let shared = ScanNotificationService()
+
+ static let longScanThreshold: TimeInterval = 8
+
+ private let center: UNUserNotificationCenter
+
+ init(center: UNUserNotificationCenter = .current()) {
+ self.center = center
+ super.init()
+ }
+
+ func requestAuthorizationIfNeeded() async {
+ let settings = await center.notificationSettings()
+ guard settings.authorizationStatus == .notDetermined else {
+ return
+ }
+
+ do {
+ _ = try await center.requestAuthorization(options: [.alert, .sound])
+ } catch {
+ return
+ }
+ }
+
+ func notifyScanCompleted(for source: MinecraftSource, duration: TimeInterval) async {
+ guard shouldNotifyAboutCompletedScan(duration: duration, isAppActive: NSApp.isActive) else {
+ return
+ }
+
+ let settings = await center.notificationSettings()
+ guard settings.authorizationStatus == .authorized else {
+ return
+ }
+
+ let content = UNMutableNotificationContent()
+ content.title = "Scan complete"
+ content.subtitle = source.displayName
+ content.body = Self.completionMessage(itemCount: source.indexedItemCount)
+ content.sound = .default
+
+ let identifier = "scan-complete-\(source.id.absoluteString)-\(UUID().uuidString)"
+ let request = UNNotificationRequest(identifier: identifier, content: content, trigger: nil)
+
+ do {
+ try await center.add(request)
+ } catch {
+ return
+ }
+ }
+
+ func shouldNotifyAboutCompletedScan(duration: TimeInterval, isAppActive: Bool) -> Bool {
+ duration >= Self.longScanThreshold && !isAppActive
+ }
+
+ nonisolated static func completionMessage(itemCount: Int) -> String {
+ switch itemCount {
+ case 0:
+ return "No worlds or packs were found."
+ case 1:
+ return "Found 1 item."
+ default:
+ return "Found \(itemCount) items."
+ }
+ }
+}
diff --git a/World Manager for Minecraft/Services/SourceLibrary.swift b/World Manager for Minecraft/Services/SourceLibrary.swift
index 233e8f0..b16921a 100644
--- a/World Manager for Minecraft/Services/SourceLibrary.swift
+++ b/World Manager for Minecraft/Services/SourceLibrary.swift
@@ -7,6 +7,7 @@
import Combine
import Foundation
+import OSLog
struct SidebarFooterState {
enum Style {
@@ -41,12 +42,26 @@ struct ConnectedDeviceSidebarEntry: Identifiable, Hashable {
}
}
+private struct CachedConnectedDeviceDiscovery {
+ let device: ConnectedDevice
+ let containers: [DeviceAppContainer]
+ let discoveryErrorDescription: String?
+ let refreshedAt: Date
+}
+
@MainActor
final class SourceLibrary: ObservableObject {
private static let enrichmentWorkerCount = 4
private static let sizeWorkerCount = 2
private static let minimumVisibleScanDuration: TimeInterval = 0.8
- private static let connectedDeviceRefreshInterval: TimeInterval = 0.5
+ private static let connectedDeviceRefreshInterval: TimeInterval = 2.0
+ private static let connectedDeviceRefreshIntervalWhileScanning: TimeInterval = 5.0
+ private static let usbConnectedDeviceDiscoveryCacheTTL: TimeInterval = 60.0
+ private static let networkConnectedDeviceDiscoveryCacheTTL: TimeInterval = 180.0
+ private static let performanceLogger = Logger(
+ subsystem: Bundle.main.bundleIdentifier ?? "WorldManagerForMinecraft",
+ category: "ConnectedDevicePerformance"
+ )
@Published var sources: [MinecraftSource] = []
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@@ -65,16 +80,22 @@ final class SourceLibrary: ObservableObject {
private let persistenceStore: SourcePersistenceStore
private let sourceAccessMethod: SourceAccessMethod
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
+ private let notificationService: ScanNotificationServicing
+ private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
private var lastMatchedConnectedSourceIDs: Set = []
+ private var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
+ private var isShuttingDown = false
init(
persistenceStore: SourcePersistenceStore = .shared,
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
- connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil
+ connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
+ notificationService: ScanNotificationServicing? = nil
) {
self.persistenceStore = persistenceStore
self.sourceAccessMethod = sourceAccessMethod
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
+ self.notificationService = notificationService ?? ScanNotificationService.shared
Task { [weak self] in
await self?.restorePersistedSources()
@@ -87,20 +108,44 @@ final class SourceLibrary: ObservableObject {
}
}
- var visibleSources: [MinecraftSource] {
- let matchedConnectedSourceIDs = Set(connectedDevices.compactMap(\.matchedSourceID))
- return sources.filter { source in
- switch source.origin {
- case .localFolder:
- return true
- case .connectedDevice:
- return matchedConnectedSourceIDs.contains(source.id)
- }
- }
+ deinit {
+ connectedDeviceRefreshTask?.cancel()
+ footerResetTask?.cancel()
+ scanTasks.values.forEach { $0.cancel() }
}
- var localSources: [MinecraftSource] {
- visibleSources.filter { $0.origin.kind == .localFolder }
+ var visibleSources: [MinecraftSource] {
+ sources
+ }
+
+ var sidebarSources: [MinecraftSource] {
+ visibleSources
+ }
+
+ func shutdown() {
+ guard !isShuttingDown else {
+ return
+ }
+
+ isShuttingDown = true
+ connectedDeviceRefreshTask?.cancel()
+ connectedDeviceRefreshTask = nil
+ footerResetTask?.cancel()
+ footerResetTask = nil
+
+ for task in scanTasks.values {
+ task.cancel()
+ }
+ scanTasks.removeAll()
+ }
+
+ func shutdownGracefully(timeout: TimeInterval = 1.0) async {
+ guard !isShuttingDown else {
+ return
+ }
+
+ shutdown()
+ try? await Task.sleep(for: .seconds(timeout))
}
func addSource(at url: URL) -> URL {
@@ -237,6 +282,10 @@ final class SourceLibrary: ObservableObject {
}
private func startScan(for sourceID: URL) {
+ guard !isShuttingDown else {
+ return
+ }
+
scanTasks[sourceID]?.cancel()
let task = Task { [weak self] in
@@ -252,10 +301,12 @@ final class SourceLibrary: ObservableObject {
private func scanSource(withID sourceID: URL) async {
var workerTasks: [Task] = []
+ var previewWorkerTasks: [Task] = []
var sizeWorkerTasks: [Task] = []
let scanStartTime = Date()
defer {
workerTasks.forEach { $0.cancel() }
+ previewWorkerTasks.forEach { $0.cancel() }
sizeWorkerTasks.forEach { $0.cancel() }
scanTasks[sourceID] = nil
}
@@ -263,18 +314,15 @@ final class SourceLibrary: ObservableObject {
guard let source = source(withID: sourceID) else {
return
}
+ let previousSource = source
+ let performanceContext = performanceContext(for: source)
updateSource(sourceID) { source in
source.isScanning = true
source.scanError = nil
+ source.scanDiagnostic = nil
source.scanStatus = initialScanStatus(for: source)
- source.displayItems = []
- source.rawItems = []
- source.logicalPacks = []
- source.logicalWorlds = []
- source.packInstances = []
- source.worldPackRelationships = []
- source.snapshot = nil
+ source.scanProgress = nil
source.indexedItemCount = 0
source.indexedDetailCount = 0
}
@@ -305,8 +353,12 @@ final class SourceLibrary: ObservableObject {
do {
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL)
let enrichmentQueue = EnrichmentWorkQueue()
+ let previewQueue = EnrichmentWorkQueue()
let sizeQueue = EnrichmentWorkQueue()
- workerTasks = (0.. String {
+ switch source.origin {
+ case .localFolder:
+ return "source=\(source.displayName) kind=local"
+ case .connectedDevice(let device, let container):
+ let transport = device.connection == .usb ? "usb" : "network"
+ return "source=\(source.displayName) kind=connected-device transport=\(transport) udid=\(device.udid) app=\(container.appID)"
+ }
+ }
+
+ private func logScanStage(
+ _ stage: String,
+ elapsed: TimeInterval,
+ context: String,
+ itemCount: Int
+ ) {
+ Self.performanceLogger.log(
+ "\(stage, privacy: .public) \(context, privacy: .public) elapsed=\(elapsed, format: .fixed(precision: 3))s items=\(itemCount)"
+ )
+ }
+
+ private func logDeviceRefreshStage(
+ _ stage: String,
+ elapsed: TimeInterval,
+ device: ConnectedDevice,
+ containerCount: Int,
+ error: Error? = nil
+ ) {
+ let transport = device.connection == .usb ? "usb" : "network"
+ let errorDescription = error?.localizedDescription ?? ""
+ Self.performanceLogger.log(
+ "\(stage, privacy: .public) device=\(device.name, privacy: .public) transport=\(transport, privacy: .public) udid=\(device.udid, privacy: .public) elapsed=\(elapsed, format: .fixed(precision: 3))s containers=\(containerCount) error=\(errorDescription, privacy: .public)"
+ )
+ }
+
+ private func friendlyScanError(for error: Error, source: MinecraftSource) -> String {
+ let description = error.localizedDescription
+
+ guard source.origin.kind == .connectedDevice else {
+ return "Failed to scan folder: \(description)"
+ }
+
+ if description.contains("AMDeviceCreateHouseArrestService returned -402653093")
+ || description.contains("kAMDServiceLimitError") {
+ return "Device is busy. Too many device access sessions were open, so the scan could not start."
+ }
+
+ if description.localizedCaseInsensitiveContains("InstallationLookupFailed") {
+ return "The device refused access to the Minecraft app container."
+ }
+
+ if description.localizedCaseInsensitiveContains("not paired") {
+ return "The device is not paired with this Mac."
+ }
+
+ if description.localizedCaseInsensitiveContains("no longer available") {
+ return "The device disconnected during the scan."
+ }
+
+ return "Failed to scan device library."
+ }
+
private func handleDiscoveredItem(_ item: MinecraftContentItem, in source: inout MinecraftSource, sourceID: URL) {
guard isLogicalPackType(item.contentType) else {
return
@@ -684,6 +900,7 @@ final class SourceLibrary: ObservableObject {
source.indexedItemCount = snapshot.indexedItemCount
source.indexedDetailCount = snapshot.indexedDetailCount
source.scanStatus = snapshot.scanStatus
+ source.scanProgress = snapshot.scanProgress
source.isScanning = snapshot.isScanning
source.lastScanDate = snapshot.lastScanDate
}
@@ -819,11 +1036,16 @@ final class SourceLibrary: ObservableObject {
}
private func runConnectedDeviceRefreshLoop() async {
- while !Task.isCancelled {
+ while !Task.isCancelled && !isShuttingDown {
await refreshConnectedDevices()
do {
- try await Task.sleep(for: .seconds(Self.connectedDeviceRefreshInterval))
+ let refreshInterval = sources.contains {
+ $0.isScanning && $0.origin.kind == .connectedDevice
+ }
+ ? Self.connectedDeviceRefreshIntervalWhileScanning
+ : Self.connectedDeviceRefreshInterval
+ try await Task.sleep(for: .seconds(refreshInterval))
} catch {
return
}
@@ -831,6 +1053,10 @@ final class SourceLibrary: ObservableObject {
}
private func refreshConnectedDevices() async {
+ guard !isShuttingDown else {
+ return
+ }
+
guard let connectedDeviceAccessMethod else {
return
}
@@ -847,36 +1073,69 @@ final class SourceLibrary: ObservableObject {
var entries: [ConnectedDeviceSidebarEntry] = []
var matchedSourceIDs = Set()
+ let activeScanningDeviceUDIDs = Set(
+ sources.compactMap { source -> String? in
+ guard source.isScanning, case .connectedDevice(let device, _) = source.origin else {
+ return nil
+ }
+
+ return device.udid
+ }
+ )
+ let currentDeviceUDIDs = Set(devices.map(\.udid))
+ cachedDeviceDiscoveryByUDID = cachedDeviceDiscoveryByUDID.filter { currentDeviceUDIDs.contains($0.key) }
for device in devices {
if let matchedSourceID = knownConnectedDeviceSourceID(for: device) {
matchedSourceIDs.insert(matchedSourceID)
+ let cachedContainers = cachedDeviceDiscoveryByUDID[device.udid]?.containers ?? []
refreshMatchedConnectedDeviceSource(
sourceID: matchedSourceID,
device: device,
- containers: []
+ containers: cachedContainers
)
- entries.append(
- ConnectedDeviceSidebarEntry(
- device: device,
- containers: [],
- matchedSourceID: matchedSourceID,
- discoveryErrorDescription: nil
- )
- )
continue
}
let containers: [DeviceAppContainer]
let discoveryErrorDescription: String?
+ if let cachedDiscovery = cachedDiscovery(for: device, isActivelyScanning: activeScanningDeviceUDIDs.contains(device.udid)) {
+ containers = cachedDiscovery.containers
+ discoveryErrorDescription = cachedDiscovery.discoveryErrorDescription
+ } else {
+ let containerDiscoveryStartTime = Date()
- do {
- containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
- discoveryErrorDescription = nil
- } catch {
- containers = []
- discoveryErrorDescription = error.localizedDescription
+ do {
+ containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
+ discoveryErrorDescription = nil
+ cacheDeviceDiscovery(
+ device: device,
+ containers: containers,
+ discoveryErrorDescription: nil
+ )
+ logDeviceRefreshStage(
+ "Container discovery",
+ elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
+ device: device,
+ containerCount: containers.count
+ )
+ } catch {
+ containers = []
+ discoveryErrorDescription = error.localizedDescription
+ cacheDeviceDiscovery(
+ device: device,
+ containers: [],
+ discoveryErrorDescription: error.localizedDescription
+ )
+ logDeviceRefreshStage(
+ "Container discovery failed",
+ elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
+ device: device,
+ containerCount: 0,
+ error: error
+ )
+ }
}
let matchedSourceID = matchingConnectedDeviceSourceID(
@@ -894,9 +1153,8 @@ final class SourceLibrary: ObservableObject {
}
let shouldDisplayEntry =
- matchedSourceID != nil
- || !containers.isEmpty
- || device.trustState != .trusted
+ matchedSourceID == nil
+ && (!containers.isEmpty || device.trustState != .trusted)
if shouldDisplayEntry {
entries.append(
@@ -931,46 +1189,82 @@ final class SourceLibrary: ObservableObject {
lastMatchedConnectedSourceIDs = matchedSourceIDs
}
+ private func cachedDiscovery(for device: ConnectedDevice, isActivelyScanning: Bool) -> CachedConnectedDeviceDiscovery? {
+ guard let cachedDiscovery = cachedDeviceDiscoveryByUDID[device.udid] else {
+ return nil
+ }
+
+ if isActivelyScanning {
+ return cachedDiscovery
+ }
+
+ let age = Date().timeIntervalSince(cachedDiscovery.refreshedAt)
+ guard age <= discoveryCacheTTL(for: device) else {
+ return nil
+ }
+
+ guard cachedDiscovery.device.connection == device.connection,
+ cachedDiscovery.device.trustState == device.trustState,
+ cachedDiscovery.device.name == device.name else {
+ return nil
+ }
+
+ return cachedDiscovery
+ }
+
+ private func discoveryCacheTTL(for device: ConnectedDevice) -> TimeInterval {
+ switch device.connection {
+ case .usb:
+ return Self.usbConnectedDeviceDiscoveryCacheTTL
+ case .network:
+ return Self.networkConnectedDeviceDiscoveryCacheTTL
+ }
+ }
+
+ private func cacheDeviceDiscovery(
+ device: ConnectedDevice,
+ containers: [DeviceAppContainer],
+ discoveryErrorDescription: String?
+ ) {
+ cachedDeviceDiscoveryByUDID[device.udid] = CachedConnectedDeviceDiscovery(
+ device: device,
+ containers: containers,
+ discoveryErrorDescription: discoveryErrorDescription,
+ refreshedAt: Date()
+ )
+ }
+
private func matchingConnectedDeviceSourceID(
device: ConnectedDevice,
containers: [DeviceAppContainer]
) -> URL? {
- for source in sources {
- guard case .connectedDevice(let expectedDevice, let expectedContainer) = source.origin else {
- continue
+ for container in containers {
+ let sourceID = connectedDeviceSourceFactory.makeSourceIdentifier(
+ device: device,
+ container: container
+ )
+ if sources.contains(where: { $0.id == sourceID }) {
+ return sourceID
}
-
- guard expectedDevice.udid == device.udid else {
- continue
- }
-
- guard containers.contains(where: { container in
- container.appID == expectedContainer.appID
- && container.accessMode == expectedContainer.accessMode
- }) else {
- continue
- }
-
- return source.id
}
return nil
}
private func knownConnectedDeviceSourceID(for device: ConnectedDevice) -> URL? {
- for source in sources {
+ let matchingSourceIDs = sources.compactMap { source -> URL? in
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
- continue
+ return nil
}
- guard expectedDevice.udid == device.udid else {
- continue
- }
-
- return source.id
+ return expectedDevice.udid == device.udid ? source.id : nil
}
- return nil
+ guard matchingSourceIDs.count == 1 else {
+ return nil
+ }
+
+ return matchingSourceIDs.first
}
private func refreshMatchedConnectedDeviceSource(
@@ -1281,7 +1575,15 @@ final class SourceLibrary: ObservableObject {
let detail: String?
if source.indexedItemCount > 0 {
subtitle = source.displayName
- detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
+ let previewLoadedCount = source.rawItems.filter(\.previewLoaded).count
+ let sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count
+ if sizeLoadedCount > 0 || source.scanStatus.contains("Calculating sizes") {
+ detail = "\(sizeLoadedCount) of \(source.indexedItemCount) sizes calculated"
+ } else if previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") {
+ detail = "\(previewLoadedCount) of \(source.indexedItemCount) previews loaded"
+ } else {
+ detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
+ }
} else {
subtitle = "Searching \(source.displayName)"
detail = nil
@@ -1534,6 +1836,7 @@ private struct SourceIndexSnapshot {
let indexedItemCount: Int
let indexedDetailCount: Int
let scanStatus: String
+ let scanProgress: Double?
let isScanning: Bool
let lastScanDate: Date?
}
@@ -1552,8 +1855,10 @@ private actor SourceIndexActor {
private var packRepresentativeItemIDByIdentityID: [String: URL] = [:]
private var indexedItemCount = 0
private var indexedDetailCount = 0
+ private var previewLoadedCount = 0
private var discoveryFinished = false
private var metadataFinished = false
+ private var previewsFinished = false
private var sizesFinished = false
private var lastPublishedAt: Date?
@@ -1594,6 +1899,16 @@ private actor SourceIndexActor {
return snapshotIfNeeded()
}
+ func applyPreviewItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? {
+ let previous = itemsByID[item.id]
+ itemsByID[item.id] = item
+ if item.previewLoaded, previous?.previewLoaded != true {
+ previewLoadedCount += 1
+ }
+
+ return snapshotIfNeeded()
+ }
+
func markDiscoveryFinished() -> SourceIndexSnapshot? {
discoveryFinished = true
return buildSnapshot(force: true)
@@ -1605,9 +1920,17 @@ private actor SourceIndexActor {
return buildSnapshot(force: true)
}
+ func markPreviewsFinished() -> SourceIndexSnapshot? {
+ discoveryFinished = true
+ metadataFinished = true
+ previewsFinished = true
+ return buildSnapshot(force: true)
+ }
+
func finishScan() -> SourceIndexSnapshot? {
discoveryFinished = true
metadataFinished = true
+ previewsFinished = true
sizesFinished = true
return buildSnapshot(force: true)
}
@@ -1632,6 +1955,10 @@ private actor SourceIndexActor {
logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID
)
+ let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
+ let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
+ let sizeLoadedCount = rawItems.filter(\.sizeLoaded).count
+ let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
let scanStatus: String
if !discoveryFinished {
@@ -1649,6 +1976,7 @@ private actor SourceIndexActor {
indexedItemCount: indexedItemCount,
indexedDetailCount: indexedDetailCount,
scanStatus: scanStatus,
+ scanProgress: nil,
isScanning: true,
lastScanDate: nil
)
@@ -1657,7 +1985,7 @@ private actor SourceIndexActor {
if !metadataFinished {
scanStatus = indexedItemCount == 0
? "No Minecraft items found."
- : "Deduplicating packs..."
+ : "Loading metadata for \(indexedDetailCount) of \(indexedItemCount) items..."
return SourceIndexSnapshot(
displayItems: dedupedDisplayItems,
@@ -1669,6 +1997,28 @@ private actor SourceIndexActor {
indexedItemCount: indexedItemCount,
indexedDetailCount: indexedDetailCount,
scanStatus: scanStatus,
+ scanProgress: progressAfterDiscovery(metadataFraction),
+ isScanning: true,
+ lastScanDate: nil
+ )
+ }
+
+ if !previewsFinished {
+ scanStatus = indexedItemCount == 0
+ ? "No Minecraft items found."
+ : "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..."
+
+ return SourceIndexSnapshot(
+ displayItems: dedupedDisplayItems,
+ rawItems: rawItems,
+ logicalPacks: logicalPacks,
+ logicalWorlds: [],
+ packInstances: [],
+ worldPackRelationships: [],
+ indexedItemCount: indexedItemCount,
+ indexedDetailCount: indexedDetailCount,
+ scanStatus: scanStatus,
+ scanProgress: progressAfterMetadata(previewFraction),
isScanning: true,
lastScanDate: nil
)
@@ -1752,7 +2102,7 @@ private actor SourceIndexActor {
if !sizesFinished {
scanStatus = indexedItemCount == 0
? "No Minecraft items found."
- : "Resolving pack relationships..."
+ : "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
} else {
scanStatus = indexedItemCount == 0
? "No Minecraft items found."
@@ -1771,11 +2121,32 @@ private actor SourceIndexActor {
indexedItemCount: indexedItemCount,
indexedDetailCount: indexedDetailCount,
scanStatus: scanStatus,
+ scanProgress: sizesFinished ? nil : progressAfterPreviews(sizeFraction),
isScanning: !sizesFinished,
lastScanDate: sizesFinished ? now : nil
)
}
+ private func progressFraction(completed: Int, total: Int) -> Double {
+ guard total > 0 else {
+ return 1
+ }
+
+ return min(max(Double(completed) / Double(total), 0), 1)
+ }
+
+ private func progressAfterDiscovery(_ metadataFraction: Double) -> Double {
+ 0.1 + (metadataFraction * 0.55)
+ }
+
+ private func progressAfterMetadata(_ previewFraction: Double) -> Double {
+ 0.65 + (previewFraction * 0.1)
+ }
+
+ private func progressAfterPreviews(_ sizeFraction: Double) -> Double {
+ 0.75 + (sizeFraction * 0.25)
+ }
+
private func buildDisplayItems(
from rawItems: [MinecraftContentItem],
logicalPacks: [LogicalPack],
diff --git a/World Manager for Minecraft/Services/WorldScanner.swift b/World Manager for Minecraft/Services/WorldScanner.swift
index dcd3cd0..edd78f0 100644
--- a/World Manager for Minecraft/Services/WorldScanner.swift
+++ b/World Manager for Minecraft/Services/WorldScanner.swift
@@ -90,13 +90,24 @@ enum WorldScanner {
let fileManager = FileManager.default
var enrichedItem = item
- enrichedItem.displayName = displayName(for: item, fileManager: fileManager)
- let sourceIconURL = iconURL(for: item, fileManager: fileManager)
+ enrichedItem.displayName = MinecraftContentMetadataReader.displayName(
+ for: item.folderURL,
+ contentType: item.contentType,
+ fallbackName: item.folderName,
+ fileManager: fileManager
+ )
+ let sourceIconURL = MinecraftContentMetadataReader.iconURL(
+ for: item.folderURL,
+ contentType: item.contentType,
+ fileManager: fileManager
+ )
enrichedItem.iconURL = await ImageCacheStore.shared.cachedImageURL(for: sourceIconURL)
- enrichedItem.worldMetadata = worldMetadata(for: item, fileManager: fileManager)
+ enrichedItem.worldMetadata = item.contentType == .world
+ ? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
+ : nil
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
- if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) {
+ if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
enrichedItem.packUUID = manifestMetadata.uuid
enrichedItem.packVersion = manifestMetadata.version
enrichedItem.packMetadataDetails = PackMetadataDetails(
@@ -108,6 +119,7 @@ enum WorldScanner {
}
enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager)
enrichedItem.metadataLoaded = true
+ enrichedItem.previewLoaded = true
enrichedItem.sizeLoaded = false
return enrichedItem
@@ -204,65 +216,6 @@ enum WorldScanner {
return embeddedItems
}
- nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
- switch item.contentType {
- case .world:
- let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt")
- guard
- let name = try? String(contentsOf: levelNameURL, encoding: .utf8)
- .trimmingCharacters(in: .whitespacesAndNewlines),
- !name.isEmpty
- else {
- return item.folderName
- }
-
- return name
- case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
- if let manifestName = manifestName(in: item.folderURL, fileManager: fileManager) {
- return manifestName
- }
-
- return item.folderName
- }
- }
-
- nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? {
- manifestMetadata(in: directoryURL, fileManager: fileManager)?.name
- }
-
- nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? {
- let candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
-
- for candidateName in candidateNames {
- let candidateURL = directoryURL.appendingPathComponent(candidateName)
- if fileManager.fileExists(atPath: candidateURL.path) {
- return candidateURL
- }
- }
-
- return nil
- }
-
- nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
- let candidateNames: [String]
-
- switch item.contentType {
- case .world:
- candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
- case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
- candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
- }
-
- for candidateName in candidateNames {
- let candidateURL = item.folderURL.appendingPathComponent(candidateName)
- if fileManager.fileExists(atPath: candidateURL.path) {
- return candidateURL
- }
- }
-
- return nil
- }
-
nonisolated private static func lastPlayedDate(
for item: MinecraftContentItem,
fileManager: FileManager,
@@ -374,7 +327,7 @@ enum WorldScanner {
for entry in jsonObject {
let uuid = (entry["pack_id"] as? String)?.lowercased()
- let version = versionString(from: entry["version"])
+ let version = MinecraftContentMetadataReader.versionString(from: entry["version"])
let resolvedPack: ContentPackReference?
if let uuid {
resolvedPack = await resolvedPackReference(
@@ -429,33 +382,20 @@ enum WorldScanner {
source: PackSource,
fileManager: FileManager
) -> ContentPackReference? {
- guard let metadata = manifestMetadata(in: directoryURL, fileManager: fileManager) else {
+ guard let metadata = MinecraftContentMetadataReader.manifestMetadata(in: directoryURL, fileManager: fileManager) else {
return nil
}
return ContentPackReference(
name: metadata.name,
type: type,
- iconURL: packIconURL(in: directoryURL, fileManager: fileManager),
+ iconURL: MinecraftContentMetadataReader.packIconURL(in: directoryURL, fileManager: fileManager),
uuid: metadata.uuid,
version: metadata.version,
source: source
)
}
- nonisolated private static func worldMetadata(for item: MinecraftContentItem, fileManager: FileManager) -> WorldMetadata? {
- guard item.contentType == .world else {
- return nil
- }
-
- let levelDatURL = item.folderURL.appendingPathComponent("level.dat")
- guard fileManager.fileExists(atPath: levelDatURL.path) else {
- return nil
- }
-
- return BedrockLevelMetadataDecoder.decode(fromLevelDatAt: levelDatURL)
- }
-
nonisolated private static func resolvedPackReference(
uuid: String,
type: MinecraftContentType,
@@ -472,51 +412,6 @@ enum WorldScanner {
)
}
- nonisolated private static func versionString(from value: Any?) -> String? {
- if let versionString = value as? String, !versionString.isEmpty {
- return versionString
- }
-
- if let versionArray = value as? [Any] {
- let components = versionArray.compactMap { component -> String? in
- if let intComponent = component as? Int {
- return String(intComponent)
- }
- if let stringComponent = component as? String {
- return stringComponent
- }
- return nil
- }
-
- return components.isEmpty ? nil : components.joined(separator: ".")
- }
-
- return nil
- }
-
- nonisolated private static func manifestMetadata(in directoryURL: URL, fileManager: FileManager) -> ManifestMetadata? {
- let manifestURL = directoryURL.appendingPathComponent("manifest.json")
- guard
- fileManager.fileExists(atPath: manifestURL.path),
- let data = try? Data(contentsOf: manifestURL),
- let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
- let header = jsonObject["header"] as? [String: Any]
- else {
- return nil
- }
-
- let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
- $0.isEmpty ? nil : $0
- } ?? directoryURL.lastPathComponent
-
- return ManifestMetadata(
- name: name,
- uuid: (header["uuid"] as? String)?.lowercased(),
- version: versionString(from: header["version"]),
- minimumEngineVersion: versionString(from: header["min_engine_version"])
- )
- }
-
nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
var seen = Set()
var uniqueReferences: [ContentPackReference] = []
@@ -541,13 +436,6 @@ enum WorldScanner {
}
}
-private struct ManifestMetadata {
- let name: String
- let uuid: String?
- let version: String?
- let minimumEngineVersion: String?
-}
-
private actor PackReferenceIndexStore {
private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:]
diff --git a/World Manager for Minecraft/Services/ZipArchiveReader.swift b/World Manager for Minecraft/Services/ZipArchiveReader.swift
new file mode 100644
index 0000000..496fb09
--- /dev/null
+++ b/World Manager for Minecraft/Services/ZipArchiveReader.swift
@@ -0,0 +1,258 @@
+//
+// ZipArchiveReader.swift
+// World Manager for Minecraft
+//
+// Created by OpenAI on 2026-05-27.
+//
+
+import Foundation
+import zlib
+
+struct ZipArchiveEntry: Sendable, Hashable {
+ let path: String
+ let compressionMethod: UInt16
+ let compressedSize: UInt32
+ let uncompressedSize: UInt32
+ let localHeaderOffset: UInt32
+ let isDirectory: Bool
+}
+
+enum ZipArchiveReaderError: LocalizedError {
+ case invalidArchive
+ case unsupportedCompressionMethod(UInt16)
+ case unsupportedFeatures(String)
+ case entryNotFound(String)
+ case decompressionFailed(Int32)
+
+ var errorDescription: String? {
+ switch self {
+ case .invalidArchive:
+ return "The ZIP archive is invalid or unsupported."
+ case .unsupportedCompressionMethod(let method):
+ return "Unsupported ZIP compression method: \(method)."
+ case .unsupportedFeatures(let message):
+ return message
+ case .entryNotFound(let path):
+ return "ZIP entry not found: \(path)"
+ case .decompressionFailed(let code):
+ return "ZIP decompression failed with zlib error \(code)."
+ }
+ }
+}
+
+struct ZipArchiveReader {
+ private let data: Data
+ let entries: [ZipArchiveEntry]
+
+ init(url: URL) throws {
+ self.data = try Data(contentsOf: url)
+ self.entries = try ZipArchiveReader.parseEntries(in: data)
+ }
+
+ func entry(named path: String) -> ZipArchiveEntry? {
+ let normalizedPath = Self.normalizedPath(path)
+ return entries.first { $0.path == normalizedPath }
+ }
+
+ func extract(_ entry: ZipArchiveEntry) throws -> Data {
+ let localHeaderOffset = Int(entry.localHeaderOffset)
+ guard localHeaderOffset + 30 <= data.count else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ guard data.readUInt32LE(at: localHeaderOffset) == 0x04034b50 else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ let generalPurposeFlags = data.readUInt16LE(at: localHeaderOffset + 6)
+ if generalPurposeFlags & 0x0001 != 0 {
+ throw ZipArchiveReaderError.unsupportedFeatures("Encrypted ZIP entries are not supported.")
+ }
+
+ let filenameLength = Int(data.readUInt16LE(at: localHeaderOffset + 26))
+ let extraFieldLength = Int(data.readUInt16LE(at: localHeaderOffset + 28))
+ let payloadOffset = localHeaderOffset + 30 + filenameLength + extraFieldLength
+ let compressedSize = Int(entry.compressedSize)
+
+ guard payloadOffset >= 0, payloadOffset + compressedSize <= data.count else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ let compressedData = data.subdata(in: payloadOffset ..< payloadOffset + compressedSize)
+
+ switch entry.compressionMethod {
+ case 0:
+ return compressedData
+ case 8:
+ return try Self.inflateRawDeflate(compressedData, expectedSize: Int(entry.uncompressedSize))
+ default:
+ throw ZipArchiveReaderError.unsupportedCompressionMethod(entry.compressionMethod)
+ }
+ }
+
+ private static func parseEntries(in data: Data) throws -> [ZipArchiveEntry] {
+ let endOfCentralDirectoryOffset = try locateEndOfCentralDirectory(in: data)
+ let totalEntries = Int(data.readUInt16LE(at: endOfCentralDirectoryOffset + 10))
+ let centralDirectorySize = Int(data.readUInt32LE(at: endOfCentralDirectoryOffset + 12))
+ let centralDirectoryOffset = Int(data.readUInt32LE(at: endOfCentralDirectoryOffset + 16))
+
+ guard centralDirectoryOffset >= 0, centralDirectorySize >= 0, centralDirectoryOffset + centralDirectorySize <= data.count else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ var entries: [ZipArchiveEntry] = []
+ var offset = centralDirectoryOffset
+
+ for _ in 0 ..< totalEntries {
+ guard offset + 46 <= data.count else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ guard data.readUInt32LE(at: offset) == 0x02014b50 else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ let compressionMethod = data.readUInt16LE(at: offset + 10)
+ let compressedSize = data.readUInt32LE(at: offset + 20)
+ let uncompressedSize = data.readUInt32LE(at: offset + 24)
+ let filenameLength = Int(data.readUInt16LE(at: offset + 28))
+ let extraFieldLength = Int(data.readUInt16LE(at: offset + 30))
+ let fileCommentLength = Int(data.readUInt16LE(at: offset + 32))
+ let localHeaderOffset = data.readUInt32LE(at: offset + 42)
+
+ let filenameStart = offset + 46
+ let filenameEnd = filenameStart + filenameLength
+ guard filenameEnd <= data.count else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ let filenameData = data.subdata(in: filenameStart ..< filenameEnd)
+ guard let filename = String(data: filenameData, encoding: .utf8) ?? String(data: filenameData, encoding: .isoLatin1) else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ let normalizedPath = Self.normalizedPath(filename)
+ entries.append(
+ ZipArchiveEntry(
+ path: normalizedPath,
+ compressionMethod: compressionMethod,
+ compressedSize: compressedSize,
+ uncompressedSize: uncompressedSize,
+ localHeaderOffset: localHeaderOffset,
+ isDirectory: normalizedPath.hasSuffix("/")
+ )
+ )
+
+ offset = filenameEnd + extraFieldLength + fileCommentLength
+ }
+
+ return entries
+ }
+
+ private static func locateEndOfCentralDirectory(in data: Data) throws -> Int {
+ let minimumLength = 22
+ guard data.count >= minimumLength else {
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ let searchStart = max(0, data.count - (minimumLength + 65_535))
+ let signature: UInt32 = 0x06054b50
+
+ for offset in stride(from: data.count - minimumLength, through: searchStart, by: -1) {
+ if data.readUInt32LE(at: offset) == signature {
+ return offset
+ }
+ }
+
+ throw ZipArchiveReaderError.invalidArchive
+ }
+
+ private static func normalizedPath(_ path: String) -> String {
+ let replaced = path.replacingOccurrences(of: "\\", with: "/")
+ let components = replaced
+ .split(separator: "/")
+ .filter { $0 != "." && !$0.isEmpty }
+ .map(String.init)
+
+ let trailingSlash = replaced.hasSuffix("/")
+ let joined = components.joined(separator: "/")
+ if trailingSlash, !joined.isEmpty {
+ return joined + "/"
+ }
+ return joined
+ }
+
+ private static func inflateRawDeflate(_ data: Data, expectedSize: Int) throws -> Data {
+ if data.isEmpty {
+ return Data()
+ }
+
+ var stream = z_stream()
+ stream.zalloc = nil
+ stream.zfree = nil
+ stream.opaque = nil
+
+ let initCode = inflateInit2_(&stream, -MAX_WBITS, ZLIB_VERSION, Int32(MemoryLayout.size))
+ guard initCode == Z_OK else {
+ throw ZipArchiveReaderError.decompressionFailed(initCode)
+ }
+
+ defer {
+ inflateEnd(&stream)
+ }
+
+ var output = Data()
+ let chunkSize = max(expectedSize, 64 * 1024)
+ var status: Int32 = Z_OK
+
+ try data.withUnsafeBytes { compressedBytes in
+ guard let compressedBase = compressedBytes.bindMemory(to: Bytef.self).baseAddress else {
+ throw ZipArchiveReaderError.decompressionFailed(Z_DATA_ERROR)
+ }
+
+ stream.next_in = UnsafeMutablePointer(mutating: compressedBase)
+ stream.avail_in = uInt(data.count)
+
+ var buffer = [UInt8](repeating: 0, count: chunkSize)
+
+ repeat {
+ try buffer.withUnsafeMutableBufferPointer { bufferPointer in
+ guard let baseAddress = bufferPointer.baseAddress else {
+ throw ZipArchiveReaderError.decompressionFailed(Z_DATA_ERROR)
+ }
+
+ stream.next_out = baseAddress
+ stream.avail_out = uInt(bufferPointer.count)
+
+ status = inflate(&stream, Z_NO_FLUSH)
+ if status != Z_OK && status != Z_STREAM_END {
+ throw ZipArchiveReaderError.decompressionFailed(status)
+ }
+
+ let producedByteCount = bufferPointer.count - Int(stream.avail_out)
+ if producedByteCount > 0 {
+ output.append(contentsOf: bufferPointer.prefix(producedByteCount))
+ }
+ }
+ } while status != Z_STREAM_END
+ }
+
+ return output
+ }
+}
+
+private extension Data {
+ func readUInt16LE(at offset: Int) -> UInt16 {
+ return self.withUnsafeBytes { bytes in
+ let base = bytes.baseAddress!.advanced(by: offset)
+ return base.loadUnaligned(as: UInt16.self).littleEndian
+ }
+ }
+
+ func readUInt32LE(at offset: Int) -> UInt32 {
+ return self.withUnsafeBytes { bytes in
+ let base = bytes.baseAddress!.advanced(by: offset)
+ return base.loadUnaligned(as: UInt32.self).littleEndian
+ }
+ }
+}
diff --git a/World Manager for Minecraft/SidebarColumnViews.swift b/World Manager for Minecraft/SidebarColumnViews.swift
index ea6272e..1b87b01 100644
--- a/World Manager for Minecraft/SidebarColumnViews.swift
+++ b/World Manager for Minecraft/SidebarColumnViews.swift
@@ -21,7 +21,7 @@ struct SidebarFilter: Identifiable, Hashable {
}
struct SourcesSidebarView: View {
- let localSources: [MinecraftSource]
+ let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry]
@Binding var selection: SidebarSelection?
let footerState: SidebarFooterState
@@ -32,13 +32,12 @@ struct SourcesSidebarView: View {
let removeSourceAction: (MinecraftSource) -> Void
let revealFooterURLAction: (URL) -> Void
let filters: (MinecraftSource) -> [SidebarFilter]
- let matchedSource: (ConnectedDeviceSidebarEntry) -> MinecraftSource?
var body: some View {
List(selection: $selection) {
- if !localSources.isEmpty {
+ if !sources.isEmpty {
Section {
- ForEach(localSources) { source in
+ ForEach(sources) { source in
sourceSectionRows(for: source)
}
} header: {
@@ -57,17 +56,6 @@ struct SourcesSidebarView: View {
}
}
.listStyle(.sidebar)
- .overlay(alignment: .bottom) {
- if footerState.style != .idle {
- SidebarFooterView(
- state: footerState,
- revealAction: revealFooterURLAction
- )
- .padding(.horizontal, 10)
- .padding(.bottom, 10)
- .transition(.move(edge: .bottom).combined(with: .opacity))
- }
- }
.toolbar {
ToolbarItem {
Button(action: addSourceAction) {
@@ -83,12 +71,11 @@ struct SourcesSidebarView: View {
.help("Add Connected Device Source")
}
}
- .animation(.easeInOut(duration: 0.2), value: footerState.style)
}
@ViewBuilder
private func sourceSectionRows(for source: MinecraftSource) -> some View {
- SourceHeaderRow(title: source.displayName)
+ SourceHeaderRow(source: source)
.listRowSeparator(.hidden)
.padding(.top, 6)
.contextMenu {
@@ -111,18 +98,14 @@ struct SourcesSidebarView: View {
@ViewBuilder
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
- if let source = matchedSource(entry) {
- sourceSectionRows(for: source)
- } else {
- ConnectedDeviceRow(
- entry: entry,
- addAction: entry.hasMinecraftContainer ? {
- addConnectedDeviceAction(entry)
- } : nil
- )
- .listRowSeparator(.hidden)
- .padding(.top, 6)
- }
+ ConnectedDeviceRow(
+ entry: entry,
+ addAction: entry.hasMinecraftContainer ? {
+ addConnectedDeviceAction(entry)
+ } : nil
+ )
+ .listRowSeparator(.hidden)
+ .padding(.top, 6)
}
}
@@ -159,12 +142,223 @@ private struct SidebarSourcesSectionHeaderView: View {
}
private struct SourceHeaderRow: View {
- let title: String
+ let source: MinecraftSource
+ @State private var isPresentingStatusPopover = false
var body: some View {
- Text(title)
- .font(.subheadline.weight(.semibold))
+ HStack(spacing: 8) {
+ Text(source.displayName)
+ .font(.subheadline.weight(.semibold))
+ .foregroundStyle(.secondary)
+
+ Spacer(minLength: 8)
+
+ if let connection {
+ SourceConnectionBadge(connection: connection)
+ }
+
+ if showsStatusButton {
+ Button {
+ isPresentingStatusPopover = true
+ } label: {
+ statusIndicator
+ .frame(width: 24, height: 24)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .help(scanStatusHelpText)
+ .popover(isPresented: $isPresentingStatusPopover, arrowEdge: .top) {
+ SourceStatusPopover(source: source)
+ }
+ }
+ }
+ }
+
+ private var connection: DeviceConnection? {
+ guard case .connectedDevice(let device, _) = source.origin else {
+ return nil
+ }
+
+ return device.connection
+ }
+
+ private var scanStatusHelpText: String {
+ if let scanError = source.scanError, !scanError.isEmpty {
+ return scanError
+ }
+
+ if !source.scanStatus.isEmpty {
+ return source.scanStatus
+ }
+
+ return "Scanning library…"
+ }
+
+ @ViewBuilder
+ private var statusIndicator: some View {
+ if source.isScanning {
+ if let scanProgress = source.scanProgress {
+ CircularScanProgressView(progress: scanProgress)
+ } else {
+ ProgressView()
+ .controlSize(.small)
+ }
+ } else if source.scanError != nil {
+ Image(systemName: "exclamationmark.circle")
+ .foregroundStyle(.secondary)
+ } else {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+ }
+
+ private var showsStatusButton: Bool {
+ source.isScanning || source.scanError != nil
+ }
+}
+
+private struct SourceConnectionBadge: View {
+ let connection: DeviceConnection
+
+ var body: some View {
+ Image(systemName: symbolName)
+ .font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
+ .padding(.horizontal, 7)
+ .padding(.vertical, 4)
+ .background(.secondary.opacity(0.12), in: Capsule())
+ .help(helpText)
+ .accessibilityLabel(helpText)
+ }
+
+ private var symbolName: String {
+ switch connection {
+ case .usb:
+ return "cable.connector"
+ case .network:
+ return "wifi"
+ }
+ }
+
+ private var helpText: String {
+ switch connection {
+ case .usb:
+ return "USB"
+ case .network:
+ return "Network"
+ }
+ }
+}
+
+private struct SourceStatusPopover: View {
+ let source: MinecraftSource
+
+ var body: some View {
+ VStack(alignment: .leading, spacing: 10) {
+ HStack(alignment: .center, spacing: 8) {
+ if !source.isScanning, source.scanError != nil {
+ Image(systemName: "exclamationmark.triangle.fill")
+ .foregroundStyle(.orange)
+ } else if !source.isScanning {
+ Image(systemName: "info.circle")
+ .foregroundStyle(.secondary)
+ }
+
+ Text(titleText)
+ .font(.headline)
+ }
+
+ if source.isScanning, let scanProgress = source.scanProgress {
+ ProgressView(value: scanProgress, total: 1)
+ }
+
+ if let subtitleText {
+ Text(subtitleText)
+ .font(.subheadline)
+ .foregroundStyle(.secondary)
+ }
+
+ if let detailText {
+ Text(detailText)
+ .font(.footnote)
+ .foregroundStyle(.secondary)
+ }
+ }
+ .frame(width: 280, alignment: .leading)
+ .padding(14)
+ }
+
+ private var titleText: String {
+ if let scanError = source.scanError, !scanError.isEmpty {
+ return "Scan Failed"
+ }
+
+ if !source.scanStatus.isEmpty {
+ return source.scanStatus
+ }
+
+ return "Scanning Minecraft library..."
+ }
+
+ private var subtitleText: String? {
+ if let scanError = source.scanError, !scanError.isEmpty {
+ return scanError
+ }
+
+ if source.indexedItemCount > 0 {
+ return source.displayName
+ }
+
+ return "Searching \(source.displayName)"
+ }
+
+ private var detailText: String? {
+ if let diagnostic = source.scanDiagnostic, !diagnostic.isEmpty {
+ return diagnostic
+ }
+
+ guard source.scanError == nil, source.indexedItemCount > 0 else {
+ return nil
+ }
+
+ if source.isScanning, let scanProgress = source.scanProgress {
+ let percentage = Int((scanProgress * 100).rounded())
+ let previewLoadedCount = source.rawItems.filter(\.previewLoaded).count
+ let sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count
+ if sizeLoadedCount > 0 || source.scanStatus.contains("Calculating sizes") {
+ return "\(sizeLoadedCount) of \(source.indexedItemCount) sizes calculated • \(percentage)%"
+ }
+ if previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") {
+ return "\(previewLoadedCount) of \(source.indexedItemCount) previews loaded • \(percentage)%"
+ }
+
+ return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed • \(percentage)%"
+ }
+
+ return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
+ }
+}
+
+private struct CircularScanProgressView: View {
+ let progress: Double
+
+ var body: some View {
+ ZStack {
+ Circle()
+ .stroke(.secondary.opacity(0.18), lineWidth: 3)
+
+ Circle()
+ .trim(from: 0, to: max(0.02, min(progress, 1)))
+ .stroke(
+ Color.appAccent,
+ style: StrokeStyle(lineWidth: 3, lineCap: .round)
+ )
+ .rotationEffect(.degrees(-90))
+ }
+ .frame(width: 18, height: 18)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel("Scan progress")
+ .accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
}
}
@@ -174,9 +368,11 @@ private struct ConnectedDeviceRow: View {
var body: some View {
HStack(alignment: .top, spacing: 10) {
- Image(systemName: iconName)
- .frame(width: 16)
- .foregroundStyle(iconColor)
+ ConnectedDeviceTransportIcon(
+ baseSymbolName: iconName,
+ connection: entry.device.connection,
+ tint: iconColor
+ )
VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name)
@@ -246,6 +442,49 @@ private struct ConnectedDeviceRow: View {
}
}
+private struct ConnectedDeviceTransportIcon: View {
+ let baseSymbolName: String
+ let connection: DeviceConnection
+ let tint: Color
+
+ var body: some View {
+ ZStack(alignment: .bottomTrailing) {
+ Image(systemName: baseSymbolName)
+ .font(.system(size: 22, weight: .medium))
+ .foregroundStyle(tint)
+ .frame(width: 28, height: 28)
+
+ Image(systemName: badgeSymbolName)
+ .font(.system(size: 8, weight: .bold))
+ .foregroundStyle(.primary)
+ .padding(4)
+ .background(.thinMaterial, in: Circle())
+ .offset(x: 4, y: 4)
+ }
+ .help(helpText)
+ .accessibilityElement(children: .ignore)
+ .accessibilityLabel(helpText)
+ }
+
+ private var badgeSymbolName: String {
+ switch connection {
+ case .usb:
+ return "cable.connector"
+ case .network:
+ return "wifi"
+ }
+ }
+
+ private var helpText: String {
+ switch connection {
+ case .usb:
+ return "Connected by USB"
+ case .network:
+ return "Connected by Network"
+ }
+ }
+}
+
private struct SidebarFooterView: View {
let state: SidebarFooterState
let revealAction: (URL) -> Void
diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift
index 57503cc..1c4749c 100644
--- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift
+++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceAccess.swift
@@ -12,6 +12,7 @@ struct AppleMobileDeviceSummary: Sendable {
let deviceIdentifier: String
let productType: String
let productVersion: String
+ let connectionType: String
let trustState: DeviceTrustState
}
@@ -28,9 +29,24 @@ struct AppleMobileMinecraftLibraryItemSummary: Sendable {
let relativePath: String
let folderName: String
let displayName: String
+ let hasIcon: Bool
+}
+
+struct AppleMobilePackReferenceSummary: Sendable {
+ let name: String
+ let contentType: String
+ let uuid: String?
+ let version: String?
+ let source: String
+}
+
+struct AppleMobileMinecraftItemMetadataSummary: Sendable {
+ let relativePath: String
+ let displayName: String?
let packUUID: String?
let packVersion: String?
let minimumEngineVersion: String?
+ let packReferences: [AppleMobilePackReferenceSummary]
}
struct AppleMobileDevicePathMetrics: Sendable {
@@ -38,11 +54,50 @@ struct AppleMobileDevicePathMetrics: Sendable {
let modifiedDate: Date?
}
+actor AppleMobileDeviceOperationLimiter {
+ static let shared = AppleMobileDeviceOperationLimiter()
+
+ private var activeDevices = Set()
+ private var waitingContinuations: [String: [CheckedContinuation]] = [:]
+
+ func run(
+ for deviceIdentifier: String,
+ operation: @Sendable () async throws -> T
+ ) async throws -> T {
+ await acquire(deviceIdentifier: deviceIdentifier)
+ defer { release(deviceIdentifier: deviceIdentifier) }
+ return try await operation()
+ }
+
+ private func acquire(deviceIdentifier: String) async {
+ guard activeDevices.contains(deviceIdentifier) else {
+ activeDevices.insert(deviceIdentifier)
+ return
+ }
+
+ await withCheckedContinuation { continuation in
+ waitingContinuations[deviceIdentifier, default: []].append(continuation)
+ }
+ }
+
+ private func release(deviceIdentifier: String) {
+ guard var queuedContinuations = waitingContinuations[deviceIdentifier], !queuedContinuations.isEmpty else {
+ activeDevices.remove(deviceIdentifier)
+ waitingContinuations[deviceIdentifier] = nil
+ return
+ }
+
+ let continuation = queuedContinuations.removeFirst()
+ waitingContinuations[deviceIdentifier] = queuedContinuations.isEmpty ? nil : queuedContinuations
+ continuation.resume()
+ }
+}
+
enum AppleMobileDeviceAccess {
- static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary {
+ static func connectedDevices() async throws -> [AppleMobileDeviceSummary] {
try await Task.detached(priority: .userInitiated) {
var error: NSError?
- guard let response = WMMCopyFirstConnectedDeviceSummary(&error) else {
+ guard let response = WMMCopyConnectedDeviceSummaries(&error) else {
throw error ?? NSError(
domain: "AppleMobileDeviceAccess",
code: 1,
@@ -50,14 +105,7 @@ enum AppleMobileDeviceAccess {
)
}
- guard
- let deviceName = response["deviceName"] as? String,
- let deviceIdentifier = response["deviceIdentifier"] as? String,
- let productType = response["productType"] as? String,
- let productVersion = response["productVersion"] as? String,
- let trustStateRawValue = response["trustState"] as? String,
- let trustState = DeviceTrustState(rawValue: trustStateRawValue)
- else {
+ guard let rawDevices = response["devices"] as? [[String: Any]] else {
throw NSError(
domain: "AppleMobileDeviceAccess",
code: 2,
@@ -65,211 +113,323 @@ enum AppleMobileDeviceAccess {
)
}
- return AppleMobileDeviceSummary(
- deviceName: deviceName,
- deviceIdentifier: deviceIdentifier,
- productType: productType,
- productVersion: productVersion,
- trustState: trustState
- )
+ return try rawDevices.map { device in
+ guard
+ let deviceName = device["deviceName"] as? String,
+ let deviceIdentifier = device["deviceIdentifier"] as? String,
+ let productType = device["productType"] as? String,
+ let productVersion = device["productVersion"] as? String,
+ let connectionType = device["connectionType"] as? String,
+ let trustStateRawValue = device["trustState"] as? String,
+ let trustState = DeviceTrustState(rawValue: trustStateRawValue)
+ else {
+ throw NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 2,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice summary returned an unexpected payload."]
+ )
+ }
+
+ return AppleMobileDeviceSummary(
+ deviceName: deviceName,
+ deviceIdentifier: deviceIdentifier,
+ productType: productType,
+ productVersion: productVersion,
+ connectionType: connectionType,
+ trustState: trustState
+ )
+ }
}.value
}
static func mirrorSubtree(
+ deviceIdentifier: String,
bundleIdentifier: String,
relativePath: String,
destinationDirectoryURL: URL
) async throws {
- try await Task.detached(priority: .userInitiated) {
- var error: NSError?
- let didCopy = WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
- bundleIdentifier,
- relativePath,
- destinationDirectoryURL,
- &error
- )
-
- if !didCopy {
- throw error ?? NSError(
- domain: "AppleMobileDeviceAccess",
- code: 2,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice subtree mirror failed."]
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .userInitiated) {
+ var error: NSError?
+ let didCopy = WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
+ deviceIdentifier,
+ bundleIdentifier,
+ relativePath,
+ destinationDirectoryURL,
+ &error
)
- }
- }.value
+
+ if !didCopy {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 2,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice subtree mirror failed."]
+ )
+ }
+ }.value
+ }
}
- static func listApplications() async throws -> [AppleMobileDeviceApplicationSummary] {
- try await Task.detached(priority: .userInitiated) {
- var error: NSError?
- guard let response = WMMCopyFirstConnectedDeviceApplicationList(&error) else {
- throw error ?? NSError(
- domain: "AppleMobileDeviceAccess",
- code: 3,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing failed."]
- )
- }
-
- guard let rawApplications = response["applications"] as? [[String: Any]] else {
- throw NSError(
- domain: "AppleMobileDeviceAccess",
- code: 4,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing returned an unexpected payload."]
- )
- }
-
- return rawApplications.compactMap { application in
- guard
- let bundleIdentifier = application["bundleIdentifier"] as? String,
- let displayName = application["displayName"] as? String
- else {
- return nil
+ static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .userInitiated) {
+ var error: NSError?
+ guard let response = WMMCopyConnectedDeviceApplicationList(deviceIdentifier, &error) else {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 3,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing failed."]
+ )
}
- return AppleMobileDeviceApplicationSummary(
- bundleIdentifier: bundleIdentifier,
- displayName: displayName,
- fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]),
- supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
- )
- }
- }.value
+ guard let rawApplications = response["applications"] as? [[String: Any]] else {
+ throw NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 4,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing returned an unexpected payload."]
+ )
+ }
+
+ return rawApplications.compactMap { application in
+ guard
+ let bundleIdentifier = application["bundleIdentifier"] as? String,
+ let displayName = application["displayName"] as? String
+ else {
+ return nil
+ }
+
+ return AppleMobileDeviceApplicationSummary(
+ bundleIdentifier: bundleIdentifier,
+ displayName: displayName,
+ fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]),
+ supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
+ )
+ }
+ }.value
+ }
}
static func listDirectory(
+ deviceIdentifier: String,
bundleIdentifier: String,
relativePath: String
) async throws -> [String] {
- try await Task.detached(priority: .userInitiated) {
- var error: NSError?
- guard let response = WMMCopyFirstConnectedDeviceAppDirectoryListing(
- bundleIdentifier,
- relativePath,
- &error
- ) else {
- throw error ?? NSError(
- domain: "AppleMobileDeviceAccess",
- code: 7,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
- )
- }
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .userInitiated) {
+ var error: NSError?
+ guard let response = WMMCopyConnectedDeviceAppDirectoryListing(
+ deviceIdentifier,
+ bundleIdentifier,
+ relativePath,
+ &error
+ ) else {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 7,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
+ )
+ }
- return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
- }.value
+ return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
+ }.value
+ }
}
static func fileData(
+ deviceIdentifier: String,
bundleIdentifier: String,
relativePath: String
) async throws -> Data {
- try await Task.detached(priority: .userInitiated) {
- var error: NSError?
- guard let data = WMMCopyFirstConnectedDeviceAppFileData(
- bundleIdentifier,
- relativePath,
- &error
- ) else {
- throw error ?? NSError(
- domain: "AppleMobileDeviceAccess",
- code: 8,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
- )
- }
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .userInitiated) {
+ var error: NSError?
+ guard let data = WMMCopyConnectedDeviceAppFileData(
+ deviceIdentifier,
+ bundleIdentifier,
+ relativePath,
+ &error
+ ) else {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 8,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
+ )
+ }
- return data as Data
- }.value
+ return data as Data
+ }.value
+ }
}
static func minecraftLibrarySnapshot(
+ deviceIdentifier: String,
bundleIdentifier: String,
relativePath: String
) async throws -> [AppleMobileMinecraftLibraryItemSummary] {
- try await Task.detached(priority: .userInitiated) {
- var error: NSError?
- guard let response = WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
- bundleIdentifier,
- relativePath,
- &error
- ) else {
- throw error ?? NSError(
- domain: "AppleMobileDeviceAccess",
- code: 5,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
- )
- }
-
- guard let rawItems = response["items"] as? [[String: Any]] else {
- throw NSError(
- domain: "AppleMobileDeviceAccess",
- code: 6,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
- )
- }
-
- return rawItems.compactMap { item in
- guard
- let contentType = item["contentType"] as? String,
- let collectionFolderName = item["collectionFolderName"] as? String,
- let relativePath = item["relativePath"] as? String,
- let folderName = item["folderName"] as? String,
- let displayName = item["displayName"] as? String
- else {
- return nil
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .userInitiated) {
+ var error: NSError?
+ guard let response = WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
+ deviceIdentifier,
+ bundleIdentifier,
+ relativePath,
+ &error
+ ) else {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 5,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
+ )
}
- return AppleMobileMinecraftLibraryItemSummary(
- contentType: contentType,
- collectionFolderName: collectionFolderName,
- relativePath: relativePath,
- folderName: folderName,
- displayName: displayName,
- packUUID: (item["packUUID"] as? String)?.lowercased(),
- packVersion: item["packVersion"] as? String,
- minimumEngineVersion: item["minimumEngineVersion"] as? String
- )
- }
- }.value
+ guard let rawItems = response["items"] as? [[String: Any]] else {
+ throw NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 6,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
+ )
+ }
+
+ return rawItems.compactMap { item in
+ guard
+ let contentType = item["contentType"] as? String,
+ let collectionFolderName = item["collectionFolderName"] as? String,
+ let relativePath = item["relativePath"] as? String,
+ let folderName = item["folderName"] as? String,
+ let displayName = item["displayName"] as? String
+ else {
+ return nil
+ }
+
+ return AppleMobileMinecraftLibraryItemSummary(
+ contentType: contentType,
+ collectionFolderName: collectionFolderName,
+ relativePath: relativePath,
+ folderName: folderName,
+ displayName: displayName,
+ hasIcon: flexibleBool(from: item["hasIcon"])
+ )
+ }
+ }.value
+ }
+ }
+
+ static func minecraftMetadataBatch(
+ deviceIdentifier: String,
+ bundleIdentifier: String,
+ relativePath: String,
+ items: [AppleMobileMinecraftLibraryItemSummary]
+ ) async throws -> [AppleMobileMinecraftItemMetadataSummary] {
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .userInitiated) {
+ let requestItems = items.map { item in
+ [
+ "contentType": item.contentType,
+ "relativePath": item.relativePath,
+ "folderName": item.folderName
+ ]
+ }
+
+ var error: NSError?
+ guard let response = WMMCopyConnectedDeviceMinecraftMetadataBatch(
+ deviceIdentifier,
+ bundleIdentifier,
+ relativePath,
+ requestItems,
+ &error
+ ) else {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 10,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice metadata batch failed."]
+ )
+ }
+
+ guard let rawItems = response["items"] as? [[String: Any]] else {
+ throw NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 11,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice metadata batch returned an unexpected payload."]
+ )
+ }
+
+ return rawItems.compactMap { item in
+ guard let relativePath = item["relativePath"] as? String else {
+ return nil
+ }
+
+ return AppleMobileMinecraftItemMetadataSummary(
+ relativePath: relativePath,
+ displayName: item["displayName"] as? String,
+ packUUID: (item["packUUID"] as? String)?.lowercased(),
+ packVersion: item["packVersion"] as? String,
+ minimumEngineVersion: item["minimumEngineVersion"] as? String,
+ packReferences: (item["packReferences"] as? [[String: Any]] ?? []).compactMap { reference in
+ guard
+ let name = reference["name"] as? String,
+ let contentType = reference["contentType"] as? String,
+ let source = reference["source"] as? String
+ else {
+ return nil
+ }
+
+ return AppleMobilePackReferenceSummary(
+ name: name,
+ contentType: contentType,
+ uuid: (reference["uuid"] as? String)?.lowercased(),
+ version: reference["version"] as? String,
+ source: source
+ )
+ }
+ )
+ }
+ }.value
+ }
}
static func pathMetrics(
+ deviceIdentifier: String,
bundleIdentifier: String,
relativePath: String
) async throws -> AppleMobileDevicePathMetrics {
- try await Task.detached(priority: .utility) {
- var error: NSError?
- guard let response = WMMCopyFirstConnectedDeviceAppPathMetrics(
- bundleIdentifier,
- relativePath,
- &error
- ) else {
- throw error ?? NSError(
- domain: "AppleMobileDeviceAccess",
- code: 9,
- userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
+ try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
+ try await Task.detached(priority: .utility) {
+ var error: NSError?
+ guard let response = WMMCopyConnectedDeviceAppPathMetrics(
+ deviceIdentifier,
+ bundleIdentifier,
+ relativePath,
+ &error
+ ) else {
+ throw error ?? NSError(
+ domain: "AppleMobileDeviceAccess",
+ code: 9,
+ userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
+ )
+ }
+
+ let rawSize = response["sizeBytes"]
+ let sizeBytes: Int64?
+ switch rawSize {
+ case let number as NSNumber:
+ sizeBytes = number.int64Value
+ case let value as Int64:
+ sizeBytes = value
+ case let value as Int:
+ sizeBytes = Int64(value)
+ default:
+ sizeBytes = nil
+ }
+
+ return AppleMobileDevicePathMetrics(
+ sizeBytes: sizeBytes,
+ modifiedDate: response["modifiedDate"] as? Date
)
- }
-
- let rawSize = response["sizeBytes"]
- let sizeBytes: Int64?
- switch rawSize {
- case let number as NSNumber:
- sizeBytes = number.int64Value
- case let value as Int64:
- sizeBytes = value
- case let value as Int:
- sizeBytes = Int64(value)
- default:
- sizeBytes = nil
- }
-
- return AppleMobileDevicePathMetrics(
- sizeBytes: sizeBytes,
- modifiedDate: response["modifiedDate"] as? Date
- )
- }.value
+ }.value
+ }
}
- private static func flexibleBool(from value: Any?) -> Bool {
+ nonisolated private static func flexibleBool(from value: Any?) -> Bool {
switch value {
case let value as Bool:
return value
diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h
index 68582e7..ed6b088 100644
--- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h
+++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.h
@@ -12,54 +12,73 @@ NS_ASSUME_NONNULL_BEGIN
FOUNDATION_EXPORT NSErrorDomain const WMMMobileDeviceErrorDomain;
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceSummary(NSError **error);
+WMMCopyConnectedDeviceSummaries(NSError **error);
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceApplicationList(NSError **error);
+WMMCopyConnectedDeviceApplicationList(
+ NSString *deviceIdentifier,
+ NSError **error
+);
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceApplicationDetails(
+WMMCopyConnectedDeviceApplicationDetails(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSError **error
);
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceAppDirectoryListing(
+WMMCopyConnectedDeviceAppDirectoryListing(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
);
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceAppPathProbeResults(
+WMMCopyConnectedDeviceAppPathProbeResults(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSArray *paths,
NSError **error
);
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
- NSString *bundleIdentifier,
- NSString *relativePath,
- NSError **error
-);
-
-FOUNDATION_EXPORT NSData * _Nullable
-WMMCopyFirstConnectedDeviceAppFileData(
+WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
);
FOUNDATION_EXPORT NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceAppPathMetrics(
+WMMCopyConnectedDeviceMinecraftMetadataBatch(
+ NSString *deviceIdentifier,
+ NSString *bundleIdentifier,
+ NSString *relativePath,
+ NSArray *> *items,
+ NSError **error
+);
+
+FOUNDATION_EXPORT NSData * _Nullable
+WMMCopyConnectedDeviceAppFileData(
+ NSString *deviceIdentifier,
+ NSString *bundleIdentifier,
+ NSString *relativePath,
+ NSError **error
+);
+
+FOUNDATION_EXPORT NSDictionary * _Nullable
+WMMCopyConnectedDeviceAppPathMetrics(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
);
FOUNDATION_EXPORT BOOL
-WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
+WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSURL *destinationDirectoryURL,
diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m
index 0784a54..524bac9 100644
--- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m
+++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceBridge.m
@@ -152,8 +152,11 @@ typedef struct {
typedef struct {
WMMMobileDeviceFunctions *functions;
CFRunLoopRef runLoop;
- AMDeviceRef device;
-} WMMDeviceWaitContext;
+ NSMutableArray *devices;
+} WMMDeviceCollectionContext;
+
+static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDeviceRef device, CFStringRef key);
+static NSInteger WMMConnectionPreferenceRank(AMDeviceRef device);
static NSError *WMMMakeError(NSInteger code, NSString *description) {
return [NSError errorWithDomain:WMMMobileDeviceErrorDomain code:code userInfo:@{
@@ -273,20 +276,16 @@ static void WMMDeviceNotificationCallback(struct am_device_notification_callback
return;
}
- WMMDeviceWaitContext *context = contextPointer;
- if (context->device == NULL) {
- context->device = context->functions->AMDeviceRetain(info->dev);
- if (context->runLoop != NULL) {
- CFRunLoopStop(context->runLoop);
- }
- }
+ WMMDeviceCollectionContext *context = contextPointer;
+ AMDeviceRef retainedDevice = context->functions->AMDeviceRetain(info->dev);
+ [context->devices addObject:[NSValue valueWithPointer:retainedDevice]];
}
-static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functions, NSError **error) {
- WMMDeviceWaitContext context = {
+static NSArray *WMMCopyConnectedDevices(WMMMobileDeviceFunctions *functions, NSError **error) {
+ WMMDeviceCollectionContext context = {
.functions = functions,
.runLoop = CFRunLoopGetCurrent(),
- .device = NULL
+ .devices = [NSMutableArray array]
};
AMDeviceNotificationRef subscription = NULL;
@@ -307,11 +306,78 @@ static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functio
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false);
functions->AMDeviceNotificationUnsubscribe(subscription);
- if (context.device == NULL && error != NULL) {
+ if (context.devices.count == 0 && error != NULL) {
*error = WMMMakeError(3, @"No connected iPhone or iPad was detected through MobileDevice.framework.");
}
- return context.device;
+ return [context.devices copy];
+}
+
+static NSString *WMMResolvedDeviceIdentifier(WMMMobileDeviceFunctions *functions, AMDeviceRef device) {
+ if (functions->AMDeviceCopyDeviceIdentifier != NULL) {
+ CFStringRef copiedIdentifier = functions->AMDeviceCopyDeviceIdentifier(device);
+ if (copiedIdentifier != NULL) {
+ return CFBridgingRelease(copiedIdentifier);
+ }
+ }
+
+ return WMMDeviceStringValue(functions, device, CFSTR("UniqueDeviceID")) ?:
+ WMMDeviceStringValue(functions, device, CFSTR("SerialNumber")) ?:
+ @"";
+}
+
+static void WMMReleaseDeviceValues(WMMMobileDeviceFunctions *functions, NSArray *devices) {
+ for (NSValue *value in devices) {
+ AMDeviceRef device = (AMDeviceRef)value.pointerValue;
+ if (device != NULL) {
+ functions->AMDeviceRelease(device);
+ }
+ }
+}
+
+static AMDeviceRef WMMCopyConnectedDevice(
+ WMMMobileDeviceFunctions *functions,
+ NSString *targetDeviceIdentifier,
+ NSError **error
+) {
+ NSArray *devices = WMMCopyConnectedDevices(functions, error);
+ if (devices.count == 0) {
+ return NULL;
+ }
+
+ AMDeviceRef matchedDevice = NULL;
+ for (NSValue *value in devices) {
+ AMDeviceRef device = (AMDeviceRef)value.pointerValue;
+ NSString *deviceIdentifier = WMMResolvedDeviceIdentifier(functions, device);
+ BOOL matchesTarget = targetDeviceIdentifier.length == 0 || [deviceIdentifier isEqualToString:targetDeviceIdentifier];
+ if (!matchesTarget) {
+ if (device != NULL) {
+ functions->AMDeviceRelease(device);
+ }
+ continue;
+ }
+
+ if (matchedDevice == NULL) {
+ matchedDevice = device;
+ continue;
+ }
+
+ if (WMMConnectionPreferenceRank(device) > WMMConnectionPreferenceRank(matchedDevice)) {
+ functions->AMDeviceRelease(matchedDevice);
+ matchedDevice = device;
+ continue;
+ }
+
+ if (device != NULL) {
+ functions->AMDeviceRelease(device);
+ }
+ }
+
+ if (matchedDevice == NULL && error != NULL) {
+ *error = WMMMakeError(3, [NSString stringWithFormat:@"The connected device %@ is no longer available.", targetDeviceIdentifier]);
+ }
+
+ return matchedDevice;
}
static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDeviceRef device, CFStringRef key) {
@@ -332,6 +398,66 @@ static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDev
return CFBridgingRelease(value);
}
+static NSString *WMMInferredConnectionType(AMDeviceRef device) {
+ if (device == NULL) {
+ return @"USB";
+ }
+
+ NSString *deviceDescription = [(__bridge id)device description];
+ if (deviceDescription.length == 0) {
+ return @"USB";
+ }
+
+ if ([deviceDescription containsString:@"FullServiceName = "]) {
+ return @"Network";
+ }
+
+ if ([deviceDescription containsString:@"location ID = "]) {
+ return @"USB";
+ }
+
+ return @"USB";
+}
+
+static NSInteger WMMConnectionPreferenceRank(AMDeviceRef device) {
+ NSString *connectionType = WMMInferredConnectionType(device);
+ if ([connectionType caseInsensitiveCompare:@"USB"] == NSOrderedSame) {
+ return 2;
+ }
+
+ if ([connectionType caseInsensitiveCompare:@"Network"] == NSOrderedSame) {
+ return 1;
+ }
+
+ return 0;
+}
+
+static void WMMLogDeviceTransportDiagnostics(
+ WMMMobileDeviceFunctions *functions,
+ AMDeviceRef device,
+ NSString *resolvedIdentifier
+) {
+ NSArray *keys = @[
+ @"ConnectionType",
+ @"InterfaceType",
+ @"DeviceName",
+ @"ProductType",
+ @"ProductVersion",
+ @"UniqueDeviceID",
+ @"SerialNumber",
+ @"WiFiAddress",
+ @"EthernetAddress"
+ ];
+
+ NSMutableDictionary *values = [NSMutableDictionary dictionary];
+ for (NSString *key in keys) {
+ NSString *value = WMMDeviceStringValue(functions, device, (__bridge CFStringRef)key);
+ values[key] = value.length > 0 ? value : @"";
+ }
+
+ NSLog(@"[DeviceSummary] udid=%@ diagnostics=%@", resolvedIdentifier, values);
+}
+
static BOOL WMMConnectAndValidateDevice(
WMMMobileDeviceFunctions *functions,
AMDeviceRef device,
@@ -391,6 +517,26 @@ static void WMMDisconnectDevice(
functions->AMDeviceDisconnect(device);
}
+static void WMMCloseVendSession(
+ WMMMobileDeviceFunctions *functions,
+ AMDeviceRef _Nullable device,
+ BOOL hadSession,
+ AFCConnectionRef _Nullable afcConnection,
+ AMDServiceConnectionRef _Nullable backingServiceConnection
+) {
+ if (afcConnection != NULL) {
+ functions->AFCConnectionClose(afcConnection);
+ }
+
+ if (backingServiceConnection != NULL) {
+ functions->AMDServiceConnectionInvalidate(backingServiceConnection);
+ }
+
+ if (device != NULL) {
+ WMMDisconnectDevice(functions, device, hadSession);
+ }
+}
+
static AFCConnectionRef _Nullable WMMCreateAFCConnectionFromServiceConnection(
WMMMobileDeviceFunctions *functions,
AMDServiceConnectionRef serviceConnection
@@ -1001,6 +1147,115 @@ static NSString * _Nullable WMMVersionStringFromValue(id value) {
return nil;
}
+static NSDictionary *WMMBuildPackReferenceSummary(
+ NSString *name,
+ NSString *contentType,
+ NSString * _Nullable uuid,
+ NSString * _Nullable version,
+ NSString *source
+) {
+ NSMutableDictionary *summary = [@{
+ @"name": name,
+ @"contentType": contentType,
+ @"source": source
+ } mutableCopy];
+
+ if (uuid.length > 0) {
+ summary[@"uuid"] = [uuid lowercaseString];
+ }
+
+ if (version.length > 0) {
+ summary[@"version"] = version;
+ }
+
+ return summary;
+}
+
+static NSArray *> *WMMParsePackReferenceSummariesFromData(
+ NSData *data,
+ NSString *contentType
+) {
+ id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
+ if (![jsonObject isKindOfClass:[NSArray class]]) {
+ return @[];
+ }
+
+ NSMutableArray *> *references = [NSMutableArray array];
+ for (id entry in (NSArray *)jsonObject) {
+ if (![entry isKindOfClass:[NSDictionary class]]) {
+ continue;
+ }
+
+ NSDictionary *entryDictionary = (NSDictionary *)entry;
+ NSString *uuid = [entryDictionary[@"pack_id"] isKindOfClass:[NSString class]] ? entryDictionary[@"pack_id"] : nil;
+ NSString *version = WMMVersionStringFromValue(entryDictionary[@"version"]);
+ NSString *name = uuid.length > 0 ? uuid : @"Referenced Pack";
+ [references addObject:WMMBuildPackReferenceSummary(
+ name,
+ contentType,
+ uuid,
+ version,
+ @"referencedByWorld"
+ )];
+ }
+
+ return references;
+}
+
+static NSArray *> *WMMReadPackReferenceSummariesFile(
+ WMMMobileDeviceFunctions *functions,
+ AFCConnectionRef afcConnection,
+ NSString *remotePath,
+ NSString *contentType
+) {
+ NSData *data = WMMCopyAFCFileData(functions, afcConnection, remotePath, NULL);
+ if (data == nil) {
+ return @[];
+ }
+
+ return WMMParsePackReferenceSummariesFromData(data, contentType);
+}
+
+static NSArray *> *WMMLoadEmbeddedPackReferenceSummaries(
+ WMMMobileDeviceFunctions *functions,
+ AFCConnectionRef afcConnection,
+ NSString *remoteFolderPath,
+ NSString *contentType
+) {
+ NSMutableArray *childFolders = nil;
+ if (WMMReadAFCDirectory(functions, afcConnection, remoteFolderPath, &childFolders) != 0 || childFolders == nil) {
+ return @[];
+ }
+
+ NSMutableArray *> *references = [NSMutableArray array];
+ for (NSString *childFolder in childFolders) {
+ if ([childFolder isEqualToString:@"."] || [childFolder isEqualToString:@".."]) {
+ continue;
+ }
+
+ NSString *manifestPath = [[remoteFolderPath stringByAppendingPathComponent:childFolder] stringByAppendingPathComponent:@"manifest.json"];
+ NSDictionary *header = WMMReadManifestHeader(functions, afcConnection, manifestPath);
+ if (header == nil) {
+ continue;
+ }
+
+ NSString *name = [header[@"name"] isKindOfClass:[NSString class]] && [header[@"name"] length] > 0
+ ? header[@"name"]
+ : childFolder;
+ NSString *uuid = [header[@"uuid"] isKindOfClass:[NSString class]] ? header[@"uuid"] : nil;
+ NSString *version = WMMVersionStringFromValue(header[@"version"]);
+ [references addObject:WMMBuildPackReferenceSummary(
+ name,
+ contentType,
+ uuid,
+ version,
+ @"embeddedInWorld"
+ )];
+ }
+
+ return references;
+}
+
static BOOL WMMIsCandidateItem(NSString *contentType, NSArray *entries) {
if ([contentType isEqualToString:@"World"]) {
return WMMEntryArrayContainsName(entries, @"level.dat")
@@ -1014,12 +1269,9 @@ static BOOL WMMIsCandidateItem(NSString *contentType, NSArray *entri
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg");
}
-static NSDictionary *WMMBuildMinecraftItemSummary(
- WMMMobileDeviceFunctions *functions,
- AFCConnectionRef afcConnection,
+static NSDictionary *WMMBuildShallowMinecraftItemSummary(
NSString *contentType,
NSString *collectionFolderName,
- NSString *itemRemotePath,
NSString *itemRelativePath,
NSString *folderName,
NSArray *entries
@@ -1028,44 +1280,9 @@ static NSDictionary *WMMBuildMinecraftItemSummary(
@"contentType": contentType,
@"collectionFolderName": collectionFolderName,
@"relativePath": itemRelativePath,
- @"folderName": folderName
+ @"folderName": folderName,
+ @"displayName": folderName
} mutableCopy];
-
- NSString *displayName = folderName;
- if ([contentType isEqualToString:@"World"]) {
- NSString *levelName = WMMReadUTF8TextFile(
- functions,
- afcConnection,
- [itemRemotePath stringByAppendingPathComponent:@"levelname.txt"]
- );
- if (levelName.length > 0) {
- displayName = levelName;
- }
- } else {
- NSDictionary *header = WMMReadManifestHeader(
- functions,
- afcConnection,
- [itemRemotePath stringByAppendingPathComponent:@"manifest.json"]
- );
- NSString *manifestName = [header[@"name"] isKindOfClass:[NSString class]] ? header[@"name"] : nil;
- if (manifestName.length > 0) {
- displayName = manifestName;
- }
-
- if ([header[@"uuid"] isKindOfClass:[NSString class]]) {
- summary[@"packUUID"] = [header[@"uuid"] lowercaseString];
- }
- NSString *version = WMMVersionStringFromValue(header[@"version"]);
- if (version.length > 0) {
- summary[@"packVersion"] = version;
- }
- NSString *minimumEngineVersion = WMMVersionStringFromValue(header[@"min_engine_version"]);
- if (minimumEngineVersion.length > 0) {
- summary[@"minimumEngineVersion"] = minimumEngineVersion;
- }
- }
-
- summary[@"displayName"] = displayName;
summary[@"hasIcon"] = @(
WMMEntryArrayContainsName(entries, @"world_icon.png")
|| WMMEntryArrayContainsName(entries, @"world_icon.jpeg")
@@ -1107,12 +1324,9 @@ static void WMMAppendCollectionSummaries(
}
NSString *itemRelativePath = [collectionFolderName stringByAppendingPathComponent:itemFolderName];
- [results addObject:WMMBuildMinecraftItemSummary(
- functions,
- afcConnection,
+ [results addObject:WMMBuildShallowMinecraftItemSummary(
contentType,
collectionFolderName,
- itemRemotePath,
itemRelativePath,
itemFolderName,
itemEntries
@@ -1152,12 +1366,9 @@ static void WMMAppendCollectionSummaries(
}
NSString *embeddedRelativePath = [itemRelativePath stringByAppendingPathComponent:[embeddedFolder stringByAppendingPathComponent:embeddedFolderName]];
- [results addObject:WMMBuildMinecraftItemSummary(
- functions,
- afcConnection,
+ [results addObject:WMMBuildShallowMinecraftItemSummary(
embeddedType,
embeddedFolder,
- embeddedItemPath,
embeddedRelativePath,
embeddedFolderName,
embeddedEntries
@@ -1168,47 +1379,78 @@ static void WMMAppendCollectionSummaries(
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceSummary(NSError **error) {
+WMMCopyConnectedDeviceSummaries(NSError **error) {
WMMMobileDeviceFunctions functions;
if (!WMMLoadFunctions(&functions, error)) {
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
- if (device == NULL) {
+ NSArray *devices = WMMCopyConnectedDevices(&functions, error);
+ if (devices.count == 0) {
return nil;
}
- if (!WMMConnectAndValidateDevice(&functions, device, NO, error)) {
- functions.AMDeviceRelease(device);
- return nil;
+ NSMutableArray *> *summaries = [NSMutableArray array];
+ NSMutableDictionary *preferredDevicesByIdentifier = [NSMutableDictionary dictionary];
+ for (NSValue *value in devices) {
+ AMDeviceRef device = (AMDeviceRef)value.pointerValue;
+ if (device == NULL) {
+ continue;
+ }
+
+ NSString *deviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
+ if (deviceIdentifier.length == 0) {
+ continue;
+ }
+
+ NSValue *existingValue = preferredDevicesByIdentifier[deviceIdentifier];
+ AMDeviceRef existingDevice = existingValue != nil ? (AMDeviceRef)existingValue.pointerValue : NULL;
+ if (existingDevice != NULL && WMMConnectionPreferenceRank(device) <= WMMConnectionPreferenceRank(existingDevice)) {
+ continue;
+ }
+ preferredDevicesByIdentifier[deviceIdentifier] = value;
}
- NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
- NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
- NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
- NSString *deviceIdentifier =
- WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
- WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
- @"";
+ for (NSString *deviceIdentifier in preferredDevicesByIdentifier) {
+ AMDeviceRef device = (AMDeviceRef)preferredDevicesByIdentifier[deviceIdentifier].pointerValue;
+ if (device == NULL) {
+ continue;
+ }
+ NSString *deviceName = @"Unknown Device";
+ NSString *productType = @"";
+ NSString *productVersion = @"";
+ NSString *connectionType = WMMDeviceStringValue(&functions, device, CFSTR("ConnectionType")) ?: WMMInferredConnectionType(device);
+ if (WMMConnectAndValidateDevice(&functions, device, NO, NULL)) {
+ deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
+ productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
+ productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
+ connectionType = WMMDeviceStringValue(&functions, device, CFSTR("ConnectionType")) ?: WMMInferredConnectionType(device);
+ WMMLogDeviceTransportDiagnostics(&functions, device, deviceIdentifier);
+ WMMDisconnectDevice(&functions, device, NO);
+ }
- NSString *trustState = @"trusted";
+ [summaries addObject:@{
+ @"deviceName": deviceName,
+ @"deviceIdentifier": deviceIdentifier,
+ @"productType": productType,
+ @"productVersion": productVersion,
+ @"connectionType": connectionType,
+ @"trustState": @"trusted"
+ }];
+ }
- WMMDisconnectDevice(&functions, device, NO);
+ WMMReleaseDeviceValues(&functions, devices);
- functions.AMDeviceRelease(device);
+ [summaries sortUsingComparator:^NSComparisonResult(NSDictionary *lhs, NSDictionary *rhs) {
+ return [lhs[@"deviceName"] localizedStandardCompare:rhs[@"deviceName"]];
+ }];
- return @{
- @"deviceName": deviceName,
- @"deviceIdentifier": deviceIdentifier,
- @"productType": productType,
- @"productVersion": productVersion,
- @"trustState": trustState
- };
+ return @{ @"devices": summaries };
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceAppDirectoryListing(
+WMMCopyConnectedDeviceAppDirectoryListing(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
@@ -1225,7 +1467,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1238,10 +1480,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
- NSString *deviceIdentifier =
- WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
- WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
- @"";
+ NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
AMDServiceConnectionRef backingServiceConnection = NULL;
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
@@ -1252,7 +1491,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
error
);
if (afcConnection == NULL) {
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
@@ -1268,11 +1507,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
NSMutableArray *rootEntries = nil;
const int rootStatus = WMMReadAFCDirectory(&functions, afcConnection, @"/", &rootEntries);
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
if (error != NULL) {
NSString *message = [NSString stringWithFormat:@"AFC directory read failed for %@ (%d).", normalizedPath, directoryStatus];
@@ -1286,11 +1521,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
}
return nil;
}
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
@@ -1298,7 +1529,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
@"bundleIdentifier": bundleIdentifier,
@"path": normalizedPath,
@"deviceName": deviceName,
- @"deviceIdentifier": deviceIdentifier,
+ @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType,
@"productVersion": productVersion,
@"entries": entries
@@ -1306,7 +1537,8 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceAppPathProbeResults(
+WMMCopyConnectedDeviceAppPathProbeResults(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSArray *paths,
NSError **error
@@ -1323,7 +1555,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1336,10 +1568,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
- NSString *deviceIdentifier =
- WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
- WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
- @"";
+ NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
AMDServiceConnectionRef backingServiceConnection = NULL;
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
@@ -1350,7 +1579,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
error
);
if (afcConnection == NULL) {
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
@@ -1376,17 +1605,13 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
[results addObject:result];
}
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
return @{
@"bundleIdentifier": bundleIdentifier,
@"deviceName": deviceName,
- @"deviceIdentifier": deviceIdentifier,
+ @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType,
@"productVersion": productVersion,
@"results": results
@@ -1394,13 +1619,16 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
+WMMCopyConnectedDeviceApplicationList(
+ NSString *deviceIdentifier,
+ NSError **error
+) {
WMMMobileDeviceFunctions functions;
if (!WMMLoadFunctions(&functions, error)) {
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1413,10 +1641,7 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
- NSString *deviceIdentifier =
- WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
- WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
- @"";
+ NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
CFDictionaryRef appDictionary = NULL;
const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary);
@@ -1484,7 +1709,7 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
return @{
@"deviceName": deviceName,
- @"deviceIdentifier": deviceIdentifier,
+ @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType,
@"productVersion": productVersion,
@"applications": applications
@@ -1492,7 +1717,8 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
+WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
@@ -1509,7 +1735,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1528,7 +1754,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
error
);
if (afcConnection == NULL) {
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
@@ -1554,11 +1780,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
);
}
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
return @{
@@ -1568,8 +1790,140 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
};
}
+NSDictionary * _Nullable
+WMMCopyConnectedDeviceMinecraftMetadataBatch(
+ NSString *deviceIdentifier,
+ NSString *bundleIdentifier,
+ NSString *relativePath,
+ NSArray *> *items,
+ NSError **error
+) {
+ if (bundleIdentifier.length == 0) {
+ if (error != NULL) {
+ *error = WMMMakeError(19, @"A bundle identifier is required.");
+ }
+ return nil;
+ }
+
+ WMMMobileDeviceFunctions functions;
+ if (!WMMLoadFunctions(&functions, error)) {
+ return nil;
+ }
+
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
+ if (device == NULL) {
+ return nil;
+ }
+
+ if (!WMMConnectAndValidateDevice(&functions, device, YES, error)) {
+ functions.AMDeviceRelease(device);
+ return nil;
+ }
+
+ AMDServiceConnectionRef backingServiceConnection = NULL;
+ AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
+ &functions,
+ device,
+ bundleIdentifier,
+ &backingServiceConnection,
+ error
+ );
+ if (afcConnection == NULL) {
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
+ functions.AMDeviceRelease(device);
+ return nil;
+ }
+
+ NSString *normalizedRootPath = WMMNormalizedAFCPath(relativePath);
+ NSMutableArray *> *results = [NSMutableArray array];
+
+ for (NSDictionary *item in items) {
+ NSString *contentType = [item[@"contentType"] isKindOfClass:[NSString class]] ? item[@"contentType"] : nil;
+ NSString *relativeItemPath = [item[@"relativePath"] isKindOfClass:[NSString class]] ? item[@"relativePath"] : nil;
+ NSString *folderName = [item[@"folderName"] isKindOfClass:[NSString class]] ? item[@"folderName"] : nil;
+ if (contentType.length == 0 || relativeItemPath.length == 0 || folderName.length == 0) {
+ continue;
+ }
+
+ NSString *itemRemotePath = [normalizedRootPath stringByAppendingPathComponent:relativeItemPath];
+ NSMutableDictionary *metadata = [@{
+ @"relativePath": relativeItemPath
+ } mutableCopy];
+
+ if ([contentType isEqualToString:@"World"]) {
+ NSString *levelName = WMMReadUTF8TextFile(
+ &functions,
+ afcConnection,
+ [itemRemotePath stringByAppendingPathComponent:@"levelname.txt"]
+ );
+ metadata[@"displayName"] = levelName.length > 0 ? levelName : folderName;
+
+ NSMutableArray *> *packReferences = [NSMutableArray array];
+ [packReferences addObjectsFromArray:WMMReadPackReferenceSummariesFile(
+ &functions,
+ afcConnection,
+ [itemRemotePath stringByAppendingPathComponent:@"world_behavior_packs.json"],
+ @"Behavior Pack"
+ )];
+ [packReferences addObjectsFromArray:WMMReadPackReferenceSummariesFile(
+ &functions,
+ afcConnection,
+ [itemRemotePath stringByAppendingPathComponent:@"world_resource_packs.json"],
+ @"Resource Pack"
+ )];
+ [packReferences addObjectsFromArray:WMMLoadEmbeddedPackReferenceSummaries(
+ &functions,
+ afcConnection,
+ [itemRemotePath stringByAppendingPathComponent:@"behavior_packs"],
+ @"Behavior Pack"
+ )];
+ [packReferences addObjectsFromArray:WMMLoadEmbeddedPackReferenceSummaries(
+ &functions,
+ afcConnection,
+ [itemRemotePath stringByAppendingPathComponent:@"resource_packs"],
+ @"Resource Pack"
+ )];
+ if (packReferences.count > 0) {
+ metadata[@"packReferences"] = packReferences;
+ }
+ } else {
+ NSDictionary *header = WMMReadManifestHeader(
+ &functions,
+ afcConnection,
+ [itemRemotePath stringByAppendingPathComponent:@"manifest.json"]
+ );
+ NSString *manifestName = [header[@"name"] isKindOfClass:[NSString class]] ? header[@"name"] : nil;
+ metadata[@"displayName"] = manifestName.length > 0 ? manifestName : folderName;
+
+ if ([header[@"uuid"] isKindOfClass:[NSString class]]) {
+ metadata[@"packUUID"] = [header[@"uuid"] lowercaseString];
+ }
+ NSString *version = WMMVersionStringFromValue(header[@"version"]);
+ if (version.length > 0) {
+ metadata[@"packVersion"] = version;
+ }
+ NSString *minimumEngineVersion = WMMVersionStringFromValue(header[@"min_engine_version"]);
+ if (minimumEngineVersion.length > 0) {
+ metadata[@"minimumEngineVersion"] = minimumEngineVersion;
+ }
+ }
+
+ [results addObject:metadata];
+ }
+
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
+ functions.AMDeviceRelease(device);
+
+ return @{
+ @"bundleIdentifier": bundleIdentifier,
+ @"path": normalizedRootPath,
+ @"items": results
+ };
+}
+
NSData * _Nullable
-WMMCopyFirstConnectedDeviceAppFileData(
+WMMCopyConnectedDeviceAppFileData(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
@@ -1586,7 +1940,7 @@ WMMCopyFirstConnectedDeviceAppFileData(
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1605,7 +1959,7 @@ WMMCopyFirstConnectedDeviceAppFileData(
error
);
if (afcConnection == NULL) {
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
@@ -1613,18 +1967,15 @@ WMMCopyFirstConnectedDeviceAppFileData(
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
NSData *data = WMMCopyAFCFileData(&functions, afcConnection, normalizedPath, error);
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
return data;
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceAppPathMetrics(
+WMMCopyConnectedDeviceAppPathMetrics(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
@@ -1641,7 +1992,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1660,7 +2011,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
error
);
if (afcConnection == NULL) {
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return nil;
}
@@ -1673,11 +2024,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
error
);
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
if (metrics == nil) {
@@ -1693,7 +2040,8 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
}
NSDictionary * _Nullable
-WMMCopyFirstConnectedDeviceApplicationDetails(
+WMMCopyConnectedDeviceApplicationDetails(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSError **error
) {
@@ -1709,7 +2057,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
return nil;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return nil;
}
@@ -1722,10 +2070,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
- NSString *deviceIdentifier =
- WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
- WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
- @"";
+ NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
CFDictionaryRef appDictionary = NULL;
const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary);
@@ -1774,7 +2119,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
return @{
@"deviceName": deviceName,
- @"deviceIdentifier": deviceIdentifier,
+ @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType,
@"productVersion": productVersion,
@"bundleIdentifier": bundleIdentifier,
@@ -1783,7 +2128,8 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
}
BOOL
-WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
+WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
+ NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSURL *destinationDirectoryURL,
@@ -1808,7 +2154,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
return NO;
}
- AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
+ AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) {
return NO;
}
@@ -1827,7 +2173,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
error
);
if (afcConnection == NULL) {
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device);
return NO;
}
@@ -1848,11 +2194,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
error
);
- functions.AFCConnectionClose(afcConnection);
- if (backingServiceConnection != NULL) {
- functions.AMDServiceConnectionInvalidate(backingServiceConnection);
- }
- WMMDisconnectDevice(&functions, device, YES);
+ WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
functions.AMDeviceRelease(device);
if (!success) {
diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift
index 6cebf43..9a44bdb 100644
--- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift
+++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/AppleMobileDevice/AppleMobileDeviceSourceAccess.swift
@@ -47,21 +47,29 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
- let device = try await AppleMobileDeviceAccess.firstConnectedDevice()
- return [
- ConnectedDevice(
+ let devices = try await AppleMobileDeviceAccess.connectedDevices()
+ return devices.compactMap { device in
+ let connection: DeviceConnection
+ switch device.connectionType.lowercased() {
+ case "network", "wifi", "wi-fi":
+ connection = .network
+ default:
+ connection = .usb
+ }
+
+ return ConnectedDevice(
udid: device.deviceIdentifier,
name: device.deviceName,
productType: device.productType.isEmpty ? nil : device.productType,
osVersion: device.productVersion.isEmpty ? nil : device.productVersion,
- connection: .usb,
+ connection: connection,
trustState: device.trustState
)
- ]
+ }
}
nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] {
- let applications = try await AppleMobileDeviceAccess.listApplications()
+ let applications = try await AppleMobileDeviceAccess.listApplications(deviceIdentifier: device.udid)
return applications
.filter { application in
@@ -109,12 +117,23 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: requestedSubpath
)
+ let metadataByPath = try await metadataByRelativePath(
+ for: summaries,
+ container: container,
+ requestedSubpath: requestedSubpath
+ )
+
let items = summaries.compactMap { summary in
- makeItem(from: summary, source: source)
+ makeItem(
+ from: summary,
+ metadata: metadataByPath[summary.relativePath],
+ source: source
+ )
}
for item in items {
@@ -126,35 +145,39 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
var enrichedItem = item
- guard case .connectedDevice(_, let container) = source.origin else {
+ guard case .connectedDevice = source.origin else {
enrichedItem.metadataLoaded = true
+ enrichedItem.previewLoaded = true
return enrichedItem
}
- enrichedItem.iconURL = await loadRemoteIcon(for: item, source: source, container: container)
enrichedItem.modifiedDate = nil
-
- if item.contentType == .world {
- if let levelDatPath = remoteItemPath(for: item, in: source, appending: "level.dat"),
- let levelDatData = try? await AppleMobileDeviceAccess.fileData(
- bundleIdentifier: container.appID,
- relativePath: levelDatPath
- ) {
- enrichedItem.worldMetadata = BedrockLevelMetadataDecoder.decode(fromLevelDatData: levelDatData)
- enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
- }
-
- enrichedItem.packReferences = await loadWorldPackReferences(for: item, source: source, container: container)
- } else {
- enrichedItem.lastPlayedDate = nil
- enrichedItem.packReferences = []
- }
-
+ enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
enrichedItem.metadataLoaded = true
+ enrichedItem.previewLoaded = !enrichedItem.hasKnownIcon
enrichedItem.sizeLoaded = false
return enrichedItem
}
+ nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
+ var previewItem = item
+ guard case .connectedDevice(_, let container) = source.origin else {
+ previewItem.previewLoaded = true
+ return previewItem
+ }
+
+ if previewItem.hasKnownIcon {
+ previewItem.iconURL = await loadRemoteIcon(
+ for: previewItem,
+ source: source,
+ container: container
+ )
+ }
+
+ previewItem.previewLoaded = true
+ return previewItem
+ }
+
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
var sizedItem = item
guard case .connectedDevice(_, let container) = source.origin else {
@@ -164,6 +187,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
if let remoteItemPath = remoteItemPath(for: item, in: source),
let metrics = try? await AppleMobileDeviceAccess.pathMetrics(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteItemPath
) {
@@ -187,6 +211,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
let entries = try await AppleMobileDeviceAccess.listDirectory(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteFolderPath
)
@@ -221,6 +246,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
do {
try await AppleMobileDeviceAccess.mirrorSubtree(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteItemPath,
destinationDirectoryURL: destinationURL
@@ -242,6 +268,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
nonisolated private func makeItem(
from summary: AppleMobileMinecraftLibraryItemSummary,
+ metadata: AppleMobileMinecraftItemMetadataSummary?,
source: MinecraftSource
) -> MinecraftContentItem? {
let contentType: MinecraftContentType
@@ -262,21 +289,92 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true)
let folderURL = source.folderURL.appendingPathComponent(summary.relativePath, isDirectory: true)
+ let displayName = metadata?.displayName ?? summary.displayName
+ let packMetadataDetails: PackMetadataDetails?
+ if let minimumEngineVersion = metadata?.minimumEngineVersion {
+ packMetadataDetails = PackMetadataDetails(minimumEngineVersion: minimumEngineVersion)
+ } else {
+ packMetadataDetails = nil
+ }
+
return MinecraftContentItem(
folderURL: folderURL,
folderName: summary.folderName,
contentType: contentType,
collectionRootURL: collectionRootURL,
- displayName: summary.displayName,
+ displayName: displayName,
iconURL: nil,
- packUUID: summary.packUUID,
- packVersion: summary.packVersion,
- packMetadataDetails: PackMetadataDetails(minimumEngineVersion: summary.minimumEngineVersion),
+ hasKnownIcon: summary.hasIcon,
+ packUUID: metadata?.packUUID,
+ packVersion: metadata?.packVersion,
+ packMetadataDetails: packMetadataDetails,
+ packReferences: packReferences(from: metadata?.packReferences ?? []),
metadataLoaded: false,
+ previewLoaded: !summary.hasIcon,
sizeLoaded: false
)
}
+ nonisolated private func metadataByRelativePath(
+ for summaries: [AppleMobileMinecraftLibraryItemSummary],
+ container: DeviceAppContainer,
+ requestedSubpath: String
+ ) async throws -> [String: AppleMobileMinecraftItemMetadataSummary] {
+ guard !summaries.isEmpty else {
+ return [:]
+ }
+
+ let metadata = try await AppleMobileDeviceAccess.minecraftMetadataBatch(
+ deviceIdentifier: container.deviceUDID,
+ bundleIdentifier: container.appID,
+ relativePath: requestedSubpath,
+ items: summaries
+ )
+
+ return Dictionary(uniqueKeysWithValues: metadata.map { ($0.relativePath, $0) })
+ }
+
+ nonisolated private func packReferences(
+ from summaries: [AppleMobilePackReferenceSummary]
+ ) -> [ContentPackReference] {
+ let references = summaries.compactMap { summary -> ContentPackReference? in
+ let contentType: MinecraftContentType
+ switch summary.contentType {
+ case MinecraftContentType.behaviorPack.rawValue:
+ contentType = .behaviorPack
+ case MinecraftContentType.resourcePack.rawValue:
+ contentType = .resourcePack
+ case MinecraftContentType.skinPack.rawValue:
+ contentType = .skinPack
+ case MinecraftContentType.worldTemplate.rawValue:
+ contentType = .worldTemplate
+ default:
+ return nil
+ }
+
+ let source: PackSource
+ switch summary.source {
+ case PackSource.embeddedInWorld.rawValue:
+ source = .embeddedInWorld
+ case PackSource.foundInCollection.rawValue:
+ source = .foundInCollection
+ default:
+ source = .referencedByWorld
+ }
+
+ return ContentPackReference(
+ name: summary.name,
+ type: contentType,
+ iconURL: nil,
+ uuid: summary.uuid,
+ version: summary.version,
+ source: source
+ )
+ }
+
+ return uniquePackReferences(references)
+ }
+
nonisolated private func remoteItemPath(
for item: MinecraftContentItem,
in source: MinecraftSource,
@@ -326,6 +424,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
continue
}
guard let data = try? await AppleMobileDeviceAccess.fileData(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remotePath
) else {
@@ -358,6 +457,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"),
let behaviorData = try? await AppleMobileDeviceAccess.fileData(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: behaviorRefPath
) {
@@ -366,6 +466,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"),
let resourceData = try? await AppleMobileDeviceAccess.fileData(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: resourceRefPath
) {
@@ -402,6 +503,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
}
guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: remoteFolderPath
) else {
@@ -413,6 +515,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder)
let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json")
guard let manifestData = try? await AppleMobileDeviceAccess.fileData(
+ deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID,
relativePath: manifestPath
) else {
diff --git a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift
index 6a16189..4db30e0 100644
--- a/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift
+++ b/World Manager for Minecraft/SourceAccess/ConnectedDevice/ConnectedDeviceSourcePickerView.swift
@@ -191,18 +191,28 @@ struct ConnectedDeviceSourcePickerView: View {
isLoadingDevices = true
availabilityMessage = nil
errorMessage = nil
+ let previousSelectedDeviceID = selectedDeviceID
do {
let devices = try await deviceDiscoveryService.listConnectedDevices()
+ let resolvedSelectedDeviceID = devices.contains(where: { $0.id == previousSelectedDeviceID })
+ ? previousSelectedDeviceID
+ : devices.first?.id
+ let shouldReloadContainers = resolvedSelectedDeviceID != nil && resolvedSelectedDeviceID == previousSelectedDeviceID
+
await MainActor.run {
self.devices = devices
- self.selectedDeviceID = devices.first?.id
+ self.selectedDeviceID = resolvedSelectedDeviceID
if devices.isEmpty {
self.containers = []
self.selectedContainerID = nil
}
self.isLoadingDevices = false
}
+
+ if shouldReloadContainers {
+ await loadContainersForSelectedDevice()
+ }
} catch {
await MainActor.run {
self.devices = []
diff --git a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift
index 48d0345..e93b5c3 100644
--- a/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift
+++ b/World Manager for Minecraft/SourceAccess/Core/SourceAccessCoordinator.swift
@@ -16,6 +16,7 @@ protocol SourceAccessMethod: Sendable {
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem]
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
+ nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry]
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
@@ -55,6 +56,11 @@ extension SourceAccessMethod {
return item
}
+ nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
+ _ = source
+ return item
+ }
+
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
_ = source
return item
@@ -134,6 +140,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return await accessMethod(for: source).enrich(item, for: source)
}
+ nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
+ return await accessMethod(for: source).loadPreviewAssets(for: item, in: source)
+ }
+
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
return await accessMethod(for: source).loadSize(for: item, in: source)
}
diff --git a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift
index 3f5a5f2..335b0d0 100644
--- a/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift
+++ b/World Manager for Minecraft/World_Manager_for_MinecraftApp.swift
@@ -5,10 +5,19 @@
// Created by John Burwell on 2026-05-25.
//
+import AppKit
import SwiftUI
@main
struct World_Manager_for_MinecraftApp: App {
+ @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
+
+ init() {
+ Task {
+ await ScanNotificationService.shared.requestAuthorizationIfNeeded()
+ }
+ }
+
var body: some Scene {
WindowGroup {
ContentView()
@@ -21,6 +30,41 @@ struct World_Manager_for_MinecraftApp: App {
}
}
+final class AppDelegate: NSObject, NSApplicationDelegate {
+ func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
+ AppTerminationCoordinator.shared.beginTermination(for: sender)
+ }
+}
+
+@MainActor
+final class AppTerminationCoordinator {
+ static let shared = AppTerminationCoordinator()
+
+ private weak var library: SourceLibrary?
+ private var isTerminationInProgress = false
+
+ func register(library: SourceLibrary) {
+ self.library = library
+ }
+
+ func beginTermination(for application: NSApplication) -> NSApplication.TerminateReply {
+ guard !isTerminationInProgress else {
+ return .terminateLater
+ }
+
+ isTerminationInProgress = true
+
+ Task { @MainActor [weak self] in
+ if let library = self?.library {
+ await library.shutdownGracefully(timeout: 2.0)
+ }
+ application.reply(toApplicationShouldTerminate: true)
+ }
+
+ return .terminateLater
+ }
+}
+
private struct WindowChromeConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView {
let view = NSView()
diff --git a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift
index 48ce466..cec7c8f 100644
--- a/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift
+++ b/World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift
@@ -254,6 +254,124 @@ struct World_Manager_for_MinecraftTests {
#expect(enrichedPack.packMetadataDetails?.minimumEngineVersion == "1.19.50")
}
+ @Test func minecraftPackageInspectorReadsMcworldMetadata() async throws {
+ let fileManager = FileManager.default
+ let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let sourceDirectoryURL = workingURL.appendingPathComponent("WorldSource", isDirectory: true)
+ let archiveURL = workingURL.appendingPathComponent("WorldA.mcworld", isDirectory: false)
+ defer { try? fileManager.removeItem(at: workingURL) }
+
+ try fileManager.createDirectory(at: sourceDirectoryURL, withIntermediateDirectories: true)
+ try "World A".write(
+ to: sourceDirectoryURL.appendingPathComponent("levelname.txt"),
+ atomically: true,
+ encoding: .utf8
+ )
+
+ let lastPlayedMilliseconds: Int64 = 1_700_000_000_000
+ let levelDat = makeBedrockLevelDat(
+ root: .compound([
+ "GameType": .int(0),
+ "Difficulty": .int(2),
+ "LastPlayed": .long(lastPlayedMilliseconds)
+ ]),
+ storageVersion: 10
+ )
+ try levelDat.write(to: sourceDirectoryURL.appendingPathComponent("level.dat"))
+ try makeArchive(from: sourceDirectoryURL, to: archiveURL)
+
+ let inspection = try MinecraftPackageInspector.inspectArchive(at: archiveURL)
+ defer { MinecraftPackageInspector.cleanup(inspection) }
+
+ #expect(inspection.contentType == .world)
+ #expect(inspection.displayName == "World A")
+ #expect(inspection.worldMetadata?.gameMode == "Survival")
+ #expect(inspection.worldMetadata?.difficulty == "Normal")
+ #expect(inspection.worldMetadata?.lastPlayedDate == Date(timeIntervalSince1970: 1_700_000_000))
+ }
+
+ @Test func minecraftPackageInspectorInfersMcpackTypeAndManifest() async throws {
+ let fileManager = FileManager.default
+ let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let sourceDirectoryURL = workingURL.appendingPathComponent("PackSource", isDirectory: true)
+ let archiveURL = workingURL.appendingPathComponent("PackA.mcpack", isDirectory: false)
+ defer { try? fileManager.removeItem(at: workingURL) }
+
+ try fileManager.createDirectory(at: sourceDirectoryURL, withIntermediateDirectories: true)
+ let manifest = """
+ {
+ "header": {
+ "name": "Resource Pack A",
+ "uuid": "b92836dc-f5a4-4f10-9d29-6a2d2ea3a2f7",
+ "version": [2, 1, 0],
+ "min_engine_version": [1, 21, 0]
+ },
+ "modules": [
+ {
+ "type": "resources",
+ "uuid": "818ac674-bf84-4955-a1db-5bf7acd63488",
+ "version": [2, 1, 0]
+ }
+ ]
+ }
+ """
+ try manifest.write(
+ to: sourceDirectoryURL.appendingPathComponent("manifest.json"),
+ atomically: true,
+ encoding: .utf8
+ )
+ try makeArchive(from: sourceDirectoryURL, to: archiveURL)
+
+ let inspection = try MinecraftPackageInspector.inspectArchive(at: archiveURL)
+ defer { MinecraftPackageInspector.cleanup(inspection) }
+
+ #expect(inspection.contentType == .resourcePack)
+ #expect(inspection.displayName == "Resource Pack A")
+ #expect(inspection.manifestMetadata?.uuid == "b92836dc-f5a4-4f10-9d29-6a2d2ea3a2f7")
+ #expect(inspection.manifestMetadata?.version == "2.1.0")
+ #expect(inspection.manifestMetadata?.minimumEngineVersion == "1.21.0")
+ }
+
+ @Test func minecraftPackageInspectorAcceptsSingleNestedTopLevelFolder() async throws {
+ let fileManager = FileManager.default
+ let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
+ let archiveRootURL = workingURL.appendingPathComponent("ArchiveRoot", isDirectory: true)
+ let nestedPackURL = archiveRootURL.appendingPathComponent("Nested Pack", isDirectory: true)
+ let archiveURL = workingURL.appendingPathComponent("Nested.mcpack", isDirectory: false)
+ defer { try? fileManager.removeItem(at: workingURL) }
+
+ try fileManager.createDirectory(at: nestedPackURL, withIntermediateDirectories: true)
+ let manifest = """
+ {
+ "header": {
+ "name": "Nested Behavior Pack",
+ "uuid": "2bcd9b1a-c558-4906-9521-7cccd2f9ca56",
+ "version": [1, 0, 1]
+ },
+ "modules": [
+ {
+ "type": "data",
+ "uuid": "4fbe707b-7cd1-4d10-80a5-b4fb45f79095",
+ "version": [1, 0, 1]
+ }
+ ]
+ }
+ """
+ try manifest.write(
+ to: nestedPackURL.appendingPathComponent("manifest.json"),
+ atomically: true,
+ encoding: .utf8
+ )
+ try makeArchive(from: archiveRootURL, to: archiveURL)
+
+ let inspection = try MinecraftPackageInspector.inspectArchive(at: archiveURL)
+ defer { MinecraftPackageInspector.cleanup(inspection) }
+
+ #expect(inspection.contentRootURL.lastPathComponent == "Nested Pack")
+ #expect(inspection.contentType == .behaviorPack)
+ #expect(inspection.displayName == "Nested Behavior Pack")
+ }
+
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
@@ -375,6 +493,20 @@ struct World_Manager_for_MinecraftTests {
#expect(values["ConnectionType"] == "USB")
}
+ @Test func scanNotificationServiceFormatsCompletionMessage() async throws {
+ #expect(ScanNotificationService.completionMessage(itemCount: 0) == "No worlds or packs were found.")
+ #expect(ScanNotificationService.completionMessage(itemCount: 1) == "Found 1 item.")
+ #expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
+ }
+
+ @Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
+ let service = ScanNotificationService()
+
+ #expect(service.shouldNotifyAboutCompletedScan(duration: 2, isAppActive: false) == false)
+ #expect(service.shouldNotifyAboutCompletedScan(duration: 8, isAppActive: true) == false)
+ #expect(service.shouldNotifyAboutCompletedScan(duration: 8, isAppActive: false) == true)
+ }
+
}
private enum TestNBTTagType: UInt8 {
@@ -470,6 +602,43 @@ private func appendString(_ string: String, to data: inout Data) {
data.append(utf8)
}
+private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throws {
+ let process = Process()
+ process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
+ process.currentDirectoryURL = sourceDirectoryURL
+ process.arguments = [
+ "-c",
+ "-k",
+ "--norsrc",
+ ".",
+ archiveURL.path
+ ]
+
+ let outputPipe = Pipe()
+ process.standardOutput = outputPipe
+ process.standardError = outputPipe
+
+ try process.run()
+ process.waitUntilExit()
+
+ guard process.terminationStatus == 0 else {
+ let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
+ let output = String(data: outputData, encoding: .utf8) ?? ""
+ throw ArchiveTestError.failedToCreateArchive(output)
+ }
+}
+
+private enum ArchiveTestError: LocalizedError {
+ case failedToCreateArchive(String)
+
+ var errorDescription: String? {
+ switch self {
+ case .failedToCreateArchive(let output):
+ return output.isEmpty ? "Failed to create test archive." : output
+ }
+ }
+}
+
private func appendLE(_ value: T, to data: inout Data) {
var value = value.littleEndian
withUnsafeBytes(of: &value) { bytes in
diff --git a/World-Manager-for-Minecraft-Info.plist b/World-Manager-for-Minecraft-Info.plist
new file mode 100644
index 0000000..6e0f1f1
--- /dev/null
+++ b/World-Manager-for-Minecraft-Info.plist
@@ -0,0 +1,144 @@
+
+
+
+
+ CFBundleDocumentTypes
+
+
+ CFBundleTypeName
+ Minecraft World
+ CFBundleTypeIconFile
+ MinecraftVoxelDocument.png
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ us.b-wells.minecraft.mcworld
+
+
+
+ CFBundleTypeName
+ Minecraft Pack
+ CFBundleTypeIconFile
+ MinecraftVoxelDocument.png
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ us.b-wells.minecraft.mcpack
+
+
+
+ CFBundleTypeName
+ Minecraft Template
+ CFBundleTypeIconFile
+ MinecraftVoxelDocument.png
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Alternate
+ LSItemContentTypes
+
+ us.b-wells.minecraft.template
+
+
+
+ CFBundleTypeName
+ Minecraft Add-on
+ CFBundleTypeIconFile
+ MinecraftVoxelDocument.png
+ CFBundleTypeRole
+ Viewer
+ LSHandlerRank
+ Default
+ LSItemContentTypes
+
+ us.b-wells.minecraft.mcaddon
+
+
+
+ UTExportedTypeDeclarations
+
+
+ UTTypeConformsTo
+
+ public.zip-archive
+
+ UTTypeDescription
+ Minecraft World
+ UTTypeIconFile
+ MinecraftVoxelDocument.png
+ UTTypeIdentifier
+ us.b-wells.minecraft.mcworld
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ mcworld
+
+
+
+
+ UTTypeConformsTo
+
+ public.zip-archive
+
+ UTTypeDescription
+ Minecraft Pack
+ UTTypeIconFile
+ MinecraftVoxelDocument.png
+ UTTypeIdentifier
+ us.b-wells.minecraft.mcpack
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ mcpack
+
+
+
+
+ UTTypeConformsTo
+
+ public.zip-archive
+
+ UTTypeDescription
+ Minecraft Template
+ UTTypeIconFile
+ MinecraftVoxelDocument.png
+ UTTypeIdentifier
+ us.b-wells.minecraft.mctemplate
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ mctemplate
+
+
+
+
+ UTTypeConformsTo
+
+ public.zip-archive
+
+ UTTypeDescription
+ Minecraft Add-on
+ UTTypeIconFile
+ MinecraftVoxelDocument.png
+ UTTypeIdentifier
+ us.b-wells.minecraft.mcaddon
+ UTTypeTagSpecification
+
+ public.filename-extension
+
+ mcaddon
+
+
+
+
+
+
diff --git a/docs/quick-look-plan.md b/docs/quick-look-plan.md
new file mode 100644
index 0000000..34896ad
--- /dev/null
+++ b/docs/quick-look-plan.md
@@ -0,0 +1,96 @@
+# Quick Look Plan
+
+## Current State
+
+The shared Bedrock package inspection layer is implemented in app code and ready to be reused by Quick Look targets:
+
+- `World Manager for Minecraft/Services/MinecraftPackageInspector.swift`
+ - extracts `.mcworld`, `.mcpack`, `.mctemplate`, and `.mcaddon`
+ - normalizes archives with either flat contents or a single nested top-level folder
+ - infers pack type for ambiguous `.mcpack` and `.mcaddon` archives
+- `World Manager for Minecraft/Services/MinecraftContentMetadataReader.swift`
+ - shared manifest, icon, display-name, and world metadata parsing
+- `World Manager for Minecraft/QuickLook/MinecraftPackageTypes.swift`
+ - central UTType identifiers and extension definitions
+- `World Manager for Minecraft/QuickLook/MinecraftPackageQuickLookModel.swift`
+ - preview-friendly summary model
+- `World Manager for Minecraft/QuickLook/MinecraftPackageThumbnailRenderer.swift`
+ - branded thumbnail rendering with icon fallback
+
+Archive inspection is covered by tests in `World Manager for MinecraftTests/World_Manager_for_MinecraftTests.swift`.
+
+## Why The Extension Target Is Not Landed Yet
+
+The project is using Xcode's filesystem-synchronized project format. Adding a new Quick Look target by hand in `project.pbxproj` is possible, but it is the highest-risk part of this feature to do blind because:
+
+- the target graph, extension point declaration, product embedding, and bundle metadata all need to be correct together
+- a malformed `pbxproj` can break the project more broadly than a normal source change
+- the extension will need generated or explicit Info.plist keys for document/UTType registration
+
+The parsing and rendering code is now in a shape where the target layer can stay thin.
+
+## Recommended Next Steps
+
+1. Add a `Quick Look Thumbnail Extension` target in Xcode.
+2. Point it at the shared package inspector and thumbnail renderer.
+3. Register support for:
+ - `.mcworld`
+ - `.mcpack`
+ - `.mctemplate`
+ - `.mcaddon`
+4. Add a `Quick Look Preview Extension` target after thumbnails are working.
+5. Render the preview from `MinecraftPackageQuickLookModel`.
+
+## Thumbnail Extension Outline
+
+Implement a provider roughly like this:
+
+```swift
+final class ThumbnailProvider: QLThumbnailProvider {
+ override func provideThumbnail(
+ for request: QLFileThumbnailRequest,
+ _ handler: @escaping (QLThumbnailReply?, Error?) -> Void
+ ) {
+ do {
+ let inspection = try MinecraftPackageInspector.inspectArchive(at: request.fileURL)
+ defer { MinecraftPackageInspector.cleanup(inspection) }
+
+ guard let image = MinecraftPackageThumbnailRenderer.makeThumbnail(
+ for: inspection,
+ size: request.maximumSize,
+ scale: request.scale
+ ) else {
+ handler(nil, CocoaError(.fileReadCorruptFile))
+ return
+ }
+
+ handler(
+ QLThumbnailReply(contextSize: request.maximumSize) { context in
+ context.draw(image, in: CGRect(origin: .zero, size: request.maximumSize))
+ return true
+ },
+ nil
+ )
+ } catch {
+ handler(nil, error)
+ }
+ }
+}
+```
+
+## Preview Extension Outline
+
+The preview target can:
+
+- inspect the archive with `MinecraftPackageInspector`
+- build display content with `MinecraftPackageQuickLookModelBuilder`
+- render a compact SwiftUI summary view with:
+ - package icon or branded artwork
+ - title
+ - package kind
+ - key facts like version, UUID, minimum engine, game mode, difficulty, and last played
+
+## Verification Notes
+
+- `xcodebuild ... build` succeeds.
+- `xcodebuild ... test` currently does not complete cleanly because the existing test target already references `IFuseDeviceServices`, which is not in scope for the test build. That issue predates the Quick Look work.