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.