quick look, thumbnails, better pipelining, better network/usb support

This commit is contained in:
John Burwell 2026-05-27 20:31:33 -05:00
parent ab6661d66b
commit 3788b5f2a9
51 changed files with 4908 additions and 636 deletions

View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.Cocoa.XIB" version="3.0" toolsVersion="24506" targetRuntime="MacOSX.Cocoa" propertyAccessControl="none" useAutolayout="YES" customObjectInstantitationMethod="direct">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.CocoaPlugin" version="24506"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<objects>
<customObject id="-2" userLabel="File's Owner" customClass="PreviewViewController" customModule="MinecraftPackagePreviewExtension" customModuleProvider="target">
<connections>
<outlet property="view" destination="c22-O7-iKe" id="NRM-P4-wb6"/>
</connections>
</customObject>
<customObject id="-1" userLabel="First Responder" customClass="FirstResponder"/>
<customObject id="-3" userLabel="Application" customClass="NSObject"/>
<customView id="c22-O7-iKe" userLabel="Preview View">
<rect key="frame" x="0.0" y="0.0" width="480" height="272"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<point key="canvasLocation" x="145" y="-129"/>
</customView>
</objects>
</document>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>QLIsDataBasedPreview</key>
<false/>
<key>QLSupportedContentTypes</key>
<array>
<string>us.b-wells.minecraft.mcworld</string>
<string>us.b-wells.minecraft.mcpack</string>
<string>us.b-wells.minecraft.mctemplate</string>
<string>us.b-wells.minecraft.mcaddon</string>
</array>
<key>QLSupportsSearchableItems</key>
<false/>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.quicklook.preview</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PreviewViewController</string>
</dict>
</dict>
</plist>

View File

@ -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)
]
}
}
}

View File

@ -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
<key>QLIsDataBasedPreview</key>
<true/>
- Add the supported content types to QLSupportedContentTypes array in the extension's Info.plist.
- Change the NSExtensionPrincipalClass to this class.
e.g.
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).PreviewProvider</string>
- 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
}
}

View File

@ -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)
}
}
}

View File

@ -0,0 +1,25 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>QLSupportedContentTypes</key>
<array>
<string>us.b-wells.minecraft.mcworld</string>
<string>us.b-wells.minecraft.mcpack</string>
<string>us.b-wells.minecraft.mctemplate</string>
<string>us.b-wells.minecraft.mcaddon</string>
</array>
<key>QLThumbnailMinimumDimension</key>
<integer>0</integer>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.quicklook.thumbnail</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ThumbnailProvider</string>
</dict>
</dict>
</plist>

View File

@ -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)
]
}
}
}

View File

@ -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)
}
}
}

View File

@ -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)
}

View File

@ -6,6 +6,14 @@
objectVersion = 77; objectVersion = 77;
objects = { 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 */ /* Begin PBXContainerItemProxy section */
5218F9162FC4C9F100CAF7B7 /* PBXContainerItemProxy */ = { 5218F9162FC4C9F100CAF7B7 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
@ -21,17 +29,96 @@
remoteGlobalIDString = 5218F9052FC4C9EF00CAF7B7; remoteGlobalIDString = 5218F9052FC4C9EF00CAF7B7;
remoteInfo = "World Manager for Minecraft"; 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 */ /* 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 */ /* 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; }; 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; }; 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; }; 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 */ /* 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 */ /* Begin PBXFileSystemSynchronizedRootGroup section */
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */ = { 5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */ = {
isa = PBXFileSystemSynchronizedRootGroup; 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"; path = "World Manager for Minecraft";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -45,6 +132,22 @@
path = "World Manager for MinecraftUITests"; path = "World Manager for MinecraftUITests";
sourceTree = "<group>"; sourceTree = "<group>";
}; };
52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
52C72BD32FC7314E009928CB /* Exceptions for "MinecraftPackageThumbnailExtension" folder in "MinecraftPackageThumbnailExtension" target */,
);
path = MinecraftPackageThumbnailExtension;
sourceTree = "<group>";
};
52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
52C72BE92FC73171009928CB /* Exceptions for "MinecraftPackagePreviewExtension" folder in "MinecraftPackagePreviewExtension" target */,
);
path = MinecraftPackagePreviewExtension;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
@ -69,6 +172,23 @@
); );
runOnlyForDeploymentPostprocessing = 0; 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 */ /* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */ /* Begin PBXGroup section */
@ -78,6 +198,9 @@
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */, 5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
5218F9182FC4C9F100CAF7B7 /* World Manager for MinecraftTests */, 5218F9182FC4C9F100CAF7B7 /* World Manager for MinecraftTests */,
5218F9222FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */, 5218F9222FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */,
52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */,
52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */,
52C72BB02FC72940009928CB /* Frameworks */,
5218F9072FC4C9EF00CAF7B7 /* Products */, 5218F9072FC4C9EF00CAF7B7 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -88,10 +211,21 @@
5218F9062FC4C9EF00CAF7B7 /* World Manager for Minecraft.app */, 5218F9062FC4C9EF00CAF7B7 /* World Manager for Minecraft.app */,
5218F9152FC4C9F100CAF7B7 /* World Manager for MinecraftTests.xctest */, 5218F9152FC4C9F100CAF7B7 /* World Manager for MinecraftTests.xctest */,
5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */, 5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */,
52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */,
52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
52C72BB02FC72940009928CB /* Frameworks */ = {
isa = PBXGroup;
children = (
52C72BB12FC72940009928CB /* Quartz.framework */,
52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
@ -102,10 +236,13 @@
5218F9022FC4C9EF00CAF7B7 /* Sources */, 5218F9022FC4C9EF00CAF7B7 /* Sources */,
5218F9032FC4C9EF00CAF7B7 /* Frameworks */, 5218F9032FC4C9EF00CAF7B7 /* Frameworks */,
5218F9042FC4C9EF00CAF7B7 /* Resources */, 5218F9042FC4C9EF00CAF7B7 /* Resources */,
52C72BC32FC72940009928CB /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
52C72BD12FC7314E009928CB /* PBXTargetDependency */,
52C72BE72FC73171009928CB /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */, 5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
@ -163,6 +300,50 @@
productReference = 5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */; productReference = 5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */;
productType = "com.apple.product-type.bundle.ui-testing"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@ -184,6 +365,12 @@
CreatedOnToolsVersion = 26.2; CreatedOnToolsVersion = 26.2;
TestTargetID = 5218F9052FC4C9EF00CAF7B7; TestTargetID = 5218F9052FC4C9EF00CAF7B7;
}; };
52C72BC72FC7314D009928CB = {
CreatedOnToolsVersion = 26.2;
};
52C72BDA2FC73171009928CB = {
CreatedOnToolsVersion = 26.2;
};
}; };
}; };
buildConfigurationList = 5218F9012FC4C9EF00CAF7B7 /* Build configuration list for PBXProject "World Manager for Minecraft" */; buildConfigurationList = 5218F9012FC4C9EF00CAF7B7 /* Build configuration list for PBXProject "World Manager for Minecraft" */;
@ -203,6 +390,8 @@
5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */, 5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
5218F9142FC4C9F100CAF7B7 /* World Manager for MinecraftTests */, 5218F9142FC4C9F100CAF7B7 /* World Manager for MinecraftTests */,
5218F91E2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */, 5218F91E2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */,
52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */,
52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
@ -229,6 +418,20 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
52C72BC62FC7314D009928CB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
52C72BD92FC73171009928CB /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
@ -253,6 +456,20 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
52C72BC42FC7314D009928CB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
52C72BD72FC73171009928CB /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
@ -266,6 +483,16 @@
target = 5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */; target = 5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */;
targetProxy = 5218F9202FC4C9F100CAF7B7 /* PBXContainerItemProxy */; 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 */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
@ -399,6 +626,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "World-Manager-for-Minecraft-Info.plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -431,6 +659,7 @@
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readwrite; ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = "World-Manager-for-Minecraft-Info.plist";
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
INFOPLIST_KEY_NSHumanReadableCopyright = ""; INFOPLIST_KEY_NSHumanReadableCopyright = "";
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = (
@ -527,6 +756,122 @@
}; };
name = Release; 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 */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
@ -566,6 +911,24 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; 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 */ /* End XCConfigurationList section */
}; };
rootObject = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */; rootObject = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "52C72BDA2FC73171009928CB"
BuildableName = "MinecraftPackagePreviewExtension.appex"
BlueprintName = "MinecraftPackagePreviewExtension"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "2.0">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "52C72BC72FC7314D009928CB"
BuildableName = "MinecraftPackageThumbnailExtension.appex"
BlueprintName = "MinecraftPackageThumbnailExtension"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildActionEntry>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = ""
selectedLauncherIdentifier = "Xcode.IDEFoundation.Launcher.PosixSpawn"
launchStyle = "0"
askForAppToLaunch = "Yes"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES"
launchAutomaticallySubstyle = "2">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
<BuildActionEntries>
<BuildActionEntry
buildForTesting = "YES"
buildForRunning = "YES"
buildForProfiling = "YES"
buildForArchiving = "YES"
buildForAnalyzing = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
<BuildableProductRunnable
runnableDebuggingMode = "0">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9052FC4C9EF00CAF7B7"
BuildableName = "World Manager for Minecraft.app"
BlueprintName = "World Manager for Minecraft"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "2620"
version = "1.7">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"
buildArchitectures = "Automatic">
</BuildAction>
<TestAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES"
shouldAutocreateTestPlan = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "5218F9142FC4C9F100CAF7B7"
BuildableName = "World Manager for MinecraftTests.xctest"
BlueprintName = "World Manager for MinecraftTests"
ReferencedContainer = "container:World Manager for Minecraft.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
launchStyle = "0"
useCustomWorkingDirectory = "NO"
ignoresPersistentStateOnLaunch = "NO"
debugDocumentVersioning = "YES"
debugServiceExtension = "internal"
allowLocationSimulation = "YES">
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"
shouldUseLaunchSchemeArgsEnv = "YES"
savedToolIdentifier = ""
useCustomWorkingDirectory = "NO"
debugDocumentVersioning = "YES">
</ProfileAction>
<AnalyzeAction
buildConfiguration = "Debug">
</AnalyzeAction>
<ArchiveAction
buildConfiguration = "Release"
revealArchiveInOrganizer = "YES">
</ArchiveAction>
</Scheme>

View File

@ -4,11 +4,59 @@
<dict> <dict>
<key>SchemeUserState</key> <key>SchemeUserState</key>
<dict> <dict>
<key>MinecraftPackagePreviewExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>MinecraftPackageThumbnailExtension.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>2</integer>
</dict>
<key>World Manager for Minecraft.xcscheme_^#shared#^_</key> <key>World Manager for Minecraft.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>4</integer>
</dict>
<key>World Manager for MinecraftTests.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>3</integer>
</dict>
<key>World Manager for MinecraftUITests.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>
<integer>0</integer> <integer>0</integer>
</dict> </dict>
</dict> </dict>
<key>SuppressBuildableAutocreation</key>
<dict>
<key>5218F9052FC4C9EF00CAF7B7</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>5218F9142FC4C9F100CAF7B7</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>52C72BAE2FC72940009928CB</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>52C72BC72FC7314D009928CB</key>
<dict>
<key>primary</key>
<true/>
</dict>
<key>52C72BDA2FC73171009928CB</key>
<dict>
<key>primary</key>
<true/>
</dict>
</dict>
</dict> </dict>
</plist> </plist>

View File

@ -42,7 +42,7 @@ struct ContentView: View {
var body: some View { var body: some View {
NavigationSplitView(columnVisibility: $columnVisibility) { NavigationSplitView(columnVisibility: $columnVisibility) {
SourcesSidebarView( SourcesSidebarView(
localSources: library.localSources, sources: library.sidebarSources,
connectedDevices: library.connectedDevices, connectedDevices: library.connectedDevices,
selection: $selectedSidebarSelection, selection: $selectedSidebarSelection,
footerState: library.sidebarFooterState, footerState: library.sidebarFooterState,
@ -58,14 +58,7 @@ struct ContentView: View {
removeSource(source.id) removeSource(source.id)
}, },
revealFooterURLAction: revealURLInFinder(_:), revealFooterURLAction: revealURLInFinder(_:),
filters: sidebarFilters(for:), filters: sidebarFilters(for:)
matchedSource: { entry in
guard let sourceID = entry.matchedSourceID else {
return nil
}
return library.source(withID: sourceID)
}
) )
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380) .navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
} content: { } content: {
@ -144,7 +137,13 @@ struct ContentView: View {
} }
) )
} }
.task {
AppTerminationCoordinator.shared.register(library: library)
}
.disabled(library.isRestoringPersistedSources) .disabled(library.isRestoringPersistedSources)
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
library.shutdown()
}
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in .onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else { guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
return return

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 781 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 KiB

View File

@ -125,6 +125,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
let collectionRootURL: URL let collectionRootURL: URL
var displayName: String var displayName: String
var iconURL: URL? var iconURL: URL?
var hasKnownIcon: Bool
var lastPlayedDate: Date? var lastPlayedDate: Date?
var modifiedDate: Date? var modifiedDate: Date?
var sizeBytes: Int64? var sizeBytes: Int64?
@ -134,6 +135,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
var packReferences: [ContentPackReference] var packReferences: [ContentPackReference]
var worldMetadata: WorldMetadata? var worldMetadata: WorldMetadata?
var metadataLoaded: Bool var metadataLoaded: Bool
var previewLoaded: Bool
var sizeLoaded: Bool var sizeLoaded: Bool
nonisolated init( nonisolated init(
@ -143,6 +145,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
collectionRootURL: URL, collectionRootURL: URL,
displayName: String? = nil, displayName: String? = nil,
iconURL: URL? = nil, iconURL: URL? = nil,
hasKnownIcon: Bool = false,
lastPlayedDate: Date? = nil, lastPlayedDate: Date? = nil,
modifiedDate: Date? = nil, modifiedDate: Date? = nil,
sizeBytes: Int64? = nil, sizeBytes: Int64? = nil,
@ -152,6 +155,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
packReferences: [ContentPackReference] = [], packReferences: [ContentPackReference] = [],
worldMetadata: WorldMetadata? = nil, worldMetadata: WorldMetadata? = nil,
metadataLoaded: Bool = false, metadataLoaded: Bool = false,
previewLoaded: Bool = false,
sizeLoaded: Bool = false sizeLoaded: Bool = false
) { ) {
self.id = folderURL.standardizedFileURL self.id = folderURL.standardizedFileURL
@ -161,6 +165,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
self.collectionRootURL = collectionRootURL self.collectionRootURL = collectionRootURL
self.displayName = displayName ?? folderName self.displayName = displayName ?? folderName
self.iconURL = iconURL self.iconURL = iconURL
self.hasKnownIcon = hasKnownIcon
self.lastPlayedDate = lastPlayedDate self.lastPlayedDate = lastPlayedDate
self.modifiedDate = modifiedDate self.modifiedDate = modifiedDate
self.sizeBytes = sizeBytes self.sizeBytes = sizeBytes
@ -170,6 +175,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
self.packReferences = packReferences self.packReferences = packReferences
self.worldMetadata = worldMetadata self.worldMetadata = worldMetadata
self.metadataLoaded = metadataLoaded self.metadataLoaded = metadataLoaded
self.previewLoaded = previewLoaded
self.sizeLoaded = sizeLoaded self.sizeLoaded = sizeLoaded
} }

View File

@ -25,6 +25,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
var isScanning: Bool var isScanning: Bool
var scanStatus: String var scanStatus: String
var scanError: String? var scanError: String?
var scanDiagnostic: String?
var scanProgress: Double?
var indexedItemCount: Int var indexedItemCount: Int
var indexedDetailCount: Int var indexedDetailCount: Int
var lastScanDate: Date? var lastScanDate: Date?
@ -61,6 +63,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
self.isScanning = false self.isScanning = false
self.scanStatus = "" self.scanStatus = ""
self.scanError = nil self.scanError = nil
self.scanDiagnostic = nil
self.scanProgress = nil
self.indexedItemCount = 0 self.indexedItemCount = 0
self.indexedDetailCount = 0 self.indexedDetailCount = 0
self.lastScanDate = nil self.lastScanDate = nil

View File

@ -290,7 +290,7 @@ struct SidebarColumnPreviewContainer: View {
var body: some View { var body: some View {
NavigationStack { NavigationStack {
SourcesSidebarView( SourcesSidebarView(
localSources: PreviewFixtures.allSources, sources: PreviewFixtures.allSources,
connectedDevices: [], connectedDevices: [],
selection: $selection, selection: $selection,
footerState: PreviewFixtures.sidebarFooter, footerState: PreviewFixtures.sidebarFooter,
@ -300,8 +300,7 @@ struct SidebarColumnPreviewContainer: View {
rescanSourceAction: { _ in }, rescanSourceAction: { _ in },
removeSourceAction: { _ in }, removeSourceAction: { _ in },
revealFooterURLAction: { _ in }, revealFooterURLAction: { _ in },
filters: PreviewFixtures.sidebarFilters(for:), filters: PreviewFixtures.sidebarFilters(for:)
matchedSource: { _ in nil }
) )
} }
} }

View File

@ -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)
}
}

View File

@ -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)
]
}
}
}

View File

@ -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)
}

View File

@ -176,6 +176,7 @@ enum ContentPackageExporter {
} }
try await AppleMobileDeviceAccess.mirrorSubtree( try await AppleMobileDeviceAccess.mirrorSubtree(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: remoteItemPath, relativePath: remoteItemPath,
destinationDirectoryURL: destinationURL destinationDirectoryURL: destinationURL

View File

@ -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
}
}

View File

@ -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<String> = [
"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
}
}

View File

@ -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."
}
}
}

View File

@ -7,6 +7,7 @@
import Combine import Combine
import Foundation import Foundation
import OSLog
struct SidebarFooterState { struct SidebarFooterState {
enum Style { 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 @MainActor
final class SourceLibrary: ObservableObject { final class SourceLibrary: ObservableObject {
private static let enrichmentWorkerCount = 4 private static let enrichmentWorkerCount = 4
private static let sizeWorkerCount = 2 private static let sizeWorkerCount = 2
private static let minimumVisibleScanDuration: TimeInterval = 0.8 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 var sources: [MinecraftSource] = []
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = [] @Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
@ -65,16 +80,22 @@ final class SourceLibrary: ObservableObject {
private let persistenceStore: SourcePersistenceStore private let persistenceStore: SourcePersistenceStore
private let sourceAccessMethod: SourceAccessMethod private let sourceAccessMethod: SourceAccessMethod
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
private let notificationService: ScanNotificationServicing
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
private var lastMatchedConnectedSourceIDs: Set<URL> = [] private var lastMatchedConnectedSourceIDs: Set<URL> = []
private var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
private var isShuttingDown = false
init( init(
persistenceStore: SourcePersistenceStore = .shared, persistenceStore: SourcePersistenceStore = .shared,
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(), sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
notificationService: ScanNotificationServicing? = nil
) { ) {
self.persistenceStore = persistenceStore self.persistenceStore = persistenceStore
self.sourceAccessMethod = sourceAccessMethod self.sourceAccessMethod = sourceAccessMethod
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
self.notificationService = notificationService ?? ScanNotificationService.shared
Task { [weak self] in Task { [weak self] in
await self?.restorePersistedSources() await self?.restorePersistedSources()
@ -87,20 +108,44 @@ final class SourceLibrary: ObservableObject {
} }
} }
var visibleSources: [MinecraftSource] { deinit {
let matchedConnectedSourceIDs = Set(connectedDevices.compactMap(\.matchedSourceID)) connectedDeviceRefreshTask?.cancel()
return sources.filter { source in footerResetTask?.cancel()
switch source.origin { scanTasks.values.forEach { $0.cancel() }
case .localFolder:
return true
case .connectedDevice:
return matchedConnectedSourceIDs.contains(source.id)
}
}
} }
var localSources: [MinecraftSource] { var visibleSources: [MinecraftSource] {
visibleSources.filter { $0.origin.kind == .localFolder } 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 { func addSource(at url: URL) -> URL {
@ -237,6 +282,10 @@ final class SourceLibrary: ObservableObject {
} }
private func startScan(for sourceID: URL) { private func startScan(for sourceID: URL) {
guard !isShuttingDown else {
return
}
scanTasks[sourceID]?.cancel() scanTasks[sourceID]?.cancel()
let task = Task { [weak self] in let task = Task { [weak self] in
@ -252,10 +301,12 @@ final class SourceLibrary: ObservableObject {
private func scanSource(withID sourceID: URL) async { private func scanSource(withID sourceID: URL) async {
var workerTasks: [Task<Void, Never>] = [] var workerTasks: [Task<Void, Never>] = []
var previewWorkerTasks: [Task<Void, Never>] = []
var sizeWorkerTasks: [Task<Void, Never>] = [] var sizeWorkerTasks: [Task<Void, Never>] = []
let scanStartTime = Date() let scanStartTime = Date()
defer { defer {
workerTasks.forEach { $0.cancel() } workerTasks.forEach { $0.cancel() }
previewWorkerTasks.forEach { $0.cancel() }
sizeWorkerTasks.forEach { $0.cancel() } sizeWorkerTasks.forEach { $0.cancel() }
scanTasks[sourceID] = nil scanTasks[sourceID] = nil
} }
@ -263,18 +314,15 @@ final class SourceLibrary: ObservableObject {
guard let source = source(withID: sourceID) else { guard let source = source(withID: sourceID) else {
return return
} }
let previousSource = source
let performanceContext = performanceContext(for: source)
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.isScanning = true source.isScanning = true
source.scanError = nil source.scanError = nil
source.scanDiagnostic = nil
source.scanStatus = initialScanStatus(for: source) source.scanStatus = initialScanStatus(for: source)
source.displayItems = [] source.scanProgress = nil
source.rawItems = []
source.logicalPacks = []
source.logicalWorlds = []
source.packInstances = []
source.worldPackRelationships = []
source.snapshot = nil
source.indexedItemCount = 0 source.indexedItemCount = 0
source.indexedDetailCount = 0 source.indexedDetailCount = 0
} }
@ -305,8 +353,12 @@ final class SourceLibrary: ObservableObject {
do { do {
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL) let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL)
let enrichmentQueue = EnrichmentWorkQueue() let enrichmentQueue = EnrichmentWorkQueue()
let previewQueue = EnrichmentWorkQueue()
let sizeQueue = EnrichmentWorkQueue() let sizeQueue = EnrichmentWorkQueue()
workerTasks = (0..<Self.enrichmentWorkerCount).map { _ in let enrichmentWorkerCount = source.origin.kind == .connectedDevice ? 1 : Self.enrichmentWorkerCount
let previewWorkerCount = source.origin.kind == .connectedDevice ? 1 : 1
let sizeWorkerCount = source.origin.kind == .connectedDevice ? 1 : Self.sizeWorkerCount
workerTasks = (0..<enrichmentWorkerCount).map { _ in
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .utility) { [weak self] in
guard let library = self else { guard let library = self else {
return return
@ -324,11 +376,33 @@ final class SourceLibrary: ObservableObject {
library.refreshSidebarFooterState() library.refreshSidebarFooterState()
} }
} }
await sizeQueue.enqueue(enrichedItem) await previewQueue.enqueue(enrichedItem)
} }
} }
} }
sizeWorkerTasks = (0..<Self.sizeWorkerCount).map { _ in previewWorkerTasks = (0..<previewWorkerCount).map { _ in
Task.detached(priority: .utility) { [weak self] in
guard let library = self else {
return
}
while let item = await previewQueue.next() {
guard !Task.isCancelled else {
return
}
let previewItem = await library.sourceAccessMethod.loadPreviewAssets(for: item, in: source)
if let snapshot = await index.applyPreviewItem(previewItem) {
await MainActor.run {
library.applySnapshot(snapshot, to: sourceID)
library.refreshSidebarFooterState()
}
}
await sizeQueue.enqueue(previewItem)
}
}
}
sizeWorkerTasks = (0..<sizeWorkerCount).map { _ in
Task.detached(priority: .utility) { [weak self] in Task.detached(priority: .utility) { [weak self] in
guard let library = self else { guard let library = self else {
return return
@ -368,6 +442,7 @@ final class SourceLibrary: ObservableObject {
} }
var discoveredCount = 0 var discoveredCount = 0
let discoveryStartTime = Date()
for try await item in discoveryStream { for try await item in discoveryStream {
guard !Task.isCancelled else { guard !Task.isCancelled else {
@ -386,23 +461,70 @@ final class SourceLibrary: ObservableObject {
await enrichmentQueue.enqueue(item) await enrichmentQueue.enqueue(item)
} }
logScanStage(
"Discovery",
elapsed: Date().timeIntervalSince(discoveryStartTime),
context: performanceContext,
itemCount: discoveredCount
)
if let snapshot = await index.markDiscoveryFinished() {
applySnapshot(snapshot, to: sourceID)
}
refreshSidebarFooterState()
await enrichmentQueue.finish() await enrichmentQueue.finish()
let enrichmentStartTime = Date()
for workerTask in workerTasks { for workerTask in workerTasks {
await workerTask.value await workerTask.value
} }
logScanStage(
"Enrichment",
elapsed: Date().timeIntervalSince(enrichmentStartTime),
context: performanceContext,
itemCount: discoveredCount
)
if let snapshot = await index.markMetadataFinished() { if let snapshot = await index.markMetadataFinished() {
applySnapshot(snapshot, to: sourceID) applySnapshot(snapshot, to: sourceID)
} }
refreshSidebarFooterState() refreshSidebarFooterState()
await previewQueue.finish()
let previewStageStartTime = Date()
for previewWorkerTask in previewWorkerTasks {
await previewWorkerTask.value
}
logScanStage(
"Previews",
elapsed: Date().timeIntervalSince(previewStageStartTime),
context: performanceContext,
itemCount: discoveredCount
)
if let snapshot = await index.markPreviewsFinished() {
applySnapshot(snapshot, to: sourceID)
}
refreshSidebarFooterState()
await sizeQueue.finish() await sizeQueue.finish()
let sizeStageStartTime = Date()
for sizeWorkerTask in sizeWorkerTasks { for sizeWorkerTask in sizeWorkerTasks {
await sizeWorkerTask.value await sizeWorkerTask.value
} }
logScanStage(
"Size",
elapsed: Date().timeIntervalSince(sizeStageStartTime),
context: performanceContext,
itemCount: discoveredCount
)
let elapsedScanTime = Date().timeIntervalSince(scanStartTime) let elapsedScanTime = Date().timeIntervalSince(scanStartTime)
if elapsedScanTime < Self.minimumVisibleScanDuration { if elapsedScanTime < Self.minimumVisibleScanDuration {
try? await Task.sleep( try? await Task.sleep(
@ -422,15 +544,33 @@ final class SourceLibrary: ObservableObject {
} }
persistSourceIfAvailable(withID: sourceID) persistSourceIfAvailable(withID: sourceID)
refreshSidebarFooterState() refreshSidebarFooterState()
} catch { logScanStage(
guard !Task.isCancelled else { "Total",
return elapsed: Date().timeIntervalSince(scanStartTime),
} context: performanceContext,
itemCount: discoveredCount
)
if let completedSource = self.source(withID: sourceID) {
await notificationService.notifyScanCompleted(
for: completedSource,
duration: Date().timeIntervalSince(scanStartTime)
)
}
} catch {
updateSource(sourceID) { source in updateSource(sourceID) { source in
source.availability = availabilityStatus(for: error, defaultingTo: source.availability) restoreScannedContent(from: previousSource, into: &source)
source.scanError = "Failed to scan folder: \(error.localizedDescription)" source.availability = Task.isCancelled
source.scanStatus = "" ? previousSource.availability
: availabilityStatus(for: error, defaultingTo: previousSource.availability)
source.scanError = Task.isCancelled
? previousSource.scanError
: friendlyScanError(for: error, source: source)
source.scanDiagnostic = Task.isCancelled
? previousSource.scanDiagnostic
: error.localizedDescription
source.scanStatus = previousSource.scanStatus
source.scanProgress = previousSource.scanProgress
source.isScanning = false source.isScanning = false
} }
persistSourceIfAvailable(withID: sourceID) persistSourceIfAvailable(withID: sourceID)
@ -630,6 +770,82 @@ final class SourceLibrary: ObservableObject {
} }
} }
private func restoreScannedContent(from previousSource: MinecraftSource, into source: inout MinecraftSource) {
source.displayItems = previousSource.displayItems
source.rawItems = previousSource.rawItems
source.logicalPacks = previousSource.logicalPacks
source.logicalWorlds = previousSource.logicalWorlds
source.packInstances = previousSource.packInstances
source.worldPackRelationships = previousSource.worldPackRelationships
source.snapshot = previousSource.snapshot
source.indexedItemCount = previousSource.indexedItemCount
source.indexedDetailCount = previousSource.indexedDetailCount
source.scanProgress = previousSource.scanProgress
source.lastScanDate = previousSource.lastScanDate
}
private func performanceContext(for source: MinecraftSource) -> 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) { private func handleDiscoveredItem(_ item: MinecraftContentItem, in source: inout MinecraftSource, sourceID: URL) {
guard isLogicalPackType(item.contentType) else { guard isLogicalPackType(item.contentType) else {
return return
@ -684,6 +900,7 @@ final class SourceLibrary: ObservableObject {
source.indexedItemCount = snapshot.indexedItemCount source.indexedItemCount = snapshot.indexedItemCount
source.indexedDetailCount = snapshot.indexedDetailCount source.indexedDetailCount = snapshot.indexedDetailCount
source.scanStatus = snapshot.scanStatus source.scanStatus = snapshot.scanStatus
source.scanProgress = snapshot.scanProgress
source.isScanning = snapshot.isScanning source.isScanning = snapshot.isScanning
source.lastScanDate = snapshot.lastScanDate source.lastScanDate = snapshot.lastScanDate
} }
@ -819,11 +1036,16 @@ final class SourceLibrary: ObservableObject {
} }
private func runConnectedDeviceRefreshLoop() async { private func runConnectedDeviceRefreshLoop() async {
while !Task.isCancelled { while !Task.isCancelled && !isShuttingDown {
await refreshConnectedDevices() await refreshConnectedDevices()
do { 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 { } catch {
return return
} }
@ -831,6 +1053,10 @@ final class SourceLibrary: ObservableObject {
} }
private func refreshConnectedDevices() async { private func refreshConnectedDevices() async {
guard !isShuttingDown else {
return
}
guard let connectedDeviceAccessMethod else { guard let connectedDeviceAccessMethod else {
return return
} }
@ -847,36 +1073,69 @@ final class SourceLibrary: ObservableObject {
var entries: [ConnectedDeviceSidebarEntry] = [] var entries: [ConnectedDeviceSidebarEntry] = []
var matchedSourceIDs = Set<URL>() var matchedSourceIDs = Set<URL>()
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 { for device in devices {
if let matchedSourceID = knownConnectedDeviceSourceID(for: device) { if let matchedSourceID = knownConnectedDeviceSourceID(for: device) {
matchedSourceIDs.insert(matchedSourceID) matchedSourceIDs.insert(matchedSourceID)
let cachedContainers = cachedDeviceDiscoveryByUDID[device.udid]?.containers ?? []
refreshMatchedConnectedDeviceSource( refreshMatchedConnectedDeviceSource(
sourceID: matchedSourceID, sourceID: matchedSourceID,
device: device, device: device,
containers: [] containers: cachedContainers
) )
entries.append(
ConnectedDeviceSidebarEntry(
device: device,
containers: [],
matchedSourceID: matchedSourceID,
discoveryErrorDescription: nil
)
)
continue continue
} }
let containers: [DeviceAppContainer] let containers: [DeviceAppContainer]
let discoveryErrorDescription: String? 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 { do {
containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device) containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
discoveryErrorDescription = nil discoveryErrorDescription = nil
cacheDeviceDiscovery(
device: device,
containers: containers,
discoveryErrorDescription: nil
)
logDeviceRefreshStage(
"Container discovery",
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
device: device,
containerCount: containers.count
)
} catch { } catch {
containers = [] containers = []
discoveryErrorDescription = error.localizedDescription 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( let matchedSourceID = matchingConnectedDeviceSourceID(
@ -894,9 +1153,8 @@ final class SourceLibrary: ObservableObject {
} }
let shouldDisplayEntry = let shouldDisplayEntry =
matchedSourceID != nil matchedSourceID == nil
|| !containers.isEmpty && (!containers.isEmpty || device.trustState != .trusted)
|| device.trustState != .trusted
if shouldDisplayEntry { if shouldDisplayEntry {
entries.append( entries.append(
@ -931,48 +1189,84 @@ final class SourceLibrary: ObservableObject {
lastMatchedConnectedSourceIDs = matchedSourceIDs 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( private func matchingConnectedDeviceSourceID(
device: ConnectedDevice, device: ConnectedDevice,
containers: [DeviceAppContainer] containers: [DeviceAppContainer]
) -> URL? { ) -> URL? {
for source in sources { for container in containers {
guard case .connectedDevice(let expectedDevice, let expectedContainer) = source.origin else { let sourceID = connectedDeviceSourceFactory.makeSourceIdentifier(
continue 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 return nil
} }
private func knownConnectedDeviceSourceID(for device: ConnectedDevice) -> URL? { 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 { guard case .connectedDevice(let expectedDevice, _) = source.origin else {
continue
}
guard expectedDevice.udid == device.udid else {
continue
}
return source.id
}
return nil return nil
} }
return expectedDevice.udid == device.udid ? source.id : nil
}
guard matchingSourceIDs.count == 1 else {
return nil
}
return matchingSourceIDs.first
}
private func refreshMatchedConnectedDeviceSource( private func refreshMatchedConnectedDeviceSource(
sourceID: URL, sourceID: URL,
device: ConnectedDevice, device: ConnectedDevice,
@ -1281,7 +1575,15 @@ final class SourceLibrary: ObservableObject {
let detail: String? let detail: String?
if source.indexedItemCount > 0 { if source.indexedItemCount > 0 {
subtitle = source.displayName subtitle = source.displayName
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" detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
}
} else { } else {
subtitle = "Searching \(source.displayName)" subtitle = "Searching \(source.displayName)"
detail = nil detail = nil
@ -1534,6 +1836,7 @@ private struct SourceIndexSnapshot {
let indexedItemCount: Int let indexedItemCount: Int
let indexedDetailCount: Int let indexedDetailCount: Int
let scanStatus: String let scanStatus: String
let scanProgress: Double?
let isScanning: Bool let isScanning: Bool
let lastScanDate: Date? let lastScanDate: Date?
} }
@ -1552,8 +1855,10 @@ private actor SourceIndexActor {
private var packRepresentativeItemIDByIdentityID: [String: URL] = [:] private var packRepresentativeItemIDByIdentityID: [String: URL] = [:]
private var indexedItemCount = 0 private var indexedItemCount = 0
private var indexedDetailCount = 0 private var indexedDetailCount = 0
private var previewLoadedCount = 0
private var discoveryFinished = false private var discoveryFinished = false
private var metadataFinished = false private var metadataFinished = false
private var previewsFinished = false
private var sizesFinished = false private var sizesFinished = false
private var lastPublishedAt: Date? private var lastPublishedAt: Date?
@ -1594,6 +1899,16 @@ private actor SourceIndexActor {
return snapshotIfNeeded() 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? { func markDiscoveryFinished() -> SourceIndexSnapshot? {
discoveryFinished = true discoveryFinished = true
return buildSnapshot(force: true) return buildSnapshot(force: true)
@ -1605,9 +1920,17 @@ private actor SourceIndexActor {
return buildSnapshot(force: true) return buildSnapshot(force: true)
} }
func markPreviewsFinished() -> SourceIndexSnapshot? {
discoveryFinished = true
metadataFinished = true
previewsFinished = true
return buildSnapshot(force: true)
}
func finishScan() -> SourceIndexSnapshot? { func finishScan() -> SourceIndexSnapshot? {
discoveryFinished = true discoveryFinished = true
metadataFinished = true metadataFinished = true
previewsFinished = true
sizesFinished = true sizesFinished = true
return buildSnapshot(force: true) return buildSnapshot(force: true)
} }
@ -1632,6 +1955,10 @@ private actor SourceIndexActor {
logicalPacks: logicalPacks, logicalPacks: logicalPacks,
rawItemsByID: rawItemsByID 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 let scanStatus: String
if !discoveryFinished { if !discoveryFinished {
@ -1649,6 +1976,7 @@ private actor SourceIndexActor {
indexedItemCount: indexedItemCount, indexedItemCount: indexedItemCount,
indexedDetailCount: indexedDetailCount, indexedDetailCount: indexedDetailCount,
scanStatus: scanStatus, scanStatus: scanStatus,
scanProgress: nil,
isScanning: true, isScanning: true,
lastScanDate: nil lastScanDate: nil
) )
@ -1657,7 +1985,7 @@ private actor SourceIndexActor {
if !metadataFinished { if !metadataFinished {
scanStatus = indexedItemCount == 0 scanStatus = indexedItemCount == 0
? "No Minecraft items found." ? "No Minecraft items found."
: "Deduplicating packs..." : "Loading metadata for \(indexedDetailCount) of \(indexedItemCount) items..."
return SourceIndexSnapshot( return SourceIndexSnapshot(
displayItems: dedupedDisplayItems, displayItems: dedupedDisplayItems,
@ -1669,6 +1997,28 @@ private actor SourceIndexActor {
indexedItemCount: indexedItemCount, indexedItemCount: indexedItemCount,
indexedDetailCount: indexedDetailCount, indexedDetailCount: indexedDetailCount,
scanStatus: scanStatus, 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, isScanning: true,
lastScanDate: nil lastScanDate: nil
) )
@ -1752,7 +2102,7 @@ private actor SourceIndexActor {
if !sizesFinished { if !sizesFinished {
scanStatus = indexedItemCount == 0 scanStatus = indexedItemCount == 0
? "No Minecraft items found." ? "No Minecraft items found."
: "Resolving pack relationships..." : "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
} else { } else {
scanStatus = indexedItemCount == 0 scanStatus = indexedItemCount == 0
? "No Minecraft items found." ? "No Minecraft items found."
@ -1771,11 +2121,32 @@ private actor SourceIndexActor {
indexedItemCount: indexedItemCount, indexedItemCount: indexedItemCount,
indexedDetailCount: indexedDetailCount, indexedDetailCount: indexedDetailCount,
scanStatus: scanStatus, scanStatus: scanStatus,
scanProgress: sizesFinished ? nil : progressAfterPreviews(sizeFraction),
isScanning: !sizesFinished, isScanning: !sizesFinished,
lastScanDate: sizesFinished ? now : nil 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( private func buildDisplayItems(
from rawItems: [MinecraftContentItem], from rawItems: [MinecraftContentItem],
logicalPacks: [LogicalPack], logicalPacks: [LogicalPack],

View File

@ -90,13 +90,24 @@ enum WorldScanner {
let fileManager = FileManager.default let fileManager = FileManager.default
var enrichedItem = item var enrichedItem = item
enrichedItem.displayName = displayName(for: item, fileManager: fileManager) enrichedItem.displayName = MinecraftContentMetadataReader.displayName(
let sourceIconURL = iconURL(for: item, fileManager: fileManager) 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.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.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL) 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.packUUID = manifestMetadata.uuid
enrichedItem.packVersion = manifestMetadata.version enrichedItem.packVersion = manifestMetadata.version
enrichedItem.packMetadataDetails = PackMetadataDetails( enrichedItem.packMetadataDetails = PackMetadataDetails(
@ -108,6 +119,7 @@ enum WorldScanner {
} }
enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager) enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager)
enrichedItem.metadataLoaded = true enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = true
enrichedItem.sizeLoaded = false enrichedItem.sizeLoaded = false
return enrichedItem return enrichedItem
@ -204,65 +216,6 @@ enum WorldScanner {
return embeddedItems 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( nonisolated private static func lastPlayedDate(
for item: MinecraftContentItem, for item: MinecraftContentItem,
fileManager: FileManager, fileManager: FileManager,
@ -374,7 +327,7 @@ enum WorldScanner {
for entry in jsonObject { for entry in jsonObject {
let uuid = (entry["pack_id"] as? String)?.lowercased() let uuid = (entry["pack_id"] as? String)?.lowercased()
let version = versionString(from: entry["version"]) let version = MinecraftContentMetadataReader.versionString(from: entry["version"])
let resolvedPack: ContentPackReference? let resolvedPack: ContentPackReference?
if let uuid { if let uuid {
resolvedPack = await resolvedPackReference( resolvedPack = await resolvedPackReference(
@ -429,33 +382,20 @@ enum WorldScanner {
source: PackSource, source: PackSource,
fileManager: FileManager fileManager: FileManager
) -> ContentPackReference? { ) -> ContentPackReference? {
guard let metadata = manifestMetadata(in: directoryURL, fileManager: fileManager) else { guard let metadata = MinecraftContentMetadataReader.manifestMetadata(in: directoryURL, fileManager: fileManager) else {
return nil return nil
} }
return ContentPackReference( return ContentPackReference(
name: metadata.name, name: metadata.name,
type: type, type: type,
iconURL: packIconURL(in: directoryURL, fileManager: fileManager), iconURL: MinecraftContentMetadataReader.packIconURL(in: directoryURL, fileManager: fileManager),
uuid: metadata.uuid, uuid: metadata.uuid,
version: metadata.version, version: metadata.version,
source: source 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( nonisolated private static func resolvedPackReference(
uuid: String, uuid: String,
type: MinecraftContentType, 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] { nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
var seen = Set<String>() var seen = Set<String>()
var uniqueReferences: [ContentPackReference] = [] 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 actor PackReferenceIndexStore {
private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:] private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:]

View File

@ -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<z_stream>.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
}
}
}

View File

@ -21,7 +21,7 @@ struct SidebarFilter: Identifiable, Hashable {
} }
struct SourcesSidebarView: View { struct SourcesSidebarView: View {
let localSources: [MinecraftSource] let sources: [MinecraftSource]
let connectedDevices: [ConnectedDeviceSidebarEntry] let connectedDevices: [ConnectedDeviceSidebarEntry]
@Binding var selection: SidebarSelection? @Binding var selection: SidebarSelection?
let footerState: SidebarFooterState let footerState: SidebarFooterState
@ -32,13 +32,12 @@ struct SourcesSidebarView: View {
let removeSourceAction: (MinecraftSource) -> Void let removeSourceAction: (MinecraftSource) -> Void
let revealFooterURLAction: (URL) -> Void let revealFooterURLAction: (URL) -> Void
let filters: (MinecraftSource) -> [SidebarFilter] let filters: (MinecraftSource) -> [SidebarFilter]
let matchedSource: (ConnectedDeviceSidebarEntry) -> MinecraftSource?
var body: some View { var body: some View {
List(selection: $selection) { List(selection: $selection) {
if !localSources.isEmpty { if !sources.isEmpty {
Section { Section {
ForEach(localSources) { source in ForEach(sources) { source in
sourceSectionRows(for: source) sourceSectionRows(for: source)
} }
} header: { } header: {
@ -57,17 +56,6 @@ struct SourcesSidebarView: View {
} }
} }
.listStyle(.sidebar) .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 { .toolbar {
ToolbarItem { ToolbarItem {
Button(action: addSourceAction) { Button(action: addSourceAction) {
@ -83,12 +71,11 @@ struct SourcesSidebarView: View {
.help("Add Connected Device Source") .help("Add Connected Device Source")
} }
} }
.animation(.easeInOut(duration: 0.2), value: footerState.style)
} }
@ViewBuilder @ViewBuilder
private func sourceSectionRows(for source: MinecraftSource) -> some View { private func sourceSectionRows(for source: MinecraftSource) -> some View {
SourceHeaderRow(title: source.displayName) SourceHeaderRow(source: source)
.listRowSeparator(.hidden) .listRowSeparator(.hidden)
.padding(.top, 6) .padding(.top, 6)
.contextMenu { .contextMenu {
@ -111,9 +98,6 @@ struct SourcesSidebarView: View {
@ViewBuilder @ViewBuilder
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View { private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
if let source = matchedSource(entry) {
sourceSectionRows(for: source)
} else {
ConnectedDeviceRow( ConnectedDeviceRow(
entry: entry, entry: entry,
addAction: entry.hasMinecraftContainer ? { addAction: entry.hasMinecraftContainer ? {
@ -124,7 +108,6 @@ struct SourcesSidebarView: View {
.padding(.top, 6) .padding(.top, 6)
} }
} }
}
private struct SidebarFilterRow: View { private struct SidebarFilterRow: View {
let filter: SidebarFilter let filter: SidebarFilter
@ -159,12 +142,223 @@ private struct SidebarSourcesSectionHeaderView: View {
} }
private struct SourceHeaderRow: View { private struct SourceHeaderRow: View {
let title: String let source: MinecraftSource
@State private var isPresentingStatusPopover = false
var body: some View { var body: some View {
Text(title) HStack(spacing: 8) {
Text(source.displayName)
.font(.subheadline.weight(.semibold)) .font(.subheadline.weight(.semibold))
.foregroundStyle(.secondary) .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 { var body: some View {
HStack(alignment: .top, spacing: 10) { HStack(alignment: .top, spacing: 10) {
Image(systemName: iconName) ConnectedDeviceTransportIcon(
.frame(width: 16) baseSymbolName: iconName,
.foregroundStyle(iconColor) connection: entry.device.connection,
tint: iconColor
)
VStack(alignment: .leading, spacing: 4) { VStack(alignment: .leading, spacing: 4) {
Text(entry.device.name) 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 { private struct SidebarFooterView: View {
let state: SidebarFooterState let state: SidebarFooterState
let revealAction: (URL) -> Void let revealAction: (URL) -> Void

View File

@ -12,6 +12,7 @@ struct AppleMobileDeviceSummary: Sendable {
let deviceIdentifier: String let deviceIdentifier: String
let productType: String let productType: String
let productVersion: String let productVersion: String
let connectionType: String
let trustState: DeviceTrustState let trustState: DeviceTrustState
} }
@ -28,9 +29,24 @@ struct AppleMobileMinecraftLibraryItemSummary: Sendable {
let relativePath: String let relativePath: String
let folderName: String let folderName: String
let displayName: 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 packUUID: String?
let packVersion: String? let packVersion: String?
let minimumEngineVersion: String? let minimumEngineVersion: String?
let packReferences: [AppleMobilePackReferenceSummary]
} }
struct AppleMobileDevicePathMetrics: Sendable { struct AppleMobileDevicePathMetrics: Sendable {
@ -38,11 +54,50 @@ struct AppleMobileDevicePathMetrics: Sendable {
let modifiedDate: Date? let modifiedDate: Date?
} }
actor AppleMobileDeviceOperationLimiter {
static let shared = AppleMobileDeviceOperationLimiter()
private var activeDevices = Set<String>()
private var waitingContinuations: [String: [CheckedContinuation<Void, Never>]] = [:]
func run<T: Sendable>(
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 { enum AppleMobileDeviceAccess {
static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary { static func connectedDevices() async throws -> [AppleMobileDeviceSummary] {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
var error: NSError? var error: NSError?
guard let response = WMMCopyFirstConnectedDeviceSummary(&error) else { guard let response = WMMCopyConnectedDeviceSummaries(&error) else {
throw error ?? NSError( throw error ?? NSError(
domain: "AppleMobileDeviceAccess", domain: "AppleMobileDeviceAccess",
code: 1, code: 1,
@ -50,12 +105,22 @@ enum AppleMobileDeviceAccess {
) )
} }
guard let rawDevices = response["devices"] as? [[String: Any]] else {
throw NSError(
domain: "AppleMobileDeviceAccess",
code: 2,
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice summary returned an unexpected payload."]
)
}
return try rawDevices.map { device in
guard guard
let deviceName = response["deviceName"] as? String, let deviceName = device["deviceName"] as? String,
let deviceIdentifier = response["deviceIdentifier"] as? String, let deviceIdentifier = device["deviceIdentifier"] as? String,
let productType = response["productType"] as? String, let productType = device["productType"] as? String,
let productVersion = response["productVersion"] as? String, let productVersion = device["productVersion"] as? String,
let trustStateRawValue = response["trustState"] as? String, let connectionType = device["connectionType"] as? String,
let trustStateRawValue = device["trustState"] as? String,
let trustState = DeviceTrustState(rawValue: trustStateRawValue) let trustState = DeviceTrustState(rawValue: trustStateRawValue)
else { else {
throw NSError( throw NSError(
@ -70,19 +135,24 @@ enum AppleMobileDeviceAccess {
deviceIdentifier: deviceIdentifier, deviceIdentifier: deviceIdentifier,
productType: productType, productType: productType,
productVersion: productVersion, productVersion: productVersion,
connectionType: connectionType,
trustState: trustState trustState: trustState
) )
}
}.value }.value
} }
static func mirrorSubtree( static func mirrorSubtree(
deviceIdentifier: String,
bundleIdentifier: String, bundleIdentifier: String,
relativePath: String, relativePath: String,
destinationDirectoryURL: URL destinationDirectoryURL: URL
) async throws { ) async throws {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
var error: NSError? var error: NSError?
let didCopy = WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( let didCopy = WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
deviceIdentifier,
bundleIdentifier, bundleIdentifier,
relativePath, relativePath,
destinationDirectoryURL, destinationDirectoryURL,
@ -98,11 +168,13 @@ enum AppleMobileDeviceAccess {
} }
}.value }.value
} }
}
static func listApplications() async throws -> [AppleMobileDeviceApplicationSummary] { static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
var error: NSError? var error: NSError?
guard let response = WMMCopyFirstConnectedDeviceApplicationList(&error) else { guard let response = WMMCopyConnectedDeviceApplicationList(deviceIdentifier, &error) else {
throw error ?? NSError( throw error ?? NSError(
domain: "AppleMobileDeviceAccess", domain: "AppleMobileDeviceAccess",
code: 3, code: 3,
@ -135,14 +207,18 @@ enum AppleMobileDeviceAccess {
} }
}.value }.value
} }
}
static func listDirectory( static func listDirectory(
deviceIdentifier: String,
bundleIdentifier: String, bundleIdentifier: String,
relativePath: String relativePath: String
) async throws -> [String] { ) async throws -> [String] {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
var error: NSError? var error: NSError?
guard let response = WMMCopyFirstConnectedDeviceAppDirectoryListing( guard let response = WMMCopyConnectedDeviceAppDirectoryListing(
deviceIdentifier,
bundleIdentifier, bundleIdentifier,
relativePath, relativePath,
&error &error
@ -157,14 +233,18 @@ enum AppleMobileDeviceAccess {
return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." } return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
}.value }.value
} }
}
static func fileData( static func fileData(
deviceIdentifier: String,
bundleIdentifier: String, bundleIdentifier: String,
relativePath: String relativePath: String
) async throws -> Data { ) async throws -> Data {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
var error: NSError? var error: NSError?
guard let data = WMMCopyFirstConnectedDeviceAppFileData( guard let data = WMMCopyConnectedDeviceAppFileData(
deviceIdentifier,
bundleIdentifier, bundleIdentifier,
relativePath, relativePath,
&error &error
@ -179,14 +259,18 @@ enum AppleMobileDeviceAccess {
return data as Data return data as Data
}.value }.value
} }
}
static func minecraftLibrarySnapshot( static func minecraftLibrarySnapshot(
deviceIdentifier: String,
bundleIdentifier: String, bundleIdentifier: String,
relativePath: String relativePath: String
) async throws -> [AppleMobileMinecraftLibraryItemSummary] { ) async throws -> [AppleMobileMinecraftLibraryItemSummary] {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .userInitiated) { try await Task.detached(priority: .userInitiated) {
var error: NSError? var error: NSError?
guard let response = WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot( guard let response = WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
deviceIdentifier,
bundleIdentifier, bundleIdentifier,
relativePath, relativePath,
&error &error
@ -223,21 +307,96 @@ enum AppleMobileDeviceAccess {
relativePath: relativePath, relativePath: relativePath,
folderName: folderName, folderName: folderName,
displayName: displayName, displayName: displayName,
packUUID: (item["packUUID"] as? String)?.lowercased(), hasIcon: flexibleBool(from: item["hasIcon"])
packVersion: item["packVersion"] as? String,
minimumEngineVersion: item["minimumEngineVersion"] as? String
) )
} }
}.value }.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( static func pathMetrics(
deviceIdentifier: String,
bundleIdentifier: String, bundleIdentifier: String,
relativePath: String relativePath: String
) async throws -> AppleMobileDevicePathMetrics { ) async throws -> AppleMobileDevicePathMetrics {
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
try await Task.detached(priority: .utility) { try await Task.detached(priority: .utility) {
var error: NSError? var error: NSError?
guard let response = WMMCopyFirstConnectedDeviceAppPathMetrics( guard let response = WMMCopyConnectedDeviceAppPathMetrics(
deviceIdentifier,
bundleIdentifier, bundleIdentifier,
relativePath, relativePath,
&error &error
@ -268,8 +427,9 @@ enum AppleMobileDeviceAccess {
) )
}.value }.value
} }
}
private static func flexibleBool(from value: Any?) -> Bool { nonisolated private static func flexibleBool(from value: Any?) -> Bool {
switch value { switch value {
case let value as Bool: case let value as Bool:
return value return value

View File

@ -12,54 +12,73 @@ NS_ASSUME_NONNULL_BEGIN
FOUNDATION_EXPORT NSErrorDomain const WMMMobileDeviceErrorDomain; FOUNDATION_EXPORT NSErrorDomain const WMMMobileDeviceErrorDomain;
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceSummary(NSError **error); WMMCopyConnectedDeviceSummaries(NSError **error);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceApplicationList(NSError **error); WMMCopyConnectedDeviceApplicationList(
NSString *deviceIdentifier,
NSError **error
);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceApplicationDetails( WMMCopyConnectedDeviceApplicationDetails(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSError **error NSError **error
); );
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppDirectoryListing( WMMCopyConnectedDeviceAppDirectoryListing(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
); );
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppPathProbeResults( WMMCopyConnectedDeviceAppPathProbeResults(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSArray<NSString *> *paths, NSArray<NSString *> *paths,
NSError **error NSError **error
); );
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot( WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
NSString *bundleIdentifier, NSString *deviceIdentifier,
NSString *relativePath,
NSError **error
);
FOUNDATION_EXPORT NSData * _Nullable
WMMCopyFirstConnectedDeviceAppFileData(
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
); );
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppPathMetrics( WMMCopyConnectedDeviceMinecraftMetadataBatch(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSArray<NSDictionary<NSString *, id> *> *items,
NSError **error
);
FOUNDATION_EXPORT NSData * _Nullable
WMMCopyConnectedDeviceAppFileData(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSError **error
);
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceAppPathMetrics(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
); );
FOUNDATION_EXPORT BOOL FOUNDATION_EXPORT BOOL
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSURL *destinationDirectoryURL, NSURL *destinationDirectoryURL,

View File

@ -152,8 +152,11 @@ typedef struct {
typedef struct { typedef struct {
WMMMobileDeviceFunctions *functions; WMMMobileDeviceFunctions *functions;
CFRunLoopRef runLoop; CFRunLoopRef runLoop;
AMDeviceRef device; NSMutableArray<NSValue *> *devices;
} WMMDeviceWaitContext; } WMMDeviceCollectionContext;
static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDeviceRef device, CFStringRef key);
static NSInteger WMMConnectionPreferenceRank(AMDeviceRef device);
static NSError *WMMMakeError(NSInteger code, NSString *description) { static NSError *WMMMakeError(NSInteger code, NSString *description) {
return [NSError errorWithDomain:WMMMobileDeviceErrorDomain code:code userInfo:@{ return [NSError errorWithDomain:WMMMobileDeviceErrorDomain code:code userInfo:@{
@ -273,20 +276,16 @@ static void WMMDeviceNotificationCallback(struct am_device_notification_callback
return; return;
} }
WMMDeviceWaitContext *context = contextPointer; WMMDeviceCollectionContext *context = contextPointer;
if (context->device == NULL) { AMDeviceRef retainedDevice = context->functions->AMDeviceRetain(info->dev);
context->device = context->functions->AMDeviceRetain(info->dev); [context->devices addObject:[NSValue valueWithPointer:retainedDevice]];
if (context->runLoop != NULL) {
CFRunLoopStop(context->runLoop);
}
}
} }
static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functions, NSError **error) { static NSArray<NSValue *> *WMMCopyConnectedDevices(WMMMobileDeviceFunctions *functions, NSError **error) {
WMMDeviceWaitContext context = { WMMDeviceCollectionContext context = {
.functions = functions, .functions = functions,
.runLoop = CFRunLoopGetCurrent(), .runLoop = CFRunLoopGetCurrent(),
.device = NULL .devices = [NSMutableArray array]
}; };
AMDeviceNotificationRef subscription = NULL; AMDeviceNotificationRef subscription = NULL;
@ -307,11 +306,78 @@ static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functio
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false); CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false);
functions->AMDeviceNotificationUnsubscribe(subscription); 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."); *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<NSValue *> *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<NSValue *> *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) { static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDeviceRef device, CFStringRef key) {
@ -332,6 +398,66 @@ static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDev
return CFBridgingRelease(value); 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<NSString *> *keys = @[
@"ConnectionType",
@"InterfaceType",
@"DeviceName",
@"ProductType",
@"ProductVersion",
@"UniqueDeviceID",
@"SerialNumber",
@"WiFiAddress",
@"EthernetAddress"
];
NSMutableDictionary<NSString *, NSString *> *values = [NSMutableDictionary dictionary];
for (NSString *key in keys) {
NSString *value = WMMDeviceStringValue(functions, device, (__bridge CFStringRef)key);
values[key] = value.length > 0 ? value : @"<nil>";
}
NSLog(@"[DeviceSummary] udid=%@ diagnostics=%@", resolvedIdentifier, values);
}
static BOOL WMMConnectAndValidateDevice( static BOOL WMMConnectAndValidateDevice(
WMMMobileDeviceFunctions *functions, WMMMobileDeviceFunctions *functions,
AMDeviceRef device, AMDeviceRef device,
@ -391,6 +517,26 @@ static void WMMDisconnectDevice(
functions->AMDeviceDisconnect(device); 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( static AFCConnectionRef _Nullable WMMCreateAFCConnectionFromServiceConnection(
WMMMobileDeviceFunctions *functions, WMMMobileDeviceFunctions *functions,
AMDServiceConnectionRef serviceConnection AMDServiceConnectionRef serviceConnection
@ -1001,6 +1147,115 @@ static NSString * _Nullable WMMVersionStringFromValue(id value) {
return nil; return nil;
} }
static NSDictionary<NSString *, id> *WMMBuildPackReferenceSummary(
NSString *name,
NSString *contentType,
NSString * _Nullable uuid,
NSString * _Nullable version,
NSString *source
) {
NSMutableDictionary<NSString *, id> *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<NSDictionary<NSString *, id> *> *WMMParsePackReferenceSummariesFromData(
NSData *data,
NSString *contentType
) {
id jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:nil];
if (![jsonObject isKindOfClass:[NSArray class]]) {
return @[];
}
NSMutableArray<NSDictionary<NSString *, id> *> *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<NSDictionary<NSString *, id> *> *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<NSDictionary<NSString *, id> *> *WMMLoadEmbeddedPackReferenceSummaries(
WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection,
NSString *remoteFolderPath,
NSString *contentType
) {
NSMutableArray<NSString *> *childFolders = nil;
if (WMMReadAFCDirectory(functions, afcConnection, remoteFolderPath, &childFolders) != 0 || childFolders == nil) {
return @[];
}
NSMutableArray<NSDictionary<NSString *, id> *> *references = [NSMutableArray array];
for (NSString *childFolder in childFolders) {
if ([childFolder isEqualToString:@"."] || [childFolder isEqualToString:@".."]) {
continue;
}
NSString *manifestPath = [[remoteFolderPath stringByAppendingPathComponent:childFolder] stringByAppendingPathComponent:@"manifest.json"];
NSDictionary<NSString *, id> *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<NSString *> *entries) { static BOOL WMMIsCandidateItem(NSString *contentType, NSArray<NSString *> *entries) {
if ([contentType isEqualToString:@"World"]) { if ([contentType isEqualToString:@"World"]) {
return WMMEntryArrayContainsName(entries, @"level.dat") return WMMEntryArrayContainsName(entries, @"level.dat")
@ -1014,12 +1269,9 @@ static BOOL WMMIsCandidateItem(NSString *contentType, NSArray<NSString *> *entri
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg"); || WMMEntryArrayContainsName(entries, @"pack_icon.jpg");
} }
static NSDictionary<NSString *, id> *WMMBuildMinecraftItemSummary( static NSDictionary<NSString *, id> *WMMBuildShallowMinecraftItemSummary(
WMMMobileDeviceFunctions *functions,
AFCConnectionRef afcConnection,
NSString *contentType, NSString *contentType,
NSString *collectionFolderName, NSString *collectionFolderName,
NSString *itemRemotePath,
NSString *itemRelativePath, NSString *itemRelativePath,
NSString *folderName, NSString *folderName,
NSArray<NSString *> *entries NSArray<NSString *> *entries
@ -1028,44 +1280,9 @@ static NSDictionary<NSString *, id> *WMMBuildMinecraftItemSummary(
@"contentType": contentType, @"contentType": contentType,
@"collectionFolderName": collectionFolderName, @"collectionFolderName": collectionFolderName,
@"relativePath": itemRelativePath, @"relativePath": itemRelativePath,
@"folderName": folderName @"folderName": folderName,
@"displayName": folderName
} mutableCopy]; } 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<NSString *, id> *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"] = @( summary[@"hasIcon"] = @(
WMMEntryArrayContainsName(entries, @"world_icon.png") WMMEntryArrayContainsName(entries, @"world_icon.png")
|| WMMEntryArrayContainsName(entries, @"world_icon.jpeg") || WMMEntryArrayContainsName(entries, @"world_icon.jpeg")
@ -1107,12 +1324,9 @@ static void WMMAppendCollectionSummaries(
} }
NSString *itemRelativePath = [collectionFolderName stringByAppendingPathComponent:itemFolderName]; NSString *itemRelativePath = [collectionFolderName stringByAppendingPathComponent:itemFolderName];
[results addObject:WMMBuildMinecraftItemSummary( [results addObject:WMMBuildShallowMinecraftItemSummary(
functions,
afcConnection,
contentType, contentType,
collectionFolderName, collectionFolderName,
itemRemotePath,
itemRelativePath, itemRelativePath,
itemFolderName, itemFolderName,
itemEntries itemEntries
@ -1152,12 +1366,9 @@ static void WMMAppendCollectionSummaries(
} }
NSString *embeddedRelativePath = [itemRelativePath stringByAppendingPathComponent:[embeddedFolder stringByAppendingPathComponent:embeddedFolderName]]; NSString *embeddedRelativePath = [itemRelativePath stringByAppendingPathComponent:[embeddedFolder stringByAppendingPathComponent:embeddedFolderName]];
[results addObject:WMMBuildMinecraftItemSummary( [results addObject:WMMBuildShallowMinecraftItemSummary(
functions,
afcConnection,
embeddedType, embeddedType,
embeddedFolder, embeddedFolder,
embeddedItemPath,
embeddedRelativePath, embeddedRelativePath,
embeddedFolderName, embeddedFolderName,
embeddedEntries embeddedEntries
@ -1168,47 +1379,78 @@ static void WMMAppendCollectionSummaries(
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceSummary(NSError **error) { WMMCopyConnectedDeviceSummaries(NSError **error) {
WMMMobileDeviceFunctions functions; WMMMobileDeviceFunctions functions;
if (!WMMLoadFunctions(&functions, error)) { if (!WMMLoadFunctions(&functions, error)) {
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); NSArray<NSValue *> *devices = WMMCopyConnectedDevices(&functions, error);
if (devices.count == 0) {
return nil;
}
NSMutableArray<NSDictionary<NSString *, id> *> *summaries = [NSMutableArray array];
NSMutableDictionary<NSString *, NSValue *> *preferredDevicesByIdentifier = [NSMutableDictionary dictionary];
for (NSValue *value in devices) {
AMDeviceRef device = (AMDeviceRef)value.pointerValue;
if (device == NULL) { if (device == NULL) {
return nil; continue;
} }
if (!WMMConnectAndValidateDevice(&functions, device, NO, error)) { NSString *deviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
functions.AMDeviceRelease(device); if (deviceIdentifier.length == 0) {
return nil; continue;
} }
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; NSValue *existingValue = preferredDevicesByIdentifier[deviceIdentifier];
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; AMDeviceRef existingDevice = existingValue != nil ? (AMDeviceRef)existingValue.pointerValue : NULL;
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; if (existingDevice != NULL && WMMConnectionPreferenceRank(device) <= WMMConnectionPreferenceRank(existingDevice)) {
NSString *deviceIdentifier = continue;
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?: }
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?: preferredDevicesByIdentifier[deviceIdentifier] = value;
@""; }
NSString *trustState = @"trusted";
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); WMMDisconnectDevice(&functions, device, NO);
}
functions.AMDeviceRelease(device); [summaries addObject:@{
return @{
@"deviceName": deviceName, @"deviceName": deviceName,
@"deviceIdentifier": deviceIdentifier, @"deviceIdentifier": deviceIdentifier,
@"productType": productType, @"productType": productType,
@"productVersion": productVersion, @"productVersion": productVersion,
@"trustState": trustState @"connectionType": connectionType,
}; @"trustState": @"trusted"
}];
}
WMMReleaseDeviceValues(&functions, devices);
[summaries sortUsingComparator:^NSComparisonResult(NSDictionary<NSString *, id> *lhs, NSDictionary<NSString *, id> *rhs) {
return [lhs[@"deviceName"] localizedStandardCompare:rhs[@"deviceName"]];
}];
return @{ @"devices": summaries };
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppDirectoryListing( WMMCopyConnectedDeviceAppDirectoryListing(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
@ -1225,7 +1467,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1238,10 +1480,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
NSString *deviceIdentifier = NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
@"";
AMDServiceConnectionRef backingServiceConnection = NULL; AMDServiceConnectionRef backingServiceConnection = NULL;
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection( AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
@ -1252,7 +1491,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
error error
); );
if (afcConnection == NULL) { if (afcConnection == NULL) {
WMMDisconnectDevice(&functions, device, YES); WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return nil; return nil;
} }
@ -1268,11 +1507,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
NSMutableArray<NSString *> *rootEntries = nil; NSMutableArray<NSString *> *rootEntries = nil;
const int rootStatus = WMMReadAFCDirectory(&functions, afcConnection, @"/", &rootEntries); const int rootStatus = WMMReadAFCDirectory(&functions, afcConnection, @"/", &rootEntries);
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
if (error != NULL) { if (error != NULL) {
NSString *message = [NSString stringWithFormat:@"AFC directory read failed for %@ (%d).", normalizedPath, directoryStatus]; NSString *message = [NSString stringWithFormat:@"AFC directory read failed for %@ (%d).", normalizedPath, directoryStatus];
@ -1286,11 +1521,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
} }
return nil; return nil;
} }
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
@ -1298,7 +1529,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
@"bundleIdentifier": bundleIdentifier, @"bundleIdentifier": bundleIdentifier,
@"path": normalizedPath, @"path": normalizedPath,
@"deviceName": deviceName, @"deviceName": deviceName,
@"deviceIdentifier": deviceIdentifier, @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType, @"productType": productType,
@"productVersion": productVersion, @"productVersion": productVersion,
@"entries": entries @"entries": entries
@ -1306,7 +1537,8 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppPathProbeResults( WMMCopyConnectedDeviceAppPathProbeResults(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSArray<NSString *> *paths, NSArray<NSString *> *paths,
NSError **error NSError **error
@ -1323,7 +1555,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1336,10 +1568,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
NSString *deviceIdentifier = NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
@"";
AMDServiceConnectionRef backingServiceConnection = NULL; AMDServiceConnectionRef backingServiceConnection = NULL;
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection( AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
@ -1350,7 +1579,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
error error
); );
if (afcConnection == NULL) { if (afcConnection == NULL) {
WMMDisconnectDevice(&functions, device, YES); WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return nil; return nil;
} }
@ -1376,17 +1605,13 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
[results addObject:result]; [results addObject:result];
} }
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return @{ return @{
@"bundleIdentifier": bundleIdentifier, @"bundleIdentifier": bundleIdentifier,
@"deviceName": deviceName, @"deviceName": deviceName,
@"deviceIdentifier": deviceIdentifier, @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType, @"productType": productType,
@"productVersion": productVersion, @"productVersion": productVersion,
@"results": results @"results": results
@ -1394,13 +1619,16 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceApplicationList(NSError **error) { WMMCopyConnectedDeviceApplicationList(
NSString *deviceIdentifier,
NSError **error
) {
WMMMobileDeviceFunctions functions; WMMMobileDeviceFunctions functions;
if (!WMMLoadFunctions(&functions, error)) { if (!WMMLoadFunctions(&functions, error)) {
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1413,10 +1641,7 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
NSString *deviceIdentifier = NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
@"";
CFDictionaryRef appDictionary = NULL; CFDictionaryRef appDictionary = NULL;
const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary); const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary);
@ -1484,7 +1709,7 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
return @{ return @{
@"deviceName": deviceName, @"deviceName": deviceName,
@"deviceIdentifier": deviceIdentifier, @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType, @"productType": productType,
@"productVersion": productVersion, @"productVersion": productVersion,
@"applications": applications @"applications": applications
@ -1492,7 +1717,8 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot( WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
@ -1509,7 +1735,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1528,7 +1754,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
error error
); );
if (afcConnection == NULL) { if (afcConnection == NULL) {
WMMDisconnectDevice(&functions, device, YES); WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return nil; return nil;
} }
@ -1554,11 +1780,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
); );
} }
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return @{ return @{
@ -1568,8 +1790,140 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
}; };
} }
NSDictionary<NSString *, id> * _Nullable
WMMCopyConnectedDeviceMinecraftMetadataBatch(
NSString *deviceIdentifier,
NSString *bundleIdentifier,
NSString *relativePath,
NSArray<NSDictionary<NSString *, id> *> *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<NSDictionary<NSString *, id> *> *results = [NSMutableArray array];
for (NSDictionary<NSString *, id> *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<NSString *, id> *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<NSDictionary<NSString *, id> *> *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<NSString *, id> *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 NSData * _Nullable
WMMCopyFirstConnectedDeviceAppFileData( WMMCopyConnectedDeviceAppFileData(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
@ -1586,7 +1940,7 @@ WMMCopyFirstConnectedDeviceAppFileData(
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1605,7 +1959,7 @@ WMMCopyFirstConnectedDeviceAppFileData(
error error
); );
if (afcConnection == NULL) { if (afcConnection == NULL) {
WMMDisconnectDevice(&functions, device, YES); WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return nil; return nil;
} }
@ -1613,18 +1967,15 @@ WMMCopyFirstConnectedDeviceAppFileData(
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath); NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
NSData *data = WMMCopyAFCFileData(&functions, afcConnection, normalizedPath, error); NSData *data = WMMCopyAFCFileData(&functions, afcConnection, normalizedPath, error);
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return data; return data;
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceAppPathMetrics( WMMCopyConnectedDeviceAppPathMetrics(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSError **error NSError **error
@ -1641,7 +1992,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1660,7 +2011,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
error error
); );
if (afcConnection == NULL) { if (afcConnection == NULL) {
WMMDisconnectDevice(&functions, device, YES); WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return nil; return nil;
} }
@ -1673,11 +2024,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
error error
); );
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
if (metrics == nil) { if (metrics == nil) {
@ -1693,7 +2040,8 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
} }
NSDictionary<NSString *, id> * _Nullable NSDictionary<NSString *, id> * _Nullable
WMMCopyFirstConnectedDeviceApplicationDetails( WMMCopyConnectedDeviceApplicationDetails(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSError **error NSError **error
) { ) {
@ -1709,7 +2057,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
return nil; return nil;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return nil; return nil;
} }
@ -1722,10 +2070,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device"; NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @""; NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @""; NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
NSString *deviceIdentifier = NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
@"";
CFDictionaryRef appDictionary = NULL; CFDictionaryRef appDictionary = NULL;
const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary); const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary);
@ -1774,7 +2119,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
return @{ return @{
@"deviceName": deviceName, @"deviceName": deviceName,
@"deviceIdentifier": deviceIdentifier, @"deviceIdentifier": resolvedDeviceIdentifier,
@"productType": productType, @"productType": productType,
@"productVersion": productVersion, @"productVersion": productVersion,
@"bundleIdentifier": bundleIdentifier, @"bundleIdentifier": bundleIdentifier,
@ -1783,7 +2128,8 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
} }
BOOL BOOL
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory( WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
NSString *deviceIdentifier,
NSString *bundleIdentifier, NSString *bundleIdentifier,
NSString *relativePath, NSString *relativePath,
NSURL *destinationDirectoryURL, NSURL *destinationDirectoryURL,
@ -1808,7 +2154,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
return NO; return NO;
} }
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error); AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
if (device == NULL) { if (device == NULL) {
return NO; return NO;
} }
@ -1827,7 +2173,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
error error
); );
if (afcConnection == NULL) { if (afcConnection == NULL) {
WMMDisconnectDevice(&functions, device, YES); WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
return NO; return NO;
} }
@ -1848,11 +2194,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
error error
); );
functions.AFCConnectionClose(afcConnection); WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
if (backingServiceConnection != NULL) {
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
}
WMMDisconnectDevice(&functions, device, YES);
functions.AMDeviceRelease(device); functions.AMDeviceRelease(device);
if (!success) { if (!success) {

View File

@ -47,21 +47,29 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
} }
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] { nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
let device = try await AppleMobileDeviceAccess.firstConnectedDevice() let devices = try await AppleMobileDeviceAccess.connectedDevices()
return [ return devices.compactMap { device in
ConnectedDevice( let connection: DeviceConnection
switch device.connectionType.lowercased() {
case "network", "wifi", "wi-fi":
connection = .network
default:
connection = .usb
}
return ConnectedDevice(
udid: device.deviceIdentifier, udid: device.deviceIdentifier,
name: device.deviceName, name: device.deviceName,
productType: device.productType.isEmpty ? nil : device.productType, productType: device.productType.isEmpty ? nil : device.productType,
osVersion: device.productVersion.isEmpty ? nil : device.productVersion, osVersion: device.productVersion.isEmpty ? nil : device.productVersion,
connection: .usb, connection: connection,
trustState: device.trustState trustState: device.trustState
) )
] }
} }
nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] { 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 return applications
.filter { application in .filter { application in
@ -109,12 +117,23 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
} }
let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot( let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: requestedSubpath relativePath: requestedSubpath
) )
let metadataByPath = try await metadataByRelativePath(
for: summaries,
container: container,
requestedSubpath: requestedSubpath
)
let items = summaries.compactMap { summary in let items = summaries.compactMap { summary in
makeItem(from: summary, source: source) makeItem(
from: summary,
metadata: metadataByPath[summary.relativePath],
source: source
)
} }
for item in items { for item in items {
@ -126,35 +145,39 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem { nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
var enrichedItem = item var enrichedItem = item
guard case .connectedDevice(_, let container) = source.origin else { guard case .connectedDevice = source.origin else {
enrichedItem.metadataLoaded = true enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = true
return enrichedItem return enrichedItem
} }
enrichedItem.iconURL = await loadRemoteIcon(for: item, source: source, container: container)
enrichedItem.modifiedDate = nil 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.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
}
enrichedItem.packReferences = await loadWorldPackReferences(for: item, source: source, container: container)
} else {
enrichedItem.lastPlayedDate = nil
enrichedItem.packReferences = []
}
enrichedItem.metadataLoaded = true enrichedItem.metadataLoaded = true
enrichedItem.previewLoaded = !enrichedItem.hasKnownIcon
enrichedItem.sizeLoaded = false enrichedItem.sizeLoaded = false
return enrichedItem 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 { nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
var sizedItem = item var sizedItem = item
guard case .connectedDevice(_, let container) = source.origin else { guard case .connectedDevice(_, let container) = source.origin else {
@ -164,6 +187,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
if let remoteItemPath = remoteItemPath(for: item, in: source), if let remoteItemPath = remoteItemPath(for: item, in: source),
let metrics = try? await AppleMobileDeviceAccess.pathMetrics( let metrics = try? await AppleMobileDeviceAccess.pathMetrics(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: remoteItemPath relativePath: remoteItemPath
) { ) {
@ -187,6 +211,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
} }
let entries = try await AppleMobileDeviceAccess.listDirectory( let entries = try await AppleMobileDeviceAccess.listDirectory(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: remoteFolderPath relativePath: remoteFolderPath
) )
@ -221,6 +246,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
do { do {
try await AppleMobileDeviceAccess.mirrorSubtree( try await AppleMobileDeviceAccess.mirrorSubtree(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: remoteItemPath, relativePath: remoteItemPath,
destinationDirectoryURL: destinationURL destinationDirectoryURL: destinationURL
@ -242,6 +268,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
nonisolated private func makeItem( nonisolated private func makeItem(
from summary: AppleMobileMinecraftLibraryItemSummary, from summary: AppleMobileMinecraftLibraryItemSummary,
metadata: AppleMobileMinecraftItemMetadataSummary?,
source: MinecraftSource source: MinecraftSource
) -> MinecraftContentItem? { ) -> MinecraftContentItem? {
let contentType: MinecraftContentType let contentType: MinecraftContentType
@ -262,21 +289,92 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true) let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true)
let folderURL = source.folderURL.appendingPathComponent(summary.relativePath, 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( return MinecraftContentItem(
folderURL: folderURL, folderURL: folderURL,
folderName: summary.folderName, folderName: summary.folderName,
contentType: contentType, contentType: contentType,
collectionRootURL: collectionRootURL, collectionRootURL: collectionRootURL,
displayName: summary.displayName, displayName: displayName,
iconURL: nil, iconURL: nil,
packUUID: summary.packUUID, hasKnownIcon: summary.hasIcon,
packVersion: summary.packVersion, packUUID: metadata?.packUUID,
packMetadataDetails: PackMetadataDetails(minimumEngineVersion: summary.minimumEngineVersion), packVersion: metadata?.packVersion,
packMetadataDetails: packMetadataDetails,
packReferences: packReferences(from: metadata?.packReferences ?? []),
metadataLoaded: false, metadataLoaded: false,
previewLoaded: !summary.hasIcon,
sizeLoaded: false 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( nonisolated private func remoteItemPath(
for item: MinecraftContentItem, for item: MinecraftContentItem,
in source: MinecraftSource, in source: MinecraftSource,
@ -326,6 +424,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
continue continue
} }
guard let data = try? await AppleMobileDeviceAccess.fileData( guard let data = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: remotePath relativePath: remotePath
) else { ) else {
@ -358,6 +457,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"), if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"),
let behaviorData = try? await AppleMobileDeviceAccess.fileData( let behaviorData = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: behaviorRefPath relativePath: behaviorRefPath
) { ) {
@ -366,6 +466,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"), if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"),
let resourceData = try? await AppleMobileDeviceAccess.fileData( let resourceData = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: resourceRefPath relativePath: resourceRefPath
) { ) {
@ -402,6 +503,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
} }
guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory( guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: remoteFolderPath relativePath: remoteFolderPath
) else { ) else {
@ -413,6 +515,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder) let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder)
let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json") let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json")
guard let manifestData = try? await AppleMobileDeviceAccess.fileData( guard let manifestData = try? await AppleMobileDeviceAccess.fileData(
deviceIdentifier: container.deviceUDID,
bundleIdentifier: container.appID, bundleIdentifier: container.appID,
relativePath: manifestPath relativePath: manifestPath
) else { ) else {

View File

@ -191,18 +191,28 @@ struct ConnectedDeviceSourcePickerView: View {
isLoadingDevices = true isLoadingDevices = true
availabilityMessage = nil availabilityMessage = nil
errorMessage = nil errorMessage = nil
let previousSelectedDeviceID = selectedDeviceID
do { do {
let devices = try await deviceDiscoveryService.listConnectedDevices() 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 { await MainActor.run {
self.devices = devices self.devices = devices
self.selectedDeviceID = devices.first?.id self.selectedDeviceID = resolvedSelectedDeviceID
if devices.isEmpty { if devices.isEmpty {
self.containers = [] self.containers = []
self.selectedContainerID = nil self.selectedContainerID = nil
} }
self.isLoadingDevices = false self.isLoadingDevices = false
} }
if shouldReloadContainers {
await loadContainersForSelectedDevice()
}
} catch { } catch {
await MainActor.run { await MainActor.run {
self.devices = [] self.devices = []

View File

@ -16,6 +16,7 @@ protocol SourceAccessMethod: Sendable {
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
) async throws -> [MinecraftContentItem] ) async throws -> [MinecraftContentItem]
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> 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 loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry] nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry]
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
@ -55,6 +56,11 @@ extension SourceAccessMethod {
return item 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 { nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
_ = source _ = source
return item return item
@ -134,6 +140,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
return await accessMethod(for: source).enrich(item, for: source) 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 { nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
return await accessMethod(for: source).loadSize(for: item, in: source) return await accessMethod(for: source).loadSize(for: item, in: source)
} }

View File

@ -5,10 +5,19 @@
// Created by John Burwell on 2026-05-25. // Created by John Burwell on 2026-05-25.
// //
import AppKit
import SwiftUI import SwiftUI
@main @main
struct World_Manager_for_MinecraftApp: App { struct World_Manager_for_MinecraftApp: App {
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
init() {
Task {
await ScanNotificationService.shared.requestAuthorizationIfNeeded()
}
}
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
ContentView() 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 { private struct WindowChromeConfigurator: NSViewRepresentable {
func makeNSView(context: Context) -> NSView { func makeNSView(context: Context) -> NSView {
let view = NSView() let view = NSView()

View File

@ -254,6 +254,124 @@ struct World_Manager_for_MinecraftTests {
#expect(enrichedPack.packMetadataDetails?.minimumEngineVersion == "1.19.50") #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 { @Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
let fileManager = FileManager.default let fileManager = FileManager.default
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
@ -375,6 +493,20 @@ struct World_Manager_for_MinecraftTests {
#expect(values["ConnectionType"] == "USB") #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 { private enum TestNBTTagType: UInt8 {
@ -470,6 +602,43 @@ private func appendString(_ string: String, to data: inout Data) {
data.append(utf8) 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<T: FixedWidthInteger>(_ value: T, to data: inout Data) { private func appendLE<T: FixedWidthInteger>(_ value: T, to data: inout Data) {
var value = value.littleEndian var value = value.littleEndian
withUnsafeBytes(of: &value) { bytes in withUnsafeBytes(of: &value) { bytes in

View File

@ -0,0 +1,144 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
<key>CFBundleTypeName</key>
<string>Minecraft World</string>
<key>CFBundleTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>us.b-wells.minecraft.mcworld</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Minecraft Pack</string>
<key>CFBundleTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>us.b-wells.minecraft.mcpack</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Minecraft Template</string>
<key>CFBundleTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Alternate</string>
<key>LSItemContentTypes</key>
<array>
<string>us.b-wells.minecraft.template</string>
</array>
</dict>
<dict>
<key>CFBundleTypeName</key>
<string>Minecraft Add-on</string>
<key>CFBundleTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>CFBundleTypeRole</key>
<string>Viewer</string>
<key>LSHandlerRank</key>
<string>Default</string>
<key>LSItemContentTypes</key>
<array>
<string>us.b-wells.minecraft.mcaddon</string>
</array>
</dict>
</array>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>Minecraft World</string>
<key>UTTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>UTTypeIdentifier</key>
<string>us.b-wells.minecraft.mcworld</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mcworld</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>Minecraft Pack</string>
<key>UTTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>UTTypeIdentifier</key>
<string>us.b-wells.minecraft.mcpack</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mcpack</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>Minecraft Template</string>
<key>UTTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>UTTypeIdentifier</key>
<string>us.b-wells.minecraft.mctemplate</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mctemplate</string>
</array>
</dict>
</dict>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.zip-archive</string>
</array>
<key>UTTypeDescription</key>
<string>Minecraft Add-on</string>
<key>UTTypeIconFile</key>
<string>MinecraftVoxelDocument.png</string>
<key>UTTypeIdentifier</key>
<string>us.b-wells.minecraft.mcaddon</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>mcaddon</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

96
docs/quick-look-plan.md Normal file
View File

@ -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.