quick look, thumbnails, better pipelining, better network/usb support
@ -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>
|
||||
27
MinecraftPackagePreviewExtension/Info.plist
Normal 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>
|
||||
@ -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)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
54
MinecraftPackagePreviewExtension/PreviewProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
114
MinecraftPackagePreviewExtension/PreviewViewController.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
25
MinecraftPackageThumbnailExtension/Info.plist
Normal 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>
|
||||
@ -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)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
53
MinecraftPackageThumbnailExtension/ThumbnailProvider.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
145
Scripts/generate_document_icon.swift
Normal 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)
|
||||
}
|
||||
@ -6,6 +6,14 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
52C72BCA2FC7314E009928CB /* QuickLookThumbnailing.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */; };
|
||||
52C72BCB2FC7314E009928CB /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52C72BB12FC72940009928CB /* Quartz.framework */; };
|
||||
52C72BD22FC7314E009928CB /* MinecraftPackageThumbnailExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
52C72BDC2FC73171009928CB /* Quartz.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52C72BB12FC72940009928CB /* Quartz.framework */; };
|
||||
52C72BE82FC73171009928CB /* MinecraftPackagePreviewExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
5218F9162FC4C9F100CAF7B7 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
@ -21,17 +29,96 @@
|
||||
remoteGlobalIDString = 5218F9052FC4C9EF00CAF7B7;
|
||||
remoteInfo = "World Manager for Minecraft";
|
||||
};
|
||||
52C72BD02FC7314E009928CB /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 52C72BC72FC7314D009928CB;
|
||||
remoteInfo = MinecraftPackageThumbnailExtension;
|
||||
};
|
||||
52C72BE62FC73171009928CB /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 52C72BDA2FC73171009928CB;
|
||||
remoteInfo = MinecraftPackagePreviewExtension;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXCopyFilesBuildPhase section */
|
||||
52C72BC32FC72940009928CB /* Embed Foundation Extensions */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
dstPath = "";
|
||||
dstSubfolderSpec = 13;
|
||||
files = (
|
||||
52C72BE82FC73171009928CB /* MinecraftPackagePreviewExtension.appex in Embed Foundation Extensions */,
|
||||
52C72BD22FC7314E009928CB /* MinecraftPackageThumbnailExtension.appex in Embed Foundation Extensions */,
|
||||
);
|
||||
name = "Embed Foundation Extensions";
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
5218F9062FC4C9EF00CAF7B7 /* World Manager for Minecraft.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "World Manager for Minecraft.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5218F9152FC4C9F100CAF7B7 /* World Manager for MinecraftTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "World Manager for MinecraftTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "World Manager for MinecraftUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
52C72BB12FC72940009928CB /* Quartz.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Quartz.framework; path = System/Library/Frameworks/Quartz.framework; sourceTree = SDKROOT; };
|
||||
52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MinecraftPackageThumbnailExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = QuickLookThumbnailing.framework; path = System/Library/Frameworks/QuickLookThumbnailing.framework; sourceTree = SDKROOT; };
|
||||
52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = MinecraftPackagePreviewExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
52C72BD32FC7314E009928CB /* Exceptions for "MinecraftPackageThumbnailExtension" folder in "MinecraftPackageThumbnailExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */;
|
||||
};
|
||||
52C72BE92FC73171009928CB /* Exceptions for "MinecraftPackagePreviewExtension" folder in "MinecraftPackagePreviewExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Info.plist,
|
||||
);
|
||||
target = 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */;
|
||||
};
|
||||
52C72BEF2FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackageThumbnailExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Models/MinecraftContentItem.swift,
|
||||
QuickLook/MinecraftPackageTypes.swift,
|
||||
Services/BedrockLevelMetadataDecoder.swift,
|
||||
Services/MinecraftContentMetadataReader.swift,
|
||||
Services/MinecraftPackageInspector.swift,
|
||||
Services/ZipArchiveReader.swift,
|
||||
);
|
||||
target = 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */;
|
||||
};
|
||||
52C72BF02FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackagePreviewExtension" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
Models/MinecraftContentItem.swift,
|
||||
QuickLook/MinecraftPackageQuickLookModel.swift,
|
||||
QuickLook/MinecraftPackageTypes.swift,
|
||||
Services/BedrockLevelMetadataDecoder.swift,
|
||||
Services/MinecraftContentMetadataReader.swift,
|
||||
Services/MinecraftPackageInspector.swift,
|
||||
Services/ZipArchiveReader.swift,
|
||||
);
|
||||
target = 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
52C72BEF2FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackageThumbnailExtension" target */,
|
||||
52C72BF02FC736C3009928CB /* Exceptions for "World Manager for Minecraft" folder in "MinecraftPackagePreviewExtension" target */,
|
||||
);
|
||||
path = "World Manager for Minecraft";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -45,6 +132,22 @@
|
||||
path = "World Manager for MinecraftUITests";
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@ -69,6 +172,23 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
52C72BC52FC7314D009928CB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
52C72BCA2FC7314E009928CB /* QuickLookThumbnailing.framework in Frameworks */,
|
||||
52C72BCB2FC7314E009928CB /* Quartz.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
52C72BD82FC73171009928CB /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
52C72BDC2FC73171009928CB /* Quartz.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
@ -78,6 +198,9 @@
|
||||
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
|
||||
5218F9182FC4C9F100CAF7B7 /* World Manager for MinecraftTests */,
|
||||
5218F9222FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */,
|
||||
52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */,
|
||||
52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */,
|
||||
52C72BB02FC72940009928CB /* Frameworks */,
|
||||
5218F9072FC4C9EF00CAF7B7 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@ -88,10 +211,21 @@
|
||||
5218F9062FC4C9EF00CAF7B7 /* World Manager for Minecraft.app */,
|
||||
5218F9152FC4C9F100CAF7B7 /* World Manager for MinecraftTests.xctest */,
|
||||
5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */,
|
||||
52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */,
|
||||
52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
52C72BB02FC72940009928CB /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
52C72BB12FC72940009928CB /* Quartz.framework */,
|
||||
52C72BC92FC7314D009928CB /* QuickLookThumbnailing.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@ -102,10 +236,13 @@
|
||||
5218F9022FC4C9EF00CAF7B7 /* Sources */,
|
||||
5218F9032FC4C9EF00CAF7B7 /* Frameworks */,
|
||||
5218F9042FC4C9EF00CAF7B7 /* Resources */,
|
||||
52C72BC32FC72940009928CB /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
52C72BD12FC7314E009928CB /* PBXTargetDependency */,
|
||||
52C72BE72FC73171009928CB /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5218F9082FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
|
||||
@ -163,6 +300,50 @@
|
||||
productReference = 5218F91F2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.ui-testing";
|
||||
};
|
||||
52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 52C72BD42FC7314E009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackageThumbnailExtension" */;
|
||||
buildPhases = (
|
||||
52C72BC42FC7314D009928CB /* Sources */,
|
||||
52C72BC52FC7314D009928CB /* Frameworks */,
|
||||
52C72BC62FC7314D009928CB /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
52C72BCC2FC7314E009928CB /* MinecraftPackageThumbnailExtension */,
|
||||
);
|
||||
name = MinecraftPackageThumbnailExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MinecraftPackageThumbnailExtension;
|
||||
productReference = 52C72BC82FC7314D009928CB /* MinecraftPackageThumbnailExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 52C72BEA2FC73171009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackagePreviewExtension" */;
|
||||
buildPhases = (
|
||||
52C72BD72FC73171009928CB /* Sources */,
|
||||
52C72BD82FC73171009928CB /* Frameworks */,
|
||||
52C72BD92FC73171009928CB /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
52C72BDD2FC73171009928CB /* MinecraftPackagePreviewExtension */,
|
||||
);
|
||||
name = MinecraftPackagePreviewExtension;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = MinecraftPackagePreviewExtension;
|
||||
productReference = 52C72BDB2FC73171009928CB /* MinecraftPackagePreviewExtension.appex */;
|
||||
productType = "com.apple.product-type.app-extension";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@ -184,6 +365,12 @@
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
TestTargetID = 5218F9052FC4C9EF00CAF7B7;
|
||||
};
|
||||
52C72BC72FC7314D009928CB = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
52C72BDA2FC73171009928CB = {
|
||||
CreatedOnToolsVersion = 26.2;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = 5218F9012FC4C9EF00CAF7B7 /* Build configuration list for PBXProject "World Manager for Minecraft" */;
|
||||
@ -203,6 +390,8 @@
|
||||
5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */,
|
||||
5218F9142FC4C9F100CAF7B7 /* World Manager for MinecraftTests */,
|
||||
5218F91E2FC4C9F100CAF7B7 /* World Manager for MinecraftUITests */,
|
||||
52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */,
|
||||
52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
@ -229,6 +418,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
52C72BC62FC7314D009928CB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
52C72BD92FC73171009928CB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@ -253,6 +456,20 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
52C72BC42FC7314D009928CB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
52C72BD72FC73171009928CB /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
@ -266,6 +483,16 @@
|
||||
target = 5218F9052FC4C9EF00CAF7B7 /* World Manager for Minecraft */;
|
||||
targetProxy = 5218F9202FC4C9F100CAF7B7 /* PBXContainerItemProxy */;
|
||||
};
|
||||
52C72BD12FC7314E009928CB /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 52C72BC72FC7314D009928CB /* MinecraftPackageThumbnailExtension */;
|
||||
targetProxy = 52C72BD02FC7314E009928CB /* PBXContainerItemProxy */;
|
||||
};
|
||||
52C72BE72FC73171009928CB /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 52C72BDA2FC73171009928CB /* MinecraftPackagePreviewExtension */;
|
||||
targetProxy = 52C72BE62FC73171009928CB /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
@ -399,6 +626,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "World-Manager-for-Minecraft-Info.plist";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -431,6 +659,7 @@
|
||||
ENABLE_PREVIEWS = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readwrite;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = "World-Manager-for-Minecraft-Info.plist";
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.games";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -527,6 +756,122 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
52C72BD52FC7314E009928CB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MinecraftPackageThumbnailExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackageThumbnailExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackageThumbnailExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
52C72BD62FC7314E009928CB /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MinecraftPackageThumbnailExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackageThumbnailExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackageThumbnailExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
52C72BEB2FC73171009928CB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MinecraftPackagePreviewExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackagePreviewExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackagePreviewExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
52C72BEC2FC73171009928CB /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "";
|
||||
ENABLE_APP_SANDBOX = YES;
|
||||
ENABLE_USER_SELECTED_FILES = readonly;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = MinecraftPackagePreviewExtension/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = MinecraftPackagePreviewExtension;
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/../Frameworks",
|
||||
"@executable_path/../../../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "us.b-wells.World-Manager-for-Minecraft.MinecraftPackagePreviewExtension";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SKIP_INSTALL = YES;
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = YES;
|
||||
SWIFT_APPROACHABLE_CONCURRENCY = YES;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
@ -566,6 +911,24 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
52C72BD42FC7314E009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackageThumbnailExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
52C72BD52FC7314E009928CB /* Debug */,
|
||||
52C72BD62FC7314E009928CB /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
52C72BEA2FC73171009928CB /* Build configuration list for PBXNativeTarget "MinecraftPackagePreviewExtension" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
52C72BEB2FC73171009928CB /* Debug */,
|
||||
52C72BEC2FC73171009928CB /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 5218F8FE2FC4C9EF00CAF7B7 /* Project object */;
|
||||
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -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>
|
||||
@ -4,11 +4,59 @@
|
||||
<dict>
|
||||
<key>SchemeUserState</key>
|
||||
<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>
|
||||
<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>
|
||||
<key>orderHint</key>
|
||||
<integer>0</integer>
|
||||
</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>
|
||||
</plist>
|
||||
|
||||
@ -42,7 +42,7 @@ struct ContentView: View {
|
||||
var body: some View {
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
SourcesSidebarView(
|
||||
localSources: library.localSources,
|
||||
sources: library.sidebarSources,
|
||||
connectedDevices: library.connectedDevices,
|
||||
selection: $selectedSidebarSelection,
|
||||
footerState: library.sidebarFooterState,
|
||||
@ -58,14 +58,7 @@ struct ContentView: View {
|
||||
removeSource(source.id)
|
||||
},
|
||||
revealFooterURLAction: revealURLInFinder(_:),
|
||||
filters: sidebarFilters(for:),
|
||||
matchedSource: { entry in
|
||||
guard let sourceID = entry.matchedSourceID else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return library.source(withID: sourceID)
|
||||
}
|
||||
filters: sidebarFilters(for:)
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 280, ideal: 320, max: 380)
|
||||
} content: {
|
||||
@ -144,7 +137,13 @@ struct ContentView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
.task {
|
||||
AppTerminationCoordinator.shared.register(library: library)
|
||||
}
|
||||
.disabled(library.isRestoringPersistedSources)
|
||||
.onReceive(NotificationCenter.default.publisher(for: NSApplication.willTerminateNotification)) { _ in
|
||||
library.shutdown()
|
||||
}
|
||||
.onChange(of: displayedItems.map(\.id)) { _, filteredIDs in
|
||||
guard let selectedItemID, !filteredIDs.contains(selectedItemID) else {
|
||||
return
|
||||
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 781 B |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 704 KiB |
BIN
World Manager for Minecraft/MinecraftVoxelDocument.png
Normal file
|
After Width: | Height: | Size: 704 KiB |
@ -125,6 +125,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||
let collectionRootURL: URL
|
||||
var displayName: String
|
||||
var iconURL: URL?
|
||||
var hasKnownIcon: Bool
|
||||
var lastPlayedDate: Date?
|
||||
var modifiedDate: Date?
|
||||
var sizeBytes: Int64?
|
||||
@ -134,6 +135,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||
var packReferences: [ContentPackReference]
|
||||
var worldMetadata: WorldMetadata?
|
||||
var metadataLoaded: Bool
|
||||
var previewLoaded: Bool
|
||||
var sizeLoaded: Bool
|
||||
|
||||
nonisolated init(
|
||||
@ -143,6 +145,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||
collectionRootURL: URL,
|
||||
displayName: String? = nil,
|
||||
iconURL: URL? = nil,
|
||||
hasKnownIcon: Bool = false,
|
||||
lastPlayedDate: Date? = nil,
|
||||
modifiedDate: Date? = nil,
|
||||
sizeBytes: Int64? = nil,
|
||||
@ -152,6 +155,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||
packReferences: [ContentPackReference] = [],
|
||||
worldMetadata: WorldMetadata? = nil,
|
||||
metadataLoaded: Bool = false,
|
||||
previewLoaded: Bool = false,
|
||||
sizeLoaded: Bool = false
|
||||
) {
|
||||
self.id = folderURL.standardizedFileURL
|
||||
@ -161,6 +165,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||
self.collectionRootURL = collectionRootURL
|
||||
self.displayName = displayName ?? folderName
|
||||
self.iconURL = iconURL
|
||||
self.hasKnownIcon = hasKnownIcon
|
||||
self.lastPlayedDate = lastPlayedDate
|
||||
self.modifiedDate = modifiedDate
|
||||
self.sizeBytes = sizeBytes
|
||||
@ -170,6 +175,7 @@ struct MinecraftContentItem: Identifiable, Hashable, Sendable, Codable {
|
||||
self.packReferences = packReferences
|
||||
self.worldMetadata = worldMetadata
|
||||
self.metadataLoaded = metadataLoaded
|
||||
self.previewLoaded = previewLoaded
|
||||
self.sizeLoaded = sizeLoaded
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
var isScanning: Bool
|
||||
var scanStatus: String
|
||||
var scanError: String?
|
||||
var scanDiagnostic: String?
|
||||
var scanProgress: Double?
|
||||
var indexedItemCount: Int
|
||||
var indexedDetailCount: Int
|
||||
var lastScanDate: Date?
|
||||
@ -61,6 +63,8 @@ struct MinecraftSource: Identifiable, Hashable, Sendable {
|
||||
self.isScanning = false
|
||||
self.scanStatus = ""
|
||||
self.scanError = nil
|
||||
self.scanDiagnostic = nil
|
||||
self.scanProgress = nil
|
||||
self.indexedItemCount = 0
|
||||
self.indexedDetailCount = 0
|
||||
self.lastScanDate = nil
|
||||
|
||||
@ -290,7 +290,7 @@ struct SidebarColumnPreviewContainer: View {
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
SourcesSidebarView(
|
||||
localSources: PreviewFixtures.allSources,
|
||||
sources: PreviewFixtures.allSources,
|
||||
connectedDevices: [],
|
||||
selection: $selection,
|
||||
footerState: PreviewFixtures.sidebarFooter,
|
||||
@ -300,8 +300,7 @@ struct SidebarColumnPreviewContainer: View {
|
||||
rescanSourceAction: { _ in },
|
||||
removeSourceAction: { _ in },
|
||||
revealFooterURLAction: { _ in },
|
||||
filters: PreviewFixtures.sidebarFilters(for:),
|
||||
matchedSource: { _ in nil }
|
||||
filters: PreviewFixtures.sidebarFilters(for:)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -176,6 +176,7 @@ enum ContentPackageExporter {
|
||||
}
|
||||
|
||||
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteItemPath,
|
||||
destinationDirectoryURL: destinationURL
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import Combine
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
struct SidebarFooterState {
|
||||
enum Style {
|
||||
@ -41,12 +42,26 @@ struct ConnectedDeviceSidebarEntry: Identifiable, Hashable {
|
||||
}
|
||||
}
|
||||
|
||||
private struct CachedConnectedDeviceDiscovery {
|
||||
let device: ConnectedDevice
|
||||
let containers: [DeviceAppContainer]
|
||||
let discoveryErrorDescription: String?
|
||||
let refreshedAt: Date
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class SourceLibrary: ObservableObject {
|
||||
private static let enrichmentWorkerCount = 4
|
||||
private static let sizeWorkerCount = 2
|
||||
private static let minimumVisibleScanDuration: TimeInterval = 0.8
|
||||
private static let connectedDeviceRefreshInterval: TimeInterval = 0.5
|
||||
private static let connectedDeviceRefreshInterval: TimeInterval = 2.0
|
||||
private static let connectedDeviceRefreshIntervalWhileScanning: TimeInterval = 5.0
|
||||
private static let usbConnectedDeviceDiscoveryCacheTTL: TimeInterval = 60.0
|
||||
private static let networkConnectedDeviceDiscoveryCacheTTL: TimeInterval = 180.0
|
||||
private static let performanceLogger = Logger(
|
||||
subsystem: Bundle.main.bundleIdentifier ?? "WorldManagerForMinecraft",
|
||||
category: "ConnectedDevicePerformance"
|
||||
)
|
||||
|
||||
@Published var sources: [MinecraftSource] = []
|
||||
@Published private(set) var connectedDevices: [ConnectedDeviceSidebarEntry] = []
|
||||
@ -65,16 +80,22 @@ final class SourceLibrary: ObservableObject {
|
||||
private let persistenceStore: SourcePersistenceStore
|
||||
private let sourceAccessMethod: SourceAccessMethod
|
||||
private let connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod?
|
||||
private let notificationService: ScanNotificationServicing
|
||||
private let connectedDeviceSourceFactory = ConnectedDeviceSourceFactory()
|
||||
private var lastMatchedConnectedSourceIDs: Set<URL> = []
|
||||
private var cachedDeviceDiscoveryByUDID: [String: CachedConnectedDeviceDiscovery] = [:]
|
||||
private var isShuttingDown = false
|
||||
|
||||
init(
|
||||
persistenceStore: SourcePersistenceStore = .shared,
|
||||
sourceAccessMethod: SourceAccessMethod = LocalFolderSourceAccess(),
|
||||
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil
|
||||
connectedDeviceAccessMethod: ConnectedDeviceSourceAccessMethod? = nil,
|
||||
notificationService: ScanNotificationServicing? = nil
|
||||
) {
|
||||
self.persistenceStore = persistenceStore
|
||||
self.sourceAccessMethod = sourceAccessMethod
|
||||
self.connectedDeviceAccessMethod = connectedDeviceAccessMethod
|
||||
self.notificationService = notificationService ?? ScanNotificationService.shared
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.restorePersistedSources()
|
||||
@ -87,20 +108,44 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
var visibleSources: [MinecraftSource] {
|
||||
let matchedConnectedSourceIDs = Set(connectedDevices.compactMap(\.matchedSourceID))
|
||||
return sources.filter { source in
|
||||
switch source.origin {
|
||||
case .localFolder:
|
||||
return true
|
||||
case .connectedDevice:
|
||||
return matchedConnectedSourceIDs.contains(source.id)
|
||||
}
|
||||
}
|
||||
deinit {
|
||||
connectedDeviceRefreshTask?.cancel()
|
||||
footerResetTask?.cancel()
|
||||
scanTasks.values.forEach { $0.cancel() }
|
||||
}
|
||||
|
||||
var localSources: [MinecraftSource] {
|
||||
visibleSources.filter { $0.origin.kind == .localFolder }
|
||||
var visibleSources: [MinecraftSource] {
|
||||
sources
|
||||
}
|
||||
|
||||
var sidebarSources: [MinecraftSource] {
|
||||
visibleSources
|
||||
}
|
||||
|
||||
func shutdown() {
|
||||
guard !isShuttingDown else {
|
||||
return
|
||||
}
|
||||
|
||||
isShuttingDown = true
|
||||
connectedDeviceRefreshTask?.cancel()
|
||||
connectedDeviceRefreshTask = nil
|
||||
footerResetTask?.cancel()
|
||||
footerResetTask = nil
|
||||
|
||||
for task in scanTasks.values {
|
||||
task.cancel()
|
||||
}
|
||||
scanTasks.removeAll()
|
||||
}
|
||||
|
||||
func shutdownGracefully(timeout: TimeInterval = 1.0) async {
|
||||
guard !isShuttingDown else {
|
||||
return
|
||||
}
|
||||
|
||||
shutdown()
|
||||
try? await Task.sleep(for: .seconds(timeout))
|
||||
}
|
||||
|
||||
func addSource(at url: URL) -> URL {
|
||||
@ -237,6 +282,10 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func startScan(for sourceID: URL) {
|
||||
guard !isShuttingDown else {
|
||||
return
|
||||
}
|
||||
|
||||
scanTasks[sourceID]?.cancel()
|
||||
|
||||
let task = Task { [weak self] in
|
||||
@ -252,10 +301,12 @@ final class SourceLibrary: ObservableObject {
|
||||
|
||||
private func scanSource(withID sourceID: URL) async {
|
||||
var workerTasks: [Task<Void, Never>] = []
|
||||
var previewWorkerTasks: [Task<Void, Never>] = []
|
||||
var sizeWorkerTasks: [Task<Void, Never>] = []
|
||||
let scanStartTime = Date()
|
||||
defer {
|
||||
workerTasks.forEach { $0.cancel() }
|
||||
previewWorkerTasks.forEach { $0.cancel() }
|
||||
sizeWorkerTasks.forEach { $0.cancel() }
|
||||
scanTasks[sourceID] = nil
|
||||
}
|
||||
@ -263,18 +314,15 @@ final class SourceLibrary: ObservableObject {
|
||||
guard let source = source(withID: sourceID) else {
|
||||
return
|
||||
}
|
||||
let previousSource = source
|
||||
let performanceContext = performanceContext(for: source)
|
||||
|
||||
updateSource(sourceID) { source in
|
||||
source.isScanning = true
|
||||
source.scanError = nil
|
||||
source.scanDiagnostic = nil
|
||||
source.scanStatus = initialScanStatus(for: source)
|
||||
source.displayItems = []
|
||||
source.rawItems = []
|
||||
source.logicalPacks = []
|
||||
source.logicalWorlds = []
|
||||
source.packInstances = []
|
||||
source.worldPackRelationships = []
|
||||
source.snapshot = nil
|
||||
source.scanProgress = nil
|
||||
source.indexedItemCount = 0
|
||||
source.indexedDetailCount = 0
|
||||
}
|
||||
@ -305,8 +353,12 @@ final class SourceLibrary: ObservableObject {
|
||||
do {
|
||||
let index = SourceIndexActor(sourceID: sourceID, folderURL: scanContextURL)
|
||||
let enrichmentQueue = EnrichmentWorkQueue()
|
||||
let previewQueue = EnrichmentWorkQueue()
|
||||
let sizeQueue = EnrichmentWorkQueue()
|
||||
workerTasks = (0..<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
|
||||
guard let library = self else {
|
||||
return
|
||||
@ -324,11 +376,33 @@ final class SourceLibrary: ObservableObject {
|
||||
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
|
||||
guard let library = self else {
|
||||
return
|
||||
@ -368,6 +442,7 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
var discoveredCount = 0
|
||||
let discoveryStartTime = Date()
|
||||
|
||||
for try await item in discoveryStream {
|
||||
guard !Task.isCancelled else {
|
||||
@ -386,23 +461,70 @@ final class SourceLibrary: ObservableObject {
|
||||
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()
|
||||
let enrichmentStartTime = Date()
|
||||
|
||||
for workerTask in workerTasks {
|
||||
await workerTask.value
|
||||
}
|
||||
|
||||
logScanStage(
|
||||
"Enrichment",
|
||||
elapsed: Date().timeIntervalSince(enrichmentStartTime),
|
||||
context: performanceContext,
|
||||
itemCount: discoveredCount
|
||||
)
|
||||
|
||||
if let snapshot = await index.markMetadataFinished() {
|
||||
applySnapshot(snapshot, to: sourceID)
|
||||
}
|
||||
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()
|
||||
let sizeStageStartTime = Date()
|
||||
|
||||
for sizeWorkerTask in sizeWorkerTasks {
|
||||
await sizeWorkerTask.value
|
||||
}
|
||||
|
||||
logScanStage(
|
||||
"Size",
|
||||
elapsed: Date().timeIntervalSince(sizeStageStartTime),
|
||||
context: performanceContext,
|
||||
itemCount: discoveredCount
|
||||
)
|
||||
|
||||
let elapsedScanTime = Date().timeIntervalSince(scanStartTime)
|
||||
if elapsedScanTime < Self.minimumVisibleScanDuration {
|
||||
try? await Task.sleep(
|
||||
@ -422,15 +544,33 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
persistSourceIfAvailable(withID: sourceID)
|
||||
refreshSidebarFooterState()
|
||||
} catch {
|
||||
guard !Task.isCancelled else {
|
||||
return
|
||||
}
|
||||
logScanStage(
|
||||
"Total",
|
||||
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
|
||||
source.availability = availabilityStatus(for: error, defaultingTo: source.availability)
|
||||
source.scanError = "Failed to scan folder: \(error.localizedDescription)"
|
||||
source.scanStatus = ""
|
||||
restoreScannedContent(from: previousSource, into: &source)
|
||||
source.availability = Task.isCancelled
|
||||
? 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
|
||||
}
|
||||
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) {
|
||||
guard isLogicalPackType(item.contentType) else {
|
||||
return
|
||||
@ -684,6 +900,7 @@ final class SourceLibrary: ObservableObject {
|
||||
source.indexedItemCount = snapshot.indexedItemCount
|
||||
source.indexedDetailCount = snapshot.indexedDetailCount
|
||||
source.scanStatus = snapshot.scanStatus
|
||||
source.scanProgress = snapshot.scanProgress
|
||||
source.isScanning = snapshot.isScanning
|
||||
source.lastScanDate = snapshot.lastScanDate
|
||||
}
|
||||
@ -819,11 +1036,16 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func runConnectedDeviceRefreshLoop() async {
|
||||
while !Task.isCancelled {
|
||||
while !Task.isCancelled && !isShuttingDown {
|
||||
await refreshConnectedDevices()
|
||||
|
||||
do {
|
||||
try await Task.sleep(for: .seconds(Self.connectedDeviceRefreshInterval))
|
||||
let refreshInterval = sources.contains {
|
||||
$0.isScanning && $0.origin.kind == .connectedDevice
|
||||
}
|
||||
? Self.connectedDeviceRefreshIntervalWhileScanning
|
||||
: Self.connectedDeviceRefreshInterval
|
||||
try await Task.sleep(for: .seconds(refreshInterval))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@ -831,6 +1053,10 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
private func refreshConnectedDevices() async {
|
||||
guard !isShuttingDown else {
|
||||
return
|
||||
}
|
||||
|
||||
guard let connectedDeviceAccessMethod else {
|
||||
return
|
||||
}
|
||||
@ -847,36 +1073,69 @@ final class SourceLibrary: ObservableObject {
|
||||
|
||||
var entries: [ConnectedDeviceSidebarEntry] = []
|
||||
var matchedSourceIDs = Set<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 {
|
||||
if let matchedSourceID = knownConnectedDeviceSourceID(for: device) {
|
||||
matchedSourceIDs.insert(matchedSourceID)
|
||||
let cachedContainers = cachedDeviceDiscoveryByUDID[device.udid]?.containers ?? []
|
||||
refreshMatchedConnectedDeviceSource(
|
||||
sourceID: matchedSourceID,
|
||||
device: device,
|
||||
containers: []
|
||||
containers: cachedContainers
|
||||
)
|
||||
|
||||
entries.append(
|
||||
ConnectedDeviceSidebarEntry(
|
||||
device: device,
|
||||
containers: [],
|
||||
matchedSourceID: matchedSourceID,
|
||||
discoveryErrorDescription: nil
|
||||
)
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
let containers: [DeviceAppContainer]
|
||||
let discoveryErrorDescription: String?
|
||||
if let cachedDiscovery = cachedDiscovery(for: device, isActivelyScanning: activeScanningDeviceUDIDs.contains(device.udid)) {
|
||||
containers = cachedDiscovery.containers
|
||||
discoveryErrorDescription = cachedDiscovery.discoveryErrorDescription
|
||||
} else {
|
||||
let containerDiscoveryStartTime = Date()
|
||||
|
||||
do {
|
||||
containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
|
||||
discoveryErrorDescription = nil
|
||||
} catch {
|
||||
containers = []
|
||||
discoveryErrorDescription = error.localizedDescription
|
||||
do {
|
||||
containers = try await connectedDeviceAccessMethod.listAccessibleContainers(for: device)
|
||||
discoveryErrorDescription = nil
|
||||
cacheDeviceDiscovery(
|
||||
device: device,
|
||||
containers: containers,
|
||||
discoveryErrorDescription: nil
|
||||
)
|
||||
logDeviceRefreshStage(
|
||||
"Container discovery",
|
||||
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
|
||||
device: device,
|
||||
containerCount: containers.count
|
||||
)
|
||||
} catch {
|
||||
containers = []
|
||||
discoveryErrorDescription = error.localizedDescription
|
||||
cacheDeviceDiscovery(
|
||||
device: device,
|
||||
containers: [],
|
||||
discoveryErrorDescription: error.localizedDescription
|
||||
)
|
||||
logDeviceRefreshStage(
|
||||
"Container discovery failed",
|
||||
elapsed: Date().timeIntervalSince(containerDiscoveryStartTime),
|
||||
device: device,
|
||||
containerCount: 0,
|
||||
error: error
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
let matchedSourceID = matchingConnectedDeviceSourceID(
|
||||
@ -894,9 +1153,8 @@ final class SourceLibrary: ObservableObject {
|
||||
}
|
||||
|
||||
let shouldDisplayEntry =
|
||||
matchedSourceID != nil
|
||||
|| !containers.isEmpty
|
||||
|| device.trustState != .trusted
|
||||
matchedSourceID == nil
|
||||
&& (!containers.isEmpty || device.trustState != .trusted)
|
||||
|
||||
if shouldDisplayEntry {
|
||||
entries.append(
|
||||
@ -931,46 +1189,82 @@ final class SourceLibrary: ObservableObject {
|
||||
lastMatchedConnectedSourceIDs = matchedSourceIDs
|
||||
}
|
||||
|
||||
private func cachedDiscovery(for device: ConnectedDevice, isActivelyScanning: Bool) -> CachedConnectedDeviceDiscovery? {
|
||||
guard let cachedDiscovery = cachedDeviceDiscoveryByUDID[device.udid] else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if isActivelyScanning {
|
||||
return cachedDiscovery
|
||||
}
|
||||
|
||||
let age = Date().timeIntervalSince(cachedDiscovery.refreshedAt)
|
||||
guard age <= discoveryCacheTTL(for: device) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
guard cachedDiscovery.device.connection == device.connection,
|
||||
cachedDiscovery.device.trustState == device.trustState,
|
||||
cachedDiscovery.device.name == device.name else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return cachedDiscovery
|
||||
}
|
||||
|
||||
private func discoveryCacheTTL(for device: ConnectedDevice) -> TimeInterval {
|
||||
switch device.connection {
|
||||
case .usb:
|
||||
return Self.usbConnectedDeviceDiscoveryCacheTTL
|
||||
case .network:
|
||||
return Self.networkConnectedDeviceDiscoveryCacheTTL
|
||||
}
|
||||
}
|
||||
|
||||
private func cacheDeviceDiscovery(
|
||||
device: ConnectedDevice,
|
||||
containers: [DeviceAppContainer],
|
||||
discoveryErrorDescription: String?
|
||||
) {
|
||||
cachedDeviceDiscoveryByUDID[device.udid] = CachedConnectedDeviceDiscovery(
|
||||
device: device,
|
||||
containers: containers,
|
||||
discoveryErrorDescription: discoveryErrorDescription,
|
||||
refreshedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private func matchingConnectedDeviceSourceID(
|
||||
device: ConnectedDevice,
|
||||
containers: [DeviceAppContainer]
|
||||
) -> URL? {
|
||||
for source in sources {
|
||||
guard case .connectedDevice(let expectedDevice, let expectedContainer) = source.origin else {
|
||||
continue
|
||||
for container in containers {
|
||||
let sourceID = connectedDeviceSourceFactory.makeSourceIdentifier(
|
||||
device: device,
|
||||
container: container
|
||||
)
|
||||
if sources.contains(where: { $0.id == sourceID }) {
|
||||
return sourceID
|
||||
}
|
||||
|
||||
guard expectedDevice.udid == device.udid else {
|
||||
continue
|
||||
}
|
||||
|
||||
guard containers.contains(where: { container in
|
||||
container.appID == expectedContainer.appID
|
||||
&& container.accessMode == expectedContainer.accessMode
|
||||
}) else {
|
||||
continue
|
||||
}
|
||||
|
||||
return source.id
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
private func knownConnectedDeviceSourceID(for device: ConnectedDevice) -> URL? {
|
||||
for source in sources {
|
||||
let matchingSourceIDs = sources.compactMap { source -> URL? in
|
||||
guard case .connectedDevice(let expectedDevice, _) = source.origin else {
|
||||
continue
|
||||
return nil
|
||||
}
|
||||
|
||||
guard expectedDevice.udid == device.udid else {
|
||||
continue
|
||||
}
|
||||
|
||||
return source.id
|
||||
return expectedDevice.udid == device.udid ? source.id : nil
|
||||
}
|
||||
|
||||
return nil
|
||||
guard matchingSourceIDs.count == 1 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return matchingSourceIDs.first
|
||||
}
|
||||
|
||||
private func refreshMatchedConnectedDeviceSource(
|
||||
@ -1281,7 +1575,15 @@ final class SourceLibrary: ObservableObject {
|
||||
let detail: String?
|
||||
if source.indexedItemCount > 0 {
|
||||
subtitle = source.displayName
|
||||
detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
|
||||
let previewLoadedCount = source.rawItems.filter(\.previewLoaded).count
|
||||
let sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count
|
||||
if sizeLoadedCount > 0 || source.scanStatus.contains("Calculating sizes") {
|
||||
detail = "\(sizeLoadedCount) of \(source.indexedItemCount) sizes calculated"
|
||||
} else if previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") {
|
||||
detail = "\(previewLoadedCount) of \(source.indexedItemCount) previews loaded"
|
||||
} else {
|
||||
detail = "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
|
||||
}
|
||||
} else {
|
||||
subtitle = "Searching \(source.displayName)"
|
||||
detail = nil
|
||||
@ -1534,6 +1836,7 @@ private struct SourceIndexSnapshot {
|
||||
let indexedItemCount: Int
|
||||
let indexedDetailCount: Int
|
||||
let scanStatus: String
|
||||
let scanProgress: Double?
|
||||
let isScanning: Bool
|
||||
let lastScanDate: Date?
|
||||
}
|
||||
@ -1552,8 +1855,10 @@ private actor SourceIndexActor {
|
||||
private var packRepresentativeItemIDByIdentityID: [String: URL] = [:]
|
||||
private var indexedItemCount = 0
|
||||
private var indexedDetailCount = 0
|
||||
private var previewLoadedCount = 0
|
||||
private var discoveryFinished = false
|
||||
private var metadataFinished = false
|
||||
private var previewsFinished = false
|
||||
private var sizesFinished = false
|
||||
private var lastPublishedAt: Date?
|
||||
|
||||
@ -1594,6 +1899,16 @@ private actor SourceIndexActor {
|
||||
return snapshotIfNeeded()
|
||||
}
|
||||
|
||||
func applyPreviewItem(_ item: MinecraftContentItem) -> SourceIndexSnapshot? {
|
||||
let previous = itemsByID[item.id]
|
||||
itemsByID[item.id] = item
|
||||
if item.previewLoaded, previous?.previewLoaded != true {
|
||||
previewLoadedCount += 1
|
||||
}
|
||||
|
||||
return snapshotIfNeeded()
|
||||
}
|
||||
|
||||
func markDiscoveryFinished() -> SourceIndexSnapshot? {
|
||||
discoveryFinished = true
|
||||
return buildSnapshot(force: true)
|
||||
@ -1605,9 +1920,17 @@ private actor SourceIndexActor {
|
||||
return buildSnapshot(force: true)
|
||||
}
|
||||
|
||||
func markPreviewsFinished() -> SourceIndexSnapshot? {
|
||||
discoveryFinished = true
|
||||
metadataFinished = true
|
||||
previewsFinished = true
|
||||
return buildSnapshot(force: true)
|
||||
}
|
||||
|
||||
func finishScan() -> SourceIndexSnapshot? {
|
||||
discoveryFinished = true
|
||||
metadataFinished = true
|
||||
previewsFinished = true
|
||||
sizesFinished = true
|
||||
return buildSnapshot(force: true)
|
||||
}
|
||||
@ -1632,6 +1955,10 @@ private actor SourceIndexActor {
|
||||
logicalPacks: logicalPacks,
|
||||
rawItemsByID: rawItemsByID
|
||||
)
|
||||
let metadataFraction = progressFraction(completed: indexedDetailCount, total: indexedItemCount)
|
||||
let previewFraction = progressFraction(completed: previewLoadedCount, total: indexedItemCount)
|
||||
let sizeLoadedCount = rawItems.filter(\.sizeLoaded).count
|
||||
let sizeFraction = progressFraction(completed: sizeLoadedCount, total: indexedItemCount)
|
||||
let scanStatus: String
|
||||
|
||||
if !discoveryFinished {
|
||||
@ -1649,6 +1976,7 @@ private actor SourceIndexActor {
|
||||
indexedItemCount: indexedItemCount,
|
||||
indexedDetailCount: indexedDetailCount,
|
||||
scanStatus: scanStatus,
|
||||
scanProgress: nil,
|
||||
isScanning: true,
|
||||
lastScanDate: nil
|
||||
)
|
||||
@ -1657,7 +1985,7 @@ private actor SourceIndexActor {
|
||||
if !metadataFinished {
|
||||
scanStatus = indexedItemCount == 0
|
||||
? "No Minecraft items found."
|
||||
: "Deduplicating packs..."
|
||||
: "Loading metadata for \(indexedDetailCount) of \(indexedItemCount) items..."
|
||||
|
||||
return SourceIndexSnapshot(
|
||||
displayItems: dedupedDisplayItems,
|
||||
@ -1669,6 +1997,28 @@ private actor SourceIndexActor {
|
||||
indexedItemCount: indexedItemCount,
|
||||
indexedDetailCount: indexedDetailCount,
|
||||
scanStatus: scanStatus,
|
||||
scanProgress: progressAfterDiscovery(metadataFraction),
|
||||
isScanning: true,
|
||||
lastScanDate: nil
|
||||
)
|
||||
}
|
||||
|
||||
if !previewsFinished {
|
||||
scanStatus = indexedItemCount == 0
|
||||
? "No Minecraft items found."
|
||||
: "Loading previews for \(previewLoadedCount) of \(indexedItemCount) items..."
|
||||
|
||||
return SourceIndexSnapshot(
|
||||
displayItems: dedupedDisplayItems,
|
||||
rawItems: rawItems,
|
||||
logicalPacks: logicalPacks,
|
||||
logicalWorlds: [],
|
||||
packInstances: [],
|
||||
worldPackRelationships: [],
|
||||
indexedItemCount: indexedItemCount,
|
||||
indexedDetailCount: indexedDetailCount,
|
||||
scanStatus: scanStatus,
|
||||
scanProgress: progressAfterMetadata(previewFraction),
|
||||
isScanning: true,
|
||||
lastScanDate: nil
|
||||
)
|
||||
@ -1752,7 +2102,7 @@ private actor SourceIndexActor {
|
||||
if !sizesFinished {
|
||||
scanStatus = indexedItemCount == 0
|
||||
? "No Minecraft items found."
|
||||
: "Resolving pack relationships..."
|
||||
: "Calculating sizes for \(sizeLoadedCount) of \(indexedItemCount) items..."
|
||||
} else {
|
||||
scanStatus = indexedItemCount == 0
|
||||
? "No Minecraft items found."
|
||||
@ -1771,11 +2121,32 @@ private actor SourceIndexActor {
|
||||
indexedItemCount: indexedItemCount,
|
||||
indexedDetailCount: indexedDetailCount,
|
||||
scanStatus: scanStatus,
|
||||
scanProgress: sizesFinished ? nil : progressAfterPreviews(sizeFraction),
|
||||
isScanning: !sizesFinished,
|
||||
lastScanDate: sizesFinished ? now : nil
|
||||
)
|
||||
}
|
||||
|
||||
private func progressFraction(completed: Int, total: Int) -> Double {
|
||||
guard total > 0 else {
|
||||
return 1
|
||||
}
|
||||
|
||||
return min(max(Double(completed) / Double(total), 0), 1)
|
||||
}
|
||||
|
||||
private func progressAfterDiscovery(_ metadataFraction: Double) -> Double {
|
||||
0.1 + (metadataFraction * 0.55)
|
||||
}
|
||||
|
||||
private func progressAfterMetadata(_ previewFraction: Double) -> Double {
|
||||
0.65 + (previewFraction * 0.1)
|
||||
}
|
||||
|
||||
private func progressAfterPreviews(_ sizeFraction: Double) -> Double {
|
||||
0.75 + (sizeFraction * 0.25)
|
||||
}
|
||||
|
||||
private func buildDisplayItems(
|
||||
from rawItems: [MinecraftContentItem],
|
||||
logicalPacks: [LogicalPack],
|
||||
|
||||
@ -90,13 +90,24 @@ enum WorldScanner {
|
||||
let fileManager = FileManager.default
|
||||
var enrichedItem = item
|
||||
|
||||
enrichedItem.displayName = displayName(for: item, fileManager: fileManager)
|
||||
let sourceIconURL = iconURL(for: item, fileManager: fileManager)
|
||||
enrichedItem.displayName = MinecraftContentMetadataReader.displayName(
|
||||
for: item.folderURL,
|
||||
contentType: item.contentType,
|
||||
fallbackName: item.folderName,
|
||||
fileManager: fileManager
|
||||
)
|
||||
let sourceIconURL = MinecraftContentMetadataReader.iconURL(
|
||||
for: item.folderURL,
|
||||
contentType: item.contentType,
|
||||
fileManager: fileManager
|
||||
)
|
||||
enrichedItem.iconURL = await ImageCacheStore.shared.cachedImageURL(for: sourceIconURL)
|
||||
enrichedItem.worldMetadata = worldMetadata(for: item, fileManager: fileManager)
|
||||
enrichedItem.worldMetadata = item.contentType == .world
|
||||
? MinecraftContentMetadataReader.worldMetadata(in: item.folderURL, fileManager: fileManager)
|
||||
: nil
|
||||
enrichedItem.lastPlayedDate = lastPlayedDate(for: item, fileManager: fileManager, worldMetadata: enrichedItem.worldMetadata)
|
||||
enrichedItem.modifiedDate = modifiedDate(for: item.folderURL)
|
||||
if let manifestMetadata = manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
||||
if let manifestMetadata = MinecraftContentMetadataReader.manifestMetadata(in: item.folderURL, fileManager: fileManager) {
|
||||
enrichedItem.packUUID = manifestMetadata.uuid
|
||||
enrichedItem.packVersion = manifestMetadata.version
|
||||
enrichedItem.packMetadataDetails = PackMetadataDetails(
|
||||
@ -108,6 +119,7 @@ enum WorldScanner {
|
||||
}
|
||||
enrichedItem.packReferences = await packReferences(for: item, fileManager: fileManager)
|
||||
enrichedItem.metadataLoaded = true
|
||||
enrichedItem.previewLoaded = true
|
||||
enrichedItem.sizeLoaded = false
|
||||
|
||||
return enrichedItem
|
||||
@ -204,65 +216,6 @@ enum WorldScanner {
|
||||
return embeddedItems
|
||||
}
|
||||
|
||||
nonisolated private static func displayName(for item: MinecraftContentItem, fileManager: FileManager) -> String {
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
let levelNameURL = item.folderURL.appendingPathComponent("levelname.txt")
|
||||
guard
|
||||
let name = try? String(contentsOf: levelNameURL, encoding: .utf8)
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!name.isEmpty
|
||||
else {
|
||||
return item.folderName
|
||||
}
|
||||
|
||||
return name
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
if let manifestName = manifestName(in: item.folderURL, fileManager: fileManager) {
|
||||
return manifestName
|
||||
}
|
||||
|
||||
return item.folderName
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func manifestName(in directoryURL: URL, fileManager: FileManager) -> String? {
|
||||
manifestMetadata(in: directoryURL, fileManager: fileManager)?.name
|
||||
}
|
||||
|
||||
nonisolated private static func packIconURL(in directoryURL: URL, fileManager: FileManager) -> URL? {
|
||||
let candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
||||
|
||||
for candidateName in candidateNames {
|
||||
let candidateURL = directoryURL.appendingPathComponent(candidateName)
|
||||
if fileManager.fileExists(atPath: candidateURL.path) {
|
||||
return candidateURL
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func iconURL(for item: MinecraftContentItem, fileManager: FileManager) -> URL? {
|
||||
let candidateNames: [String]
|
||||
|
||||
switch item.contentType {
|
||||
case .world:
|
||||
candidateNames = ["world_icon.jpeg", "world_icon.jpg", "world_icon.png"]
|
||||
case .behaviorPack, .resourcePack, .skinPack, .worldTemplate:
|
||||
candidateNames = ["pack_icon.png", "pack_icon.jpeg", "pack_icon.jpg"]
|
||||
}
|
||||
|
||||
for candidateName in candidateNames {
|
||||
let candidateURL = item.folderURL.appendingPathComponent(candidateName)
|
||||
if fileManager.fileExists(atPath: candidateURL.path) {
|
||||
return candidateURL
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func lastPlayedDate(
|
||||
for item: MinecraftContentItem,
|
||||
fileManager: FileManager,
|
||||
@ -374,7 +327,7 @@ enum WorldScanner {
|
||||
|
||||
for entry in jsonObject {
|
||||
let uuid = (entry["pack_id"] as? String)?.lowercased()
|
||||
let version = versionString(from: entry["version"])
|
||||
let version = MinecraftContentMetadataReader.versionString(from: entry["version"])
|
||||
let resolvedPack: ContentPackReference?
|
||||
if let uuid {
|
||||
resolvedPack = await resolvedPackReference(
|
||||
@ -429,33 +382,20 @@ enum WorldScanner {
|
||||
source: PackSource,
|
||||
fileManager: FileManager
|
||||
) -> ContentPackReference? {
|
||||
guard let metadata = manifestMetadata(in: directoryURL, fileManager: fileManager) else {
|
||||
guard let metadata = MinecraftContentMetadataReader.manifestMetadata(in: directoryURL, fileManager: fileManager) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ContentPackReference(
|
||||
name: metadata.name,
|
||||
type: type,
|
||||
iconURL: packIconURL(in: directoryURL, fileManager: fileManager),
|
||||
iconURL: MinecraftContentMetadataReader.packIconURL(in: directoryURL, fileManager: fileManager),
|
||||
uuid: metadata.uuid,
|
||||
version: metadata.version,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func worldMetadata(for item: MinecraftContentItem, fileManager: FileManager) -> WorldMetadata? {
|
||||
guard item.contentType == .world else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let levelDatURL = item.folderURL.appendingPathComponent("level.dat")
|
||||
guard fileManager.fileExists(atPath: levelDatURL.path) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return BedrockLevelMetadataDecoder.decode(fromLevelDatAt: levelDatURL)
|
||||
}
|
||||
|
||||
nonisolated private static func resolvedPackReference(
|
||||
uuid: String,
|
||||
type: MinecraftContentType,
|
||||
@ -472,51 +412,6 @@ enum WorldScanner {
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func versionString(from value: Any?) -> String? {
|
||||
if let versionString = value as? String, !versionString.isEmpty {
|
||||
return versionString
|
||||
}
|
||||
|
||||
if let versionArray = value as? [Any] {
|
||||
let components = versionArray.compactMap { component -> String? in
|
||||
if let intComponent = component as? Int {
|
||||
return String(intComponent)
|
||||
}
|
||||
if let stringComponent = component as? String {
|
||||
return stringComponent
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return components.isEmpty ? nil : components.joined(separator: ".")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
nonisolated private static func manifestMetadata(in directoryURL: URL, fileManager: FileManager) -> ManifestMetadata? {
|
||||
let manifestURL = directoryURL.appendingPathComponent("manifest.json")
|
||||
guard
|
||||
fileManager.fileExists(atPath: manifestURL.path),
|
||||
let data = try? Data(contentsOf: manifestURL),
|
||||
let jsonObject = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let header = jsonObject["header"] as? [String: Any]
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let name = ((header["name"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)).flatMap {
|
||||
$0.isEmpty ? nil : $0
|
||||
} ?? directoryURL.lastPathComponent
|
||||
|
||||
return ManifestMetadata(
|
||||
name: name,
|
||||
uuid: (header["uuid"] as? String)?.lowercased(),
|
||||
version: versionString(from: header["version"]),
|
||||
minimumEngineVersion: versionString(from: header["min_engine_version"])
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private static func uniquePackReferences(_ references: [ContentPackReference]) -> [ContentPackReference] {
|
||||
var seen = Set<String>()
|
||||
var uniqueReferences: [ContentPackReference] = []
|
||||
@ -541,13 +436,6 @@ enum WorldScanner {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ManifestMetadata {
|
||||
let name: String
|
||||
let uuid: String?
|
||||
let version: String?
|
||||
let minimumEngineVersion: String?
|
||||
}
|
||||
|
||||
private actor PackReferenceIndexStore {
|
||||
private var referencesByCollectionURL: [URL: [String: ContentPackReference]] = [:]
|
||||
|
||||
|
||||
258
World Manager for Minecraft/Services/ZipArchiveReader.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -21,7 +21,7 @@ struct SidebarFilter: Identifiable, Hashable {
|
||||
}
|
||||
|
||||
struct SourcesSidebarView: View {
|
||||
let localSources: [MinecraftSource]
|
||||
let sources: [MinecraftSource]
|
||||
let connectedDevices: [ConnectedDeviceSidebarEntry]
|
||||
@Binding var selection: SidebarSelection?
|
||||
let footerState: SidebarFooterState
|
||||
@ -32,13 +32,12 @@ struct SourcesSidebarView: View {
|
||||
let removeSourceAction: (MinecraftSource) -> Void
|
||||
let revealFooterURLAction: (URL) -> Void
|
||||
let filters: (MinecraftSource) -> [SidebarFilter]
|
||||
let matchedSource: (ConnectedDeviceSidebarEntry) -> MinecraftSource?
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selection) {
|
||||
if !localSources.isEmpty {
|
||||
if !sources.isEmpty {
|
||||
Section {
|
||||
ForEach(localSources) { source in
|
||||
ForEach(sources) { source in
|
||||
sourceSectionRows(for: source)
|
||||
}
|
||||
} header: {
|
||||
@ -57,17 +56,6 @@ struct SourcesSidebarView: View {
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.overlay(alignment: .bottom) {
|
||||
if footerState.style != .idle {
|
||||
SidebarFooterView(
|
||||
state: footerState,
|
||||
revealAction: revealFooterURLAction
|
||||
)
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.bottom, 10)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.toolbar {
|
||||
ToolbarItem {
|
||||
Button(action: addSourceAction) {
|
||||
@ -83,12 +71,11 @@ struct SourcesSidebarView: View {
|
||||
.help("Add Connected Device Source")
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.2), value: footerState.style)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func sourceSectionRows(for source: MinecraftSource) -> some View {
|
||||
SourceHeaderRow(title: source.displayName)
|
||||
SourceHeaderRow(source: source)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.top, 6)
|
||||
.contextMenu {
|
||||
@ -111,18 +98,14 @@ struct SourcesSidebarView: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func connectedDeviceSectionRows(for entry: ConnectedDeviceSidebarEntry) -> some View {
|
||||
if let source = matchedSource(entry) {
|
||||
sourceSectionRows(for: source)
|
||||
} else {
|
||||
ConnectedDeviceRow(
|
||||
entry: entry,
|
||||
addAction: entry.hasMinecraftContainer ? {
|
||||
addConnectedDeviceAction(entry)
|
||||
} : nil
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
ConnectedDeviceRow(
|
||||
entry: entry,
|
||||
addAction: entry.hasMinecraftContainer ? {
|
||||
addConnectedDeviceAction(entry)
|
||||
} : nil
|
||||
)
|
||||
.listRowSeparator(.hidden)
|
||||
.padding(.top, 6)
|
||||
}
|
||||
}
|
||||
|
||||
@ -159,12 +142,223 @@ private struct SidebarSourcesSectionHeaderView: View {
|
||||
}
|
||||
|
||||
private struct SourceHeaderRow: View {
|
||||
let title: String
|
||||
let source: MinecraftSource
|
||||
@State private var isPresentingStatusPopover = false
|
||||
|
||||
var body: some View {
|
||||
Text(title)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
HStack(spacing: 8) {
|
||||
Text(source.displayName)
|
||||
.font(.subheadline.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Spacer(minLength: 8)
|
||||
|
||||
if let connection {
|
||||
SourceConnectionBadge(connection: connection)
|
||||
}
|
||||
|
||||
if showsStatusButton {
|
||||
Button {
|
||||
isPresentingStatusPopover = true
|
||||
} label: {
|
||||
statusIndicator
|
||||
.frame(width: 24, height: 24)
|
||||
.contentShape(Rectangle())
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help(scanStatusHelpText)
|
||||
.popover(isPresented: $isPresentingStatusPopover, arrowEdge: .top) {
|
||||
SourceStatusPopover(source: source)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var connection: DeviceConnection? {
|
||||
guard case .connectedDevice(let device, _) = source.origin else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return device.connection
|
||||
}
|
||||
|
||||
private var scanStatusHelpText: String {
|
||||
if let scanError = source.scanError, !scanError.isEmpty {
|
||||
return scanError
|
||||
}
|
||||
|
||||
if !source.scanStatus.isEmpty {
|
||||
return source.scanStatus
|
||||
}
|
||||
|
||||
return "Scanning library…"
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIndicator: some View {
|
||||
if source.isScanning {
|
||||
if let scanProgress = source.scanProgress {
|
||||
CircularScanProgressView(progress: scanProgress)
|
||||
} else {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
} else if source.scanError != nil {
|
||||
Image(systemName: "exclamationmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var showsStatusButton: Bool {
|
||||
source.isScanning || source.scanError != nil
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceConnectionBadge: View {
|
||||
let connection: DeviceConnection
|
||||
|
||||
var body: some View {
|
||||
Image(systemName: symbolName)
|
||||
.font(.caption.weight(.semibold))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 7)
|
||||
.padding(.vertical, 4)
|
||||
.background(.secondary.opacity(0.12), in: Capsule())
|
||||
.help(helpText)
|
||||
.accessibilityLabel(helpText)
|
||||
}
|
||||
|
||||
private var symbolName: String {
|
||||
switch connection {
|
||||
case .usb:
|
||||
return "cable.connector"
|
||||
case .network:
|
||||
return "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
private var helpText: String {
|
||||
switch connection {
|
||||
case .usb:
|
||||
return "USB"
|
||||
case .network:
|
||||
return "Network"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SourceStatusPopover: View {
|
||||
let source: MinecraftSource
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
if !source.isScanning, source.scanError != nil {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
} else if !source.isScanning {
|
||||
Image(systemName: "info.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Text(titleText)
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
if source.isScanning, let scanProgress = source.scanProgress {
|
||||
ProgressView(value: scanProgress, total: 1)
|
||||
}
|
||||
|
||||
if let subtitleText {
|
||||
Text(subtitleText)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if let detailText {
|
||||
Text(detailText)
|
||||
.font(.footnote)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.frame(width: 280, alignment: .leading)
|
||||
.padding(14)
|
||||
}
|
||||
|
||||
private var titleText: String {
|
||||
if let scanError = source.scanError, !scanError.isEmpty {
|
||||
return "Scan Failed"
|
||||
}
|
||||
|
||||
if !source.scanStatus.isEmpty {
|
||||
return source.scanStatus
|
||||
}
|
||||
|
||||
return "Scanning Minecraft library..."
|
||||
}
|
||||
|
||||
private var subtitleText: String? {
|
||||
if let scanError = source.scanError, !scanError.isEmpty {
|
||||
return scanError
|
||||
}
|
||||
|
||||
if source.indexedItemCount > 0 {
|
||||
return source.displayName
|
||||
}
|
||||
|
||||
return "Searching \(source.displayName)"
|
||||
}
|
||||
|
||||
private var detailText: String? {
|
||||
if let diagnostic = source.scanDiagnostic, !diagnostic.isEmpty {
|
||||
return diagnostic
|
||||
}
|
||||
|
||||
guard source.scanError == nil, source.indexedItemCount > 0 else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if source.isScanning, let scanProgress = source.scanProgress {
|
||||
let percentage = Int((scanProgress * 100).rounded())
|
||||
let previewLoadedCount = source.rawItems.filter(\.previewLoaded).count
|
||||
let sizeLoadedCount = source.rawItems.filter(\.sizeLoaded).count
|
||||
if sizeLoadedCount > 0 || source.scanStatus.contains("Calculating sizes") {
|
||||
return "\(sizeLoadedCount) of \(source.indexedItemCount) sizes calculated • \(percentage)%"
|
||||
}
|
||||
if previewLoadedCount > 0 || source.scanStatus.contains("Loading previews") {
|
||||
return "\(previewLoadedCount) of \(source.indexedItemCount) previews loaded • \(percentage)%"
|
||||
}
|
||||
|
||||
return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed • \(percentage)%"
|
||||
}
|
||||
|
||||
return "\(source.indexedDetailCount) of \(source.indexedItemCount) indexed"
|
||||
}
|
||||
}
|
||||
|
||||
private struct CircularScanProgressView: View {
|
||||
let progress: Double
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Circle()
|
||||
.stroke(.secondary.opacity(0.18), lineWidth: 3)
|
||||
|
||||
Circle()
|
||||
.trim(from: 0, to: max(0.02, min(progress, 1)))
|
||||
.stroke(
|
||||
Color.appAccent,
|
||||
style: StrokeStyle(lineWidth: 3, lineCap: .round)
|
||||
)
|
||||
.rotationEffect(.degrees(-90))
|
||||
}
|
||||
.frame(width: 18, height: 18)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Scan progress")
|
||||
.accessibilityValue(Text("\(Int((progress * 100).rounded())) percent"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -174,9 +368,11 @@ private struct ConnectedDeviceRow: View {
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 10) {
|
||||
Image(systemName: iconName)
|
||||
.frame(width: 16)
|
||||
.foregroundStyle(iconColor)
|
||||
ConnectedDeviceTransportIcon(
|
||||
baseSymbolName: iconName,
|
||||
connection: entry.device.connection,
|
||||
tint: iconColor
|
||||
)
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(entry.device.name)
|
||||
@ -246,6 +442,49 @@ private struct ConnectedDeviceRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private struct ConnectedDeviceTransportIcon: View {
|
||||
let baseSymbolName: String
|
||||
let connection: DeviceConnection
|
||||
let tint: Color
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
Image(systemName: baseSymbolName)
|
||||
.font(.system(size: 22, weight: .medium))
|
||||
.foregroundStyle(tint)
|
||||
.frame(width: 28, height: 28)
|
||||
|
||||
Image(systemName: badgeSymbolName)
|
||||
.font(.system(size: 8, weight: .bold))
|
||||
.foregroundStyle(.primary)
|
||||
.padding(4)
|
||||
.background(.thinMaterial, in: Circle())
|
||||
.offset(x: 4, y: 4)
|
||||
}
|
||||
.help(helpText)
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel(helpText)
|
||||
}
|
||||
|
||||
private var badgeSymbolName: String {
|
||||
switch connection {
|
||||
case .usb:
|
||||
return "cable.connector"
|
||||
case .network:
|
||||
return "wifi"
|
||||
}
|
||||
}
|
||||
|
||||
private var helpText: String {
|
||||
switch connection {
|
||||
case .usb:
|
||||
return "Connected by USB"
|
||||
case .network:
|
||||
return "Connected by Network"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct SidebarFooterView: View {
|
||||
let state: SidebarFooterState
|
||||
let revealAction: (URL) -> Void
|
||||
|
||||
@ -12,6 +12,7 @@ struct AppleMobileDeviceSummary: Sendable {
|
||||
let deviceIdentifier: String
|
||||
let productType: String
|
||||
let productVersion: String
|
||||
let connectionType: String
|
||||
let trustState: DeviceTrustState
|
||||
}
|
||||
|
||||
@ -28,9 +29,24 @@ struct AppleMobileMinecraftLibraryItemSummary: Sendable {
|
||||
let relativePath: String
|
||||
let folderName: String
|
||||
let displayName: String
|
||||
let hasIcon: Bool
|
||||
}
|
||||
|
||||
struct AppleMobilePackReferenceSummary: Sendable {
|
||||
let name: String
|
||||
let contentType: String
|
||||
let uuid: String?
|
||||
let version: String?
|
||||
let source: String
|
||||
}
|
||||
|
||||
struct AppleMobileMinecraftItemMetadataSummary: Sendable {
|
||||
let relativePath: String
|
||||
let displayName: String?
|
||||
let packUUID: String?
|
||||
let packVersion: String?
|
||||
let minimumEngineVersion: String?
|
||||
let packReferences: [AppleMobilePackReferenceSummary]
|
||||
}
|
||||
|
||||
struct AppleMobileDevicePathMetrics: Sendable {
|
||||
@ -38,11 +54,50 @@ struct AppleMobileDevicePathMetrics: Sendable {
|
||||
let modifiedDate: Date?
|
||||
}
|
||||
|
||||
actor AppleMobileDeviceOperationLimiter {
|
||||
static let shared = AppleMobileDeviceOperationLimiter()
|
||||
|
||||
private var activeDevices = Set<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 {
|
||||
static func firstConnectedDevice() async throws -> AppleMobileDeviceSummary {
|
||||
static func connectedDevices() async throws -> [AppleMobileDeviceSummary] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceSummary(&error) else {
|
||||
guard let response = WMMCopyConnectedDeviceSummaries(&error) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 1,
|
||||
@ -50,14 +105,7 @@ enum AppleMobileDeviceAccess {
|
||||
)
|
||||
}
|
||||
|
||||
guard
|
||||
let deviceName = response["deviceName"] as? String,
|
||||
let deviceIdentifier = response["deviceIdentifier"] as? String,
|
||||
let productType = response["productType"] as? String,
|
||||
let productVersion = response["productVersion"] as? String,
|
||||
let trustStateRawValue = response["trustState"] as? String,
|
||||
let trustState = DeviceTrustState(rawValue: trustStateRawValue)
|
||||
else {
|
||||
guard let rawDevices = response["devices"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 2,
|
||||
@ -65,211 +113,323 @@ enum AppleMobileDeviceAccess {
|
||||
)
|
||||
}
|
||||
|
||||
return AppleMobileDeviceSummary(
|
||||
deviceName: deviceName,
|
||||
deviceIdentifier: deviceIdentifier,
|
||||
productType: productType,
|
||||
productVersion: productVersion,
|
||||
trustState: trustState
|
||||
)
|
||||
return try rawDevices.map { device in
|
||||
guard
|
||||
let deviceName = device["deviceName"] as? String,
|
||||
let deviceIdentifier = device["deviceIdentifier"] as? String,
|
||||
let productType = device["productType"] as? String,
|
||||
let productVersion = device["productVersion"] as? String,
|
||||
let connectionType = device["connectionType"] as? String,
|
||||
let trustStateRawValue = device["trustState"] as? String,
|
||||
let trustState = DeviceTrustState(rawValue: trustStateRawValue)
|
||||
else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice summary returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return AppleMobileDeviceSummary(
|
||||
deviceName: deviceName,
|
||||
deviceIdentifier: deviceIdentifier,
|
||||
productType: productType,
|
||||
productVersion: productVersion,
|
||||
connectionType: connectionType,
|
||||
trustState: trustState
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
|
||||
static func mirrorSubtree(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
relativePath: String,
|
||||
destinationDirectoryURL: URL
|
||||
) async throws {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
let didCopy = WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
destinationDirectoryURL,
|
||||
&error
|
||||
)
|
||||
|
||||
if !didCopy {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice subtree mirror failed."]
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
let didCopy = WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
destinationDirectoryURL,
|
||||
&error
|
||||
)
|
||||
}
|
||||
}.value
|
||||
|
||||
if !didCopy {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice subtree mirror failed."]
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func listApplications() async throws -> [AppleMobileDeviceApplicationSummary] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceApplicationList(&error) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing failed."]
|
||||
)
|
||||
}
|
||||
|
||||
guard let rawApplications = response["applications"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return rawApplications.compactMap { application in
|
||||
guard
|
||||
let bundleIdentifier = application["bundleIdentifier"] as? String,
|
||||
let displayName = application["displayName"] as? String
|
||||
else {
|
||||
return nil
|
||||
static func listApplications(deviceIdentifier: String) async throws -> [AppleMobileDeviceApplicationSummary] {
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyConnectedDeviceApplicationList(deviceIdentifier, &error) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing failed."]
|
||||
)
|
||||
}
|
||||
|
||||
return AppleMobileDeviceApplicationSummary(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
displayName: displayName,
|
||||
fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]),
|
||||
supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
|
||||
)
|
||||
}
|
||||
}.value
|
||||
guard let rawApplications = response["applications"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice application listing returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return rawApplications.compactMap { application in
|
||||
guard
|
||||
let bundleIdentifier = application["bundleIdentifier"] as? String,
|
||||
let displayName = application["displayName"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AppleMobileDeviceApplicationSummary(
|
||||
bundleIdentifier: bundleIdentifier,
|
||||
displayName: displayName,
|
||||
fileSharingEnabled: flexibleBool(from: application["uiFileSharingEnabled"]),
|
||||
supportsOpeningDocumentsInPlace: flexibleBool(from: application["supportsOpeningDocumentsInPlace"])
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func listDirectory(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> [String] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 7,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
|
||||
)
|
||||
}
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyConnectedDeviceAppDirectoryListing(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 7,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice directory listing failed."]
|
||||
)
|
||||
}
|
||||
|
||||
return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
|
||||
}.value
|
||||
return (response["entries"] as? [String] ?? []).filter { $0 != "." && $0 != ".." }
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func fileData(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> Data {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let data = WMMCopyFirstConnectedDeviceAppFileData(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 8,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
|
||||
)
|
||||
}
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let data = WMMCopyConnectedDeviceAppFileData(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 8,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice file read failed."]
|
||||
)
|
||||
}
|
||||
|
||||
return data as Data
|
||||
}.value
|
||||
return data as Data
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func minecraftLibrarySnapshot(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> [AppleMobileMinecraftLibraryItemSummary] {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
|
||||
)
|
||||
}
|
||||
|
||||
guard let rawItems = response["items"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 6,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return rawItems.compactMap { item in
|
||||
guard
|
||||
let contentType = item["contentType"] as? String,
|
||||
let collectionFolderName = item["collectionFolderName"] as? String,
|
||||
let relativePath = item["relativePath"] as? String,
|
||||
let folderName = item["folderName"] as? String,
|
||||
let displayName = item["displayName"] as? String
|
||||
else {
|
||||
return nil
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 5,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan failed."]
|
||||
)
|
||||
}
|
||||
|
||||
return AppleMobileMinecraftLibraryItemSummary(
|
||||
contentType: contentType,
|
||||
collectionFolderName: collectionFolderName,
|
||||
relativePath: relativePath,
|
||||
folderName: folderName,
|
||||
displayName: displayName,
|
||||
packUUID: (item["packUUID"] as? String)?.lowercased(),
|
||||
packVersion: item["packVersion"] as? String,
|
||||
minimumEngineVersion: item["minimumEngineVersion"] as? String
|
||||
)
|
||||
}
|
||||
}.value
|
||||
guard let rawItems = response["items"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 6,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice Minecraft library scan returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return rawItems.compactMap { item in
|
||||
guard
|
||||
let contentType = item["contentType"] as? String,
|
||||
let collectionFolderName = item["collectionFolderName"] as? String,
|
||||
let relativePath = item["relativePath"] as? String,
|
||||
let folderName = item["folderName"] as? String,
|
||||
let displayName = item["displayName"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AppleMobileMinecraftLibraryItemSummary(
|
||||
contentType: contentType,
|
||||
collectionFolderName: collectionFolderName,
|
||||
relativePath: relativePath,
|
||||
folderName: folderName,
|
||||
displayName: displayName,
|
||||
hasIcon: flexibleBool(from: item["hasIcon"])
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func minecraftMetadataBatch(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
relativePath: String,
|
||||
items: [AppleMobileMinecraftLibraryItemSummary]
|
||||
) async throws -> [AppleMobileMinecraftItemMetadataSummary] {
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .userInitiated) {
|
||||
let requestItems = items.map { item in
|
||||
[
|
||||
"contentType": item.contentType,
|
||||
"relativePath": item.relativePath,
|
||||
"folderName": item.folderName
|
||||
]
|
||||
}
|
||||
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyConnectedDeviceMinecraftMetadataBatch(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
requestItems,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 10,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice metadata batch failed."]
|
||||
)
|
||||
}
|
||||
|
||||
guard let rawItems = response["items"] as? [[String: Any]] else {
|
||||
throw NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 11,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice metadata batch returned an unexpected payload."]
|
||||
)
|
||||
}
|
||||
|
||||
return rawItems.compactMap { item in
|
||||
guard let relativePath = item["relativePath"] as? String else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AppleMobileMinecraftItemMetadataSummary(
|
||||
relativePath: relativePath,
|
||||
displayName: item["displayName"] as? String,
|
||||
packUUID: (item["packUUID"] as? String)?.lowercased(),
|
||||
packVersion: item["packVersion"] as? String,
|
||||
minimumEngineVersion: item["minimumEngineVersion"] as? String,
|
||||
packReferences: (item["packReferences"] as? [[String: Any]] ?? []).compactMap { reference in
|
||||
guard
|
||||
let name = reference["name"] as? String,
|
||||
let contentType = reference["contentType"] as? String,
|
||||
let source = reference["source"] as? String
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return AppleMobilePackReferenceSummary(
|
||||
name: name,
|
||||
contentType: contentType,
|
||||
uuid: (reference["uuid"] as? String)?.lowercased(),
|
||||
version: reference["version"] as? String,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
static func pathMetrics(
|
||||
deviceIdentifier: String,
|
||||
bundleIdentifier: String,
|
||||
relativePath: String
|
||||
) async throws -> AppleMobileDevicePathMetrics {
|
||||
try await Task.detached(priority: .utility) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 9,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
|
||||
try await AppleMobileDeviceOperationLimiter.shared.run(for: deviceIdentifier) {
|
||||
try await Task.detached(priority: .utility) {
|
||||
var error: NSError?
|
||||
guard let response = WMMCopyConnectedDeviceAppPathMetrics(
|
||||
deviceIdentifier,
|
||||
bundleIdentifier,
|
||||
relativePath,
|
||||
&error
|
||||
) else {
|
||||
throw error ?? NSError(
|
||||
domain: "AppleMobileDeviceAccess",
|
||||
code: 9,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The MobileDevice path metrics lookup failed."]
|
||||
)
|
||||
}
|
||||
|
||||
let rawSize = response["sizeBytes"]
|
||||
let sizeBytes: Int64?
|
||||
switch rawSize {
|
||||
case let number as NSNumber:
|
||||
sizeBytes = number.int64Value
|
||||
case let value as Int64:
|
||||
sizeBytes = value
|
||||
case let value as Int:
|
||||
sizeBytes = Int64(value)
|
||||
default:
|
||||
sizeBytes = nil
|
||||
}
|
||||
|
||||
return AppleMobileDevicePathMetrics(
|
||||
sizeBytes: sizeBytes,
|
||||
modifiedDate: response["modifiedDate"] as? Date
|
||||
)
|
||||
}
|
||||
|
||||
let rawSize = response["sizeBytes"]
|
||||
let sizeBytes: Int64?
|
||||
switch rawSize {
|
||||
case let number as NSNumber:
|
||||
sizeBytes = number.int64Value
|
||||
case let value as Int64:
|
||||
sizeBytes = value
|
||||
case let value as Int:
|
||||
sizeBytes = Int64(value)
|
||||
default:
|
||||
sizeBytes = nil
|
||||
}
|
||||
|
||||
return AppleMobileDevicePathMetrics(
|
||||
sizeBytes: sizeBytes,
|
||||
modifiedDate: response["modifiedDate"] as? Date
|
||||
)
|
||||
}.value
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
||||
private static func flexibleBool(from value: Any?) -> Bool {
|
||||
nonisolated private static func flexibleBool(from value: Any?) -> Bool {
|
||||
switch value {
|
||||
case let value as Bool:
|
||||
return value
|
||||
|
||||
@ -12,54 +12,73 @@ NS_ASSUME_NONNULL_BEGIN
|
||||
FOUNDATION_EXPORT NSErrorDomain const WMMMobileDeviceErrorDomain;
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceSummary(NSError **error);
|
||||
WMMCopyConnectedDeviceSummaries(NSError **error);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceApplicationList(NSError **error);
|
||||
WMMCopyConnectedDeviceApplicationList(
|
||||
NSString *deviceIdentifier,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
WMMCopyConnectedDeviceApplicationDetails(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
WMMCopyConnectedDeviceAppDirectoryListing(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
WMMCopyConnectedDeviceAppPathProbeResults(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSArray<NSString *> *paths,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSData * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppFileData(
|
||||
WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT NSDictionary<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 *relativePath,
|
||||
NSError **error
|
||||
);
|
||||
|
||||
FOUNDATION_EXPORT BOOL
|
||||
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSURL *destinationDirectoryURL,
|
||||
|
||||
@ -152,8 +152,11 @@ typedef struct {
|
||||
typedef struct {
|
||||
WMMMobileDeviceFunctions *functions;
|
||||
CFRunLoopRef runLoop;
|
||||
AMDeviceRef device;
|
||||
} WMMDeviceWaitContext;
|
||||
NSMutableArray<NSValue *> *devices;
|
||||
} WMMDeviceCollectionContext;
|
||||
|
||||
static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDeviceRef device, CFStringRef key);
|
||||
static NSInteger WMMConnectionPreferenceRank(AMDeviceRef device);
|
||||
|
||||
static NSError *WMMMakeError(NSInteger code, NSString *description) {
|
||||
return [NSError errorWithDomain:WMMMobileDeviceErrorDomain code:code userInfo:@{
|
||||
@ -273,20 +276,16 @@ static void WMMDeviceNotificationCallback(struct am_device_notification_callback
|
||||
return;
|
||||
}
|
||||
|
||||
WMMDeviceWaitContext *context = contextPointer;
|
||||
if (context->device == NULL) {
|
||||
context->device = context->functions->AMDeviceRetain(info->dev);
|
||||
if (context->runLoop != NULL) {
|
||||
CFRunLoopStop(context->runLoop);
|
||||
}
|
||||
}
|
||||
WMMDeviceCollectionContext *context = contextPointer;
|
||||
AMDeviceRef retainedDevice = context->functions->AMDeviceRetain(info->dev);
|
||||
[context->devices addObject:[NSValue valueWithPointer:retainedDevice]];
|
||||
}
|
||||
|
||||
static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functions, NSError **error) {
|
||||
WMMDeviceWaitContext context = {
|
||||
static NSArray<NSValue *> *WMMCopyConnectedDevices(WMMMobileDeviceFunctions *functions, NSError **error) {
|
||||
WMMDeviceCollectionContext context = {
|
||||
.functions = functions,
|
||||
.runLoop = CFRunLoopGetCurrent(),
|
||||
.device = NULL
|
||||
.devices = [NSMutableArray array]
|
||||
};
|
||||
|
||||
AMDeviceNotificationRef subscription = NULL;
|
||||
@ -307,11 +306,78 @@ static AMDeviceRef WMMCopyFirstConnectedDevice(WMMMobileDeviceFunctions *functio
|
||||
CFRunLoopRunInMode(kCFRunLoopDefaultMode, 0.2, false);
|
||||
functions->AMDeviceNotificationUnsubscribe(subscription);
|
||||
|
||||
if (context.device == NULL && error != NULL) {
|
||||
if (context.devices.count == 0 && error != NULL) {
|
||||
*error = WMMMakeError(3, @"No connected iPhone or iPad was detected through MobileDevice.framework.");
|
||||
}
|
||||
|
||||
return context.device;
|
||||
return [context.devices copy];
|
||||
}
|
||||
|
||||
static NSString *WMMResolvedDeviceIdentifier(WMMMobileDeviceFunctions *functions, AMDeviceRef device) {
|
||||
if (functions->AMDeviceCopyDeviceIdentifier != NULL) {
|
||||
CFStringRef copiedIdentifier = functions->AMDeviceCopyDeviceIdentifier(device);
|
||||
if (copiedIdentifier != NULL) {
|
||||
return CFBridgingRelease(copiedIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
return WMMDeviceStringValue(functions, device, CFSTR("UniqueDeviceID")) ?:
|
||||
WMMDeviceStringValue(functions, device, CFSTR("SerialNumber")) ?:
|
||||
@"";
|
||||
}
|
||||
|
||||
static void WMMReleaseDeviceValues(WMMMobileDeviceFunctions *functions, NSArray<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) {
|
||||
@ -332,6 +398,66 @@ static NSString *WMMDeviceStringValue(WMMMobileDeviceFunctions *functions, AMDev
|
||||
return CFBridgingRelease(value);
|
||||
}
|
||||
|
||||
static NSString *WMMInferredConnectionType(AMDeviceRef device) {
|
||||
if (device == NULL) {
|
||||
return @"USB";
|
||||
}
|
||||
|
||||
NSString *deviceDescription = [(__bridge id)device description];
|
||||
if (deviceDescription.length == 0) {
|
||||
return @"USB";
|
||||
}
|
||||
|
||||
if ([deviceDescription containsString:@"FullServiceName = "]) {
|
||||
return @"Network";
|
||||
}
|
||||
|
||||
if ([deviceDescription containsString:@"location ID = "]) {
|
||||
return @"USB";
|
||||
}
|
||||
|
||||
return @"USB";
|
||||
}
|
||||
|
||||
static NSInteger WMMConnectionPreferenceRank(AMDeviceRef device) {
|
||||
NSString *connectionType = WMMInferredConnectionType(device);
|
||||
if ([connectionType caseInsensitiveCompare:@"USB"] == NSOrderedSame) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
if ([connectionType caseInsensitiveCompare:@"Network"] == NSOrderedSame) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void WMMLogDeviceTransportDiagnostics(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AMDeviceRef device,
|
||||
NSString *resolvedIdentifier
|
||||
) {
|
||||
NSArray<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(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AMDeviceRef device,
|
||||
@ -391,6 +517,26 @@ static void WMMDisconnectDevice(
|
||||
functions->AMDeviceDisconnect(device);
|
||||
}
|
||||
|
||||
static void WMMCloseVendSession(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AMDeviceRef _Nullable device,
|
||||
BOOL hadSession,
|
||||
AFCConnectionRef _Nullable afcConnection,
|
||||
AMDServiceConnectionRef _Nullable backingServiceConnection
|
||||
) {
|
||||
if (afcConnection != NULL) {
|
||||
functions->AFCConnectionClose(afcConnection);
|
||||
}
|
||||
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions->AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
|
||||
if (device != NULL) {
|
||||
WMMDisconnectDevice(functions, device, hadSession);
|
||||
}
|
||||
}
|
||||
|
||||
static AFCConnectionRef _Nullable WMMCreateAFCConnectionFromServiceConnection(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AMDServiceConnectionRef serviceConnection
|
||||
@ -1001,6 +1147,115 @@ static NSString * _Nullable WMMVersionStringFromValue(id value) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
static NSDictionary<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) {
|
||||
if ([contentType isEqualToString:@"World"]) {
|
||||
return WMMEntryArrayContainsName(entries, @"level.dat")
|
||||
@ -1014,12 +1269,9 @@ static BOOL WMMIsCandidateItem(NSString *contentType, NSArray<NSString *> *entri
|
||||
|| WMMEntryArrayContainsName(entries, @"pack_icon.jpg");
|
||||
}
|
||||
|
||||
static NSDictionary<NSString *, id> *WMMBuildMinecraftItemSummary(
|
||||
WMMMobileDeviceFunctions *functions,
|
||||
AFCConnectionRef afcConnection,
|
||||
static NSDictionary<NSString *, id> *WMMBuildShallowMinecraftItemSummary(
|
||||
NSString *contentType,
|
||||
NSString *collectionFolderName,
|
||||
NSString *itemRemotePath,
|
||||
NSString *itemRelativePath,
|
||||
NSString *folderName,
|
||||
NSArray<NSString *> *entries
|
||||
@ -1028,44 +1280,9 @@ static NSDictionary<NSString *, id> *WMMBuildMinecraftItemSummary(
|
||||
@"contentType": contentType,
|
||||
@"collectionFolderName": collectionFolderName,
|
||||
@"relativePath": itemRelativePath,
|
||||
@"folderName": folderName
|
||||
@"folderName": folderName,
|
||||
@"displayName": folderName
|
||||
} mutableCopy];
|
||||
|
||||
NSString *displayName = folderName;
|
||||
if ([contentType isEqualToString:@"World"]) {
|
||||
NSString *levelName = WMMReadUTF8TextFile(
|
||||
functions,
|
||||
afcConnection,
|
||||
[itemRemotePath stringByAppendingPathComponent:@"levelname.txt"]
|
||||
);
|
||||
if (levelName.length > 0) {
|
||||
displayName = levelName;
|
||||
}
|
||||
} else {
|
||||
NSDictionary<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"] = @(
|
||||
WMMEntryArrayContainsName(entries, @"world_icon.png")
|
||||
|| WMMEntryArrayContainsName(entries, @"world_icon.jpeg")
|
||||
@ -1107,12 +1324,9 @@ static void WMMAppendCollectionSummaries(
|
||||
}
|
||||
|
||||
NSString *itemRelativePath = [collectionFolderName stringByAppendingPathComponent:itemFolderName];
|
||||
[results addObject:WMMBuildMinecraftItemSummary(
|
||||
functions,
|
||||
afcConnection,
|
||||
[results addObject:WMMBuildShallowMinecraftItemSummary(
|
||||
contentType,
|
||||
collectionFolderName,
|
||||
itemRemotePath,
|
||||
itemRelativePath,
|
||||
itemFolderName,
|
||||
itemEntries
|
||||
@ -1152,12 +1366,9 @@ static void WMMAppendCollectionSummaries(
|
||||
}
|
||||
|
||||
NSString *embeddedRelativePath = [itemRelativePath stringByAppendingPathComponent:[embeddedFolder stringByAppendingPathComponent:embeddedFolderName]];
|
||||
[results addObject:WMMBuildMinecraftItemSummary(
|
||||
functions,
|
||||
afcConnection,
|
||||
[results addObject:WMMBuildShallowMinecraftItemSummary(
|
||||
embeddedType,
|
||||
embeddedFolder,
|
||||
embeddedItemPath,
|
||||
embeddedRelativePath,
|
||||
embeddedFolderName,
|
||||
embeddedEntries
|
||||
@ -1168,47 +1379,78 @@ static void WMMAppendCollectionSummaries(
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceSummary(NSError **error) {
|
||||
WMMCopyConnectedDeviceSummaries(NSError **error) {
|
||||
WMMMobileDeviceFunctions functions;
|
||||
if (!WMMLoadFunctions(&functions, error)) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
if (device == NULL) {
|
||||
NSArray<NSValue *> *devices = WMMCopyConnectedDevices(&functions, error);
|
||||
if (devices.count == 0) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
if (!WMMConnectAndValidateDevice(&functions, device, NO, error)) {
|
||||
functions.AMDeviceRelease(device);
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSString *deviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
|
||||
if (deviceIdentifier.length == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSValue *existingValue = preferredDevicesByIdentifier[deviceIdentifier];
|
||||
AMDeviceRef existingDevice = existingValue != nil ? (AMDeviceRef)existingValue.pointerValue : NULL;
|
||||
if (existingDevice != NULL && WMMConnectionPreferenceRank(device) <= WMMConnectionPreferenceRank(existingDevice)) {
|
||||
continue;
|
||||
}
|
||||
preferredDevicesByIdentifier[deviceIdentifier] = value;
|
||||
}
|
||||
|
||||
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
|
||||
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
|
||||
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
|
||||
NSString *deviceIdentifier =
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
|
||||
@"";
|
||||
for (NSString *deviceIdentifier in preferredDevicesByIdentifier) {
|
||||
AMDeviceRef device = (AMDeviceRef)preferredDevicesByIdentifier[deviceIdentifier].pointerValue;
|
||||
if (device == NULL) {
|
||||
continue;
|
||||
}
|
||||
NSString *deviceName = @"Unknown Device";
|
||||
NSString *productType = @"";
|
||||
NSString *productVersion = @"";
|
||||
NSString *connectionType = WMMDeviceStringValue(&functions, device, CFSTR("ConnectionType")) ?: WMMInferredConnectionType(device);
|
||||
if (WMMConnectAndValidateDevice(&functions, device, NO, NULL)) {
|
||||
deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
|
||||
productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
|
||||
productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
|
||||
connectionType = WMMDeviceStringValue(&functions, device, CFSTR("ConnectionType")) ?: WMMInferredConnectionType(device);
|
||||
WMMLogDeviceTransportDiagnostics(&functions, device, deviceIdentifier);
|
||||
WMMDisconnectDevice(&functions, device, NO);
|
||||
}
|
||||
|
||||
NSString *trustState = @"trusted";
|
||||
[summaries addObject:@{
|
||||
@"deviceName": deviceName,
|
||||
@"deviceIdentifier": deviceIdentifier,
|
||||
@"productType": productType,
|
||||
@"productVersion": productVersion,
|
||||
@"connectionType": connectionType,
|
||||
@"trustState": @"trusted"
|
||||
}];
|
||||
}
|
||||
|
||||
WMMDisconnectDevice(&functions, device, NO);
|
||||
WMMReleaseDeviceValues(&functions, devices);
|
||||
|
||||
functions.AMDeviceRelease(device);
|
||||
[summaries sortUsingComparator:^NSComparisonResult(NSDictionary<NSString *, id> *lhs, NSDictionary<NSString *, id> *rhs) {
|
||||
return [lhs[@"deviceName"] localizedStandardCompare:rhs[@"deviceName"]];
|
||||
}];
|
||||
|
||||
return @{
|
||||
@"deviceName": deviceName,
|
||||
@"deviceIdentifier": deviceIdentifier,
|
||||
@"productType": productType,
|
||||
@"productVersion": productVersion,
|
||||
@"trustState": trustState
|
||||
};
|
||||
return @{ @"devices": summaries };
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
WMMCopyConnectedDeviceAppDirectoryListing(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
@ -1225,7 +1467,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1238,10 +1480,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
|
||||
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
|
||||
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
|
||||
NSString *deviceIdentifier =
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
|
||||
@"";
|
||||
NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
|
||||
|
||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
||||
@ -1252,7 +1491,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
@ -1268,11 +1507,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
NSMutableArray<NSString *> *rootEntries = nil;
|
||||
const int rootStatus = WMMReadAFCDirectory(&functions, afcConnection, @"/", &rootEntries);
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
if (error != NULL) {
|
||||
NSString *message = [NSString stringWithFormat:@"AFC directory read failed for %@ (%d).", normalizedPath, directoryStatus];
|
||||
@ -1286,11 +1521,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
}
|
||||
return nil;
|
||||
}
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
@ -1298,7 +1529,7 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
@"bundleIdentifier": bundleIdentifier,
|
||||
@"path": normalizedPath,
|
||||
@"deviceName": deviceName,
|
||||
@"deviceIdentifier": deviceIdentifier,
|
||||
@"deviceIdentifier": resolvedDeviceIdentifier,
|
||||
@"productType": productType,
|
||||
@"productVersion": productVersion,
|
||||
@"entries": entries
|
||||
@ -1306,7 +1537,8 @@ WMMCopyFirstConnectedDeviceAppDirectoryListing(
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
WMMCopyConnectedDeviceAppPathProbeResults(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSArray<NSString *> *paths,
|
||||
NSError **error
|
||||
@ -1323,7 +1555,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1336,10 +1568,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
|
||||
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
|
||||
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
|
||||
NSString *deviceIdentifier =
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
|
||||
@"";
|
||||
NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
|
||||
|
||||
AMDServiceConnectionRef backingServiceConnection = NULL;
|
||||
AFCConnectionRef afcConnection = WMMCreateVendAFCConnection(
|
||||
@ -1350,7 +1579,7 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
@ -1376,17 +1605,13 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
[results addObject:result];
|
||||
}
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
return @{
|
||||
@"bundleIdentifier": bundleIdentifier,
|
||||
@"deviceName": deviceName,
|
||||
@"deviceIdentifier": deviceIdentifier,
|
||||
@"deviceIdentifier": resolvedDeviceIdentifier,
|
||||
@"productType": productType,
|
||||
@"productVersion": productVersion,
|
||||
@"results": results
|
||||
@ -1394,13 +1619,16 @@ WMMCopyFirstConnectedDeviceAppPathProbeResults(
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
|
||||
WMMCopyConnectedDeviceApplicationList(
|
||||
NSString *deviceIdentifier,
|
||||
NSError **error
|
||||
) {
|
||||
WMMMobileDeviceFunctions functions;
|
||||
if (!WMMLoadFunctions(&functions, error)) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1413,10 +1641,7 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
|
||||
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
|
||||
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
|
||||
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
|
||||
NSString *deviceIdentifier =
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
|
||||
@"";
|
||||
NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
|
||||
|
||||
CFDictionaryRef appDictionary = NULL;
|
||||
const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary);
|
||||
@ -1484,7 +1709,7 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
|
||||
|
||||
return @{
|
||||
@"deviceName": deviceName,
|
||||
@"deviceIdentifier": deviceIdentifier,
|
||||
@"deviceIdentifier": resolvedDeviceIdentifier,
|
||||
@"productType": productType,
|
||||
@"productVersion": productVersion,
|
||||
@"applications": applications
|
||||
@ -1492,7 +1717,8 @@ WMMCopyFirstConnectedDeviceApplicationList(NSError **error) {
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
WMMCopyConnectedDeviceMinecraftLibrarySnapshot(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
@ -1509,7 +1735,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1528,7 +1754,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
@ -1554,11 +1780,7 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
);
|
||||
}
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
return @{
|
||||
@ -1568,8 +1790,140 @@ WMMCopyFirstConnectedDeviceMinecraftLibrarySnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
NSDictionary<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
|
||||
WMMCopyFirstConnectedDeviceAppFileData(
|
||||
WMMCopyConnectedDeviceAppFileData(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
@ -1586,7 +1940,7 @@ WMMCopyFirstConnectedDeviceAppFileData(
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1605,7 +1959,7 @@ WMMCopyFirstConnectedDeviceAppFileData(
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
@ -1613,18 +1967,15 @@ WMMCopyFirstConnectedDeviceAppFileData(
|
||||
NSString *normalizedPath = WMMNormalizedAFCPath(relativePath);
|
||||
NSData *data = WMMCopyAFCFileData(&functions, afcConnection, normalizedPath, error);
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
WMMCopyConnectedDeviceAppPathMetrics(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSError **error
|
||||
@ -1641,7 +1992,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1660,7 +2011,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return nil;
|
||||
}
|
||||
@ -1673,11 +2024,7 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
error
|
||||
);
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
if (metrics == nil) {
|
||||
@ -1693,7 +2040,8 @@ WMMCopyFirstConnectedDeviceAppPathMetrics(
|
||||
}
|
||||
|
||||
NSDictionary<NSString *, id> * _Nullable
|
||||
WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
WMMCopyConnectedDeviceApplicationDetails(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSError **error
|
||||
) {
|
||||
@ -1709,7 +2057,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
return nil;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return nil;
|
||||
}
|
||||
@ -1722,10 +2070,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
NSString *deviceName = WMMDeviceStringValue(&functions, device, CFSTR("DeviceName")) ?: @"Unknown Device";
|
||||
NSString *productType = WMMDeviceStringValue(&functions, device, CFSTR("ProductType")) ?: @"";
|
||||
NSString *productVersion = WMMDeviceStringValue(&functions, device, CFSTR("ProductVersion")) ?: @"";
|
||||
NSString *deviceIdentifier =
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("UniqueDeviceID")) ?:
|
||||
WMMDeviceStringValue(&functions, device, CFSTR("SerialNumber")) ?:
|
||||
@"";
|
||||
NSString *resolvedDeviceIdentifier = WMMResolvedDeviceIdentifier(&functions, device);
|
||||
|
||||
CFDictionaryRef appDictionary = NULL;
|
||||
const int lookupStatus = functions.AMDeviceLookupApplications(device, NULL, &appDictionary);
|
||||
@ -1774,7 +2119,7 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
|
||||
return @{
|
||||
@"deviceName": deviceName,
|
||||
@"deviceIdentifier": deviceIdentifier,
|
||||
@"deviceIdentifier": resolvedDeviceIdentifier,
|
||||
@"productType": productType,
|
||||
@"productVersion": productVersion,
|
||||
@"bundleIdentifier": bundleIdentifier,
|
||||
@ -1783,7 +2128,8 @@ WMMCopyFirstConnectedDeviceApplicationDetails(
|
||||
}
|
||||
|
||||
BOOL
|
||||
WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
WMMCopyConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
NSString *deviceIdentifier,
|
||||
NSString *bundleIdentifier,
|
||||
NSString *relativePath,
|
||||
NSURL *destinationDirectoryURL,
|
||||
@ -1808,7 +2154,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
return NO;
|
||||
}
|
||||
|
||||
AMDeviceRef device = WMMCopyFirstConnectedDevice(&functions, error);
|
||||
AMDeviceRef device = WMMCopyConnectedDevice(&functions, deviceIdentifier, error);
|
||||
if (device == NULL) {
|
||||
return NO;
|
||||
}
|
||||
@ -1827,7 +2173,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
error
|
||||
);
|
||||
if (afcConnection == NULL) {
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, NULL, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
return NO;
|
||||
}
|
||||
@ -1848,11 +2194,7 @@ WMMCopyFirstConnectedDeviceAppSubtreeToLocalDirectory(
|
||||
error
|
||||
);
|
||||
|
||||
functions.AFCConnectionClose(afcConnection);
|
||||
if (backingServiceConnection != NULL) {
|
||||
functions.AMDServiceConnectionInvalidate(backingServiceConnection);
|
||||
}
|
||||
WMMDisconnectDevice(&functions, device, YES);
|
||||
WMMCloseVendSession(&functions, device, YES, afcConnection, backingServiceConnection);
|
||||
functions.AMDeviceRelease(device);
|
||||
|
||||
if (!success) {
|
||||
|
||||
@ -47,21 +47,29 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
}
|
||||
|
||||
nonisolated func listConnectedDevices() async throws -> [ConnectedDevice] {
|
||||
let device = try await AppleMobileDeviceAccess.firstConnectedDevice()
|
||||
return [
|
||||
ConnectedDevice(
|
||||
let devices = try await AppleMobileDeviceAccess.connectedDevices()
|
||||
return devices.compactMap { device in
|
||||
let connection: DeviceConnection
|
||||
switch device.connectionType.lowercased() {
|
||||
case "network", "wifi", "wi-fi":
|
||||
connection = .network
|
||||
default:
|
||||
connection = .usb
|
||||
}
|
||||
|
||||
return ConnectedDevice(
|
||||
udid: device.deviceIdentifier,
|
||||
name: device.deviceName,
|
||||
productType: device.productType.isEmpty ? nil : device.productType,
|
||||
osVersion: device.productVersion.isEmpty ? nil : device.productVersion,
|
||||
connection: .usb,
|
||||
connection: connection,
|
||||
trustState: device.trustState
|
||||
)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func listAccessibleContainers(for device: ConnectedDevice) async throws -> [DeviceAppContainer] {
|
||||
let applications = try await AppleMobileDeviceAccess.listApplications()
|
||||
let applications = try await AppleMobileDeviceAccess.listApplications(deviceIdentifier: device.udid)
|
||||
|
||||
return applications
|
||||
.filter { application in
|
||||
@ -109,12 +117,23 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
}
|
||||
|
||||
let summaries = try await AppleMobileDeviceAccess.minecraftLibrarySnapshot(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: requestedSubpath
|
||||
)
|
||||
|
||||
let metadataByPath = try await metadataByRelativePath(
|
||||
for: summaries,
|
||||
container: container,
|
||||
requestedSubpath: requestedSubpath
|
||||
)
|
||||
|
||||
let items = summaries.compactMap { summary in
|
||||
makeItem(from: summary, source: source)
|
||||
makeItem(
|
||||
from: summary,
|
||||
metadata: metadataByPath[summary.relativePath],
|
||||
source: source
|
||||
)
|
||||
}
|
||||
|
||||
for item in items {
|
||||
@ -126,35 +145,39 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem {
|
||||
var enrichedItem = item
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
guard case .connectedDevice = source.origin else {
|
||||
enrichedItem.metadataLoaded = true
|
||||
enrichedItem.previewLoaded = true
|
||||
return enrichedItem
|
||||
}
|
||||
|
||||
enrichedItem.iconURL = await loadRemoteIcon(for: item, source: source, container: container)
|
||||
enrichedItem.modifiedDate = nil
|
||||
|
||||
if item.contentType == .world {
|
||||
if let levelDatPath = remoteItemPath(for: item, in: source, appending: "level.dat"),
|
||||
let levelDatData = try? await AppleMobileDeviceAccess.fileData(
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: levelDatPath
|
||||
) {
|
||||
enrichedItem.worldMetadata = BedrockLevelMetadataDecoder.decode(fromLevelDatData: levelDatData)
|
||||
enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
|
||||
}
|
||||
|
||||
enrichedItem.packReferences = await loadWorldPackReferences(for: item, source: source, container: container)
|
||||
} else {
|
||||
enrichedItem.lastPlayedDate = nil
|
||||
enrichedItem.packReferences = []
|
||||
}
|
||||
|
||||
enrichedItem.lastPlayedDate = enrichedItem.worldMetadata?.lastPlayedDate
|
||||
enrichedItem.metadataLoaded = true
|
||||
enrichedItem.previewLoaded = !enrichedItem.hasKnownIcon
|
||||
enrichedItem.sizeLoaded = false
|
||||
return enrichedItem
|
||||
}
|
||||
|
||||
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
var previewItem = item
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
previewItem.previewLoaded = true
|
||||
return previewItem
|
||||
}
|
||||
|
||||
if previewItem.hasKnownIcon {
|
||||
previewItem.iconURL = await loadRemoteIcon(
|
||||
for: previewItem,
|
||||
source: source,
|
||||
container: container
|
||||
)
|
||||
}
|
||||
|
||||
previewItem.previewLoaded = true
|
||||
return previewItem
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
var sizedItem = item
|
||||
guard case .connectedDevice(_, let container) = source.origin else {
|
||||
@ -164,6 +187,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
|
||||
if let remoteItemPath = remoteItemPath(for: item, in: source),
|
||||
let metrics = try? await AppleMobileDeviceAccess.pathMetrics(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteItemPath
|
||||
) {
|
||||
@ -187,6 +211,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
}
|
||||
|
||||
let entries = try await AppleMobileDeviceAccess.listDirectory(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteFolderPath
|
||||
)
|
||||
@ -221,6 +246,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
try FileManager.default.createDirectory(at: destinationURL, withIntermediateDirectories: true)
|
||||
do {
|
||||
try await AppleMobileDeviceAccess.mirrorSubtree(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteItemPath,
|
||||
destinationDirectoryURL: destinationURL
|
||||
@ -242,6 +268,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
|
||||
nonisolated private func makeItem(
|
||||
from summary: AppleMobileMinecraftLibraryItemSummary,
|
||||
metadata: AppleMobileMinecraftItemMetadataSummary?,
|
||||
source: MinecraftSource
|
||||
) -> MinecraftContentItem? {
|
||||
let contentType: MinecraftContentType
|
||||
@ -262,21 +289,92 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
|
||||
let collectionRootURL = source.folderURL.appendingPathComponent(summary.collectionFolderName, isDirectory: true)
|
||||
let folderURL = source.folderURL.appendingPathComponent(summary.relativePath, isDirectory: true)
|
||||
let displayName = metadata?.displayName ?? summary.displayName
|
||||
let packMetadataDetails: PackMetadataDetails?
|
||||
if let minimumEngineVersion = metadata?.minimumEngineVersion {
|
||||
packMetadataDetails = PackMetadataDetails(minimumEngineVersion: minimumEngineVersion)
|
||||
} else {
|
||||
packMetadataDetails = nil
|
||||
}
|
||||
|
||||
return MinecraftContentItem(
|
||||
folderURL: folderURL,
|
||||
folderName: summary.folderName,
|
||||
contentType: contentType,
|
||||
collectionRootURL: collectionRootURL,
|
||||
displayName: summary.displayName,
|
||||
displayName: displayName,
|
||||
iconURL: nil,
|
||||
packUUID: summary.packUUID,
|
||||
packVersion: summary.packVersion,
|
||||
packMetadataDetails: PackMetadataDetails(minimumEngineVersion: summary.minimumEngineVersion),
|
||||
hasKnownIcon: summary.hasIcon,
|
||||
packUUID: metadata?.packUUID,
|
||||
packVersion: metadata?.packVersion,
|
||||
packMetadataDetails: packMetadataDetails,
|
||||
packReferences: packReferences(from: metadata?.packReferences ?? []),
|
||||
metadataLoaded: false,
|
||||
previewLoaded: !summary.hasIcon,
|
||||
sizeLoaded: false
|
||||
)
|
||||
}
|
||||
|
||||
nonisolated private func metadataByRelativePath(
|
||||
for summaries: [AppleMobileMinecraftLibraryItemSummary],
|
||||
container: DeviceAppContainer,
|
||||
requestedSubpath: String
|
||||
) async throws -> [String: AppleMobileMinecraftItemMetadataSummary] {
|
||||
guard !summaries.isEmpty else {
|
||||
return [:]
|
||||
}
|
||||
|
||||
let metadata = try await AppleMobileDeviceAccess.minecraftMetadataBatch(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: requestedSubpath,
|
||||
items: summaries
|
||||
)
|
||||
|
||||
return Dictionary(uniqueKeysWithValues: metadata.map { ($0.relativePath, $0) })
|
||||
}
|
||||
|
||||
nonisolated private func packReferences(
|
||||
from summaries: [AppleMobilePackReferenceSummary]
|
||||
) -> [ContentPackReference] {
|
||||
let references = summaries.compactMap { summary -> ContentPackReference? in
|
||||
let contentType: MinecraftContentType
|
||||
switch summary.contentType {
|
||||
case MinecraftContentType.behaviorPack.rawValue:
|
||||
contentType = .behaviorPack
|
||||
case MinecraftContentType.resourcePack.rawValue:
|
||||
contentType = .resourcePack
|
||||
case MinecraftContentType.skinPack.rawValue:
|
||||
contentType = .skinPack
|
||||
case MinecraftContentType.worldTemplate.rawValue:
|
||||
contentType = .worldTemplate
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
let source: PackSource
|
||||
switch summary.source {
|
||||
case PackSource.embeddedInWorld.rawValue:
|
||||
source = .embeddedInWorld
|
||||
case PackSource.foundInCollection.rawValue:
|
||||
source = .foundInCollection
|
||||
default:
|
||||
source = .referencedByWorld
|
||||
}
|
||||
|
||||
return ContentPackReference(
|
||||
name: summary.name,
|
||||
type: contentType,
|
||||
iconURL: nil,
|
||||
uuid: summary.uuid,
|
||||
version: summary.version,
|
||||
source: source
|
||||
)
|
||||
}
|
||||
|
||||
return uniquePackReferences(references)
|
||||
}
|
||||
|
||||
nonisolated private func remoteItemPath(
|
||||
for item: MinecraftContentItem,
|
||||
in source: MinecraftSource,
|
||||
@ -326,6 +424,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
continue
|
||||
}
|
||||
guard let data = try? await AppleMobileDeviceAccess.fileData(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remotePath
|
||||
) else {
|
||||
@ -358,6 +457,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
|
||||
if let behaviorRefPath = remoteItemPath(for: item, in: source, appending: "world_behavior_packs.json"),
|
||||
let behaviorData = try? await AppleMobileDeviceAccess.fileData(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: behaviorRefPath
|
||||
) {
|
||||
@ -366,6 +466,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
|
||||
if let resourceRefPath = remoteItemPath(for: item, in: source, appending: "world_resource_packs.json"),
|
||||
let resourceData = try? await AppleMobileDeviceAccess.fileData(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: resourceRefPath
|
||||
) {
|
||||
@ -402,6 +503,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
}
|
||||
|
||||
guard let childFolders = try? await AppleMobileDeviceAccess.listDirectory(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: remoteFolderPath
|
||||
) else {
|
||||
@ -413,6 +515,7 @@ struct AppleMobileDeviceSourceAccess: ConnectedDeviceSourceAccessMethod {
|
||||
let childFolderPath = NSString(string: remoteFolderPath).appendingPathComponent(childFolder)
|
||||
let manifestPath = NSString(string: childFolderPath).appendingPathComponent("manifest.json")
|
||||
guard let manifestData = try? await AppleMobileDeviceAccess.fileData(
|
||||
deviceIdentifier: container.deviceUDID,
|
||||
bundleIdentifier: container.appID,
|
||||
relativePath: manifestPath
|
||||
) else {
|
||||
|
||||
@ -191,18 +191,28 @@ struct ConnectedDeviceSourcePickerView: View {
|
||||
isLoadingDevices = true
|
||||
availabilityMessage = nil
|
||||
errorMessage = nil
|
||||
let previousSelectedDeviceID = selectedDeviceID
|
||||
|
||||
do {
|
||||
let devices = try await deviceDiscoveryService.listConnectedDevices()
|
||||
let resolvedSelectedDeviceID = devices.contains(where: { $0.id == previousSelectedDeviceID })
|
||||
? previousSelectedDeviceID
|
||||
: devices.first?.id
|
||||
let shouldReloadContainers = resolvedSelectedDeviceID != nil && resolvedSelectedDeviceID == previousSelectedDeviceID
|
||||
|
||||
await MainActor.run {
|
||||
self.devices = devices
|
||||
self.selectedDeviceID = devices.first?.id
|
||||
self.selectedDeviceID = resolvedSelectedDeviceID
|
||||
if devices.isEmpty {
|
||||
self.containers = []
|
||||
self.selectedContainerID = nil
|
||||
}
|
||||
self.isLoadingDevices = false
|
||||
}
|
||||
|
||||
if shouldReloadContainers {
|
||||
await loadContainersForSelectedDevice()
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.devices = []
|
||||
|
||||
@ -16,6 +16,7 @@ protocol SourceAccessMethod: Sendable {
|
||||
onDiscovered: @escaping @Sendable (MinecraftContentItem) -> Void
|
||||
) async throws -> [MinecraftContentItem]
|
||||
nonisolated func enrich(_ item: MinecraftContentItem, for source: MinecraftSource) async -> MinecraftContentItem
|
||||
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem
|
||||
nonisolated func listItemContents(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> [DirectoryPreviewEntry]
|
||||
nonisolated func materializeItem(for item: MinecraftContentItem, in source: MinecraftSource) async throws -> URL
|
||||
@ -55,6 +56,11 @@ extension SourceAccessMethod {
|
||||
return item
|
||||
}
|
||||
|
||||
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
_ = source
|
||||
return item
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
_ = source
|
||||
return item
|
||||
@ -134,6 +140,10 @@ struct SourceAccessCoordinator: SourceAccessMethod {
|
||||
return await accessMethod(for: source).enrich(item, for: source)
|
||||
}
|
||||
|
||||
nonisolated func loadPreviewAssets(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
return await accessMethod(for: source).loadPreviewAssets(for: item, in: source)
|
||||
}
|
||||
|
||||
nonisolated func loadSize(for item: MinecraftContentItem, in source: MinecraftSource) async -> MinecraftContentItem {
|
||||
return await accessMethod(for: source).loadSize(for: item, in: source)
|
||||
}
|
||||
|
||||
@ -5,10 +5,19 @@
|
||||
// Created by John Burwell on 2026-05-25.
|
||||
//
|
||||
|
||||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct World_Manager_for_MinecraftApp: App {
|
||||
@NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate
|
||||
|
||||
init() {
|
||||
Task {
|
||||
await ScanNotificationService.shared.requestAuthorizationIfNeeded()
|
||||
}
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
@ -21,6 +30,41 @@ struct World_Manager_for_MinecraftApp: App {
|
||||
}
|
||||
}
|
||||
|
||||
final class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
AppTerminationCoordinator.shared.beginTermination(for: sender)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class AppTerminationCoordinator {
|
||||
static let shared = AppTerminationCoordinator()
|
||||
|
||||
private weak var library: SourceLibrary?
|
||||
private var isTerminationInProgress = false
|
||||
|
||||
func register(library: SourceLibrary) {
|
||||
self.library = library
|
||||
}
|
||||
|
||||
func beginTermination(for application: NSApplication) -> NSApplication.TerminateReply {
|
||||
guard !isTerminationInProgress else {
|
||||
return .terminateLater
|
||||
}
|
||||
|
||||
isTerminationInProgress = true
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
if let library = self?.library {
|
||||
await library.shutdownGracefully(timeout: 2.0)
|
||||
}
|
||||
application.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
|
||||
return .terminateLater
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowChromeConfigurator: NSViewRepresentable {
|
||||
func makeNSView(context: Context) -> NSView {
|
||||
let view = NSView()
|
||||
|
||||
@ -254,6 +254,124 @@ struct World_Manager_for_MinecraftTests {
|
||||
#expect(enrichedPack.packMetadataDetails?.minimumEngineVersion == "1.19.50")
|
||||
}
|
||||
|
||||
@Test func minecraftPackageInspectorReadsMcworldMetadata() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let sourceDirectoryURL = workingURL.appendingPathComponent("WorldSource", isDirectory: true)
|
||||
let archiveURL = workingURL.appendingPathComponent("WorldA.mcworld", isDirectory: false)
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(at: sourceDirectoryURL, withIntermediateDirectories: true)
|
||||
try "World A".write(
|
||||
to: sourceDirectoryURL.appendingPathComponent("levelname.txt"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
|
||||
let lastPlayedMilliseconds: Int64 = 1_700_000_000_000
|
||||
let levelDat = makeBedrockLevelDat(
|
||||
root: .compound([
|
||||
"GameType": .int(0),
|
||||
"Difficulty": .int(2),
|
||||
"LastPlayed": .long(lastPlayedMilliseconds)
|
||||
]),
|
||||
storageVersion: 10
|
||||
)
|
||||
try levelDat.write(to: sourceDirectoryURL.appendingPathComponent("level.dat"))
|
||||
try makeArchive(from: sourceDirectoryURL, to: archiveURL)
|
||||
|
||||
let inspection = try MinecraftPackageInspector.inspectArchive(at: archiveURL)
|
||||
defer { MinecraftPackageInspector.cleanup(inspection) }
|
||||
|
||||
#expect(inspection.contentType == .world)
|
||||
#expect(inspection.displayName == "World A")
|
||||
#expect(inspection.worldMetadata?.gameMode == "Survival")
|
||||
#expect(inspection.worldMetadata?.difficulty == "Normal")
|
||||
#expect(inspection.worldMetadata?.lastPlayedDate == Date(timeIntervalSince1970: 1_700_000_000))
|
||||
}
|
||||
|
||||
@Test func minecraftPackageInspectorInfersMcpackTypeAndManifest() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let sourceDirectoryURL = workingURL.appendingPathComponent("PackSource", isDirectory: true)
|
||||
let archiveURL = workingURL.appendingPathComponent("PackA.mcpack", isDirectory: false)
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(at: sourceDirectoryURL, withIntermediateDirectories: true)
|
||||
let manifest = """
|
||||
{
|
||||
"header": {
|
||||
"name": "Resource Pack A",
|
||||
"uuid": "b92836dc-f5a4-4f10-9d29-6a2d2ea3a2f7",
|
||||
"version": [2, 1, 0],
|
||||
"min_engine_version": [1, 21, 0]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"type": "resources",
|
||||
"uuid": "818ac674-bf84-4955-a1db-5bf7acd63488",
|
||||
"version": [2, 1, 0]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try manifest.write(
|
||||
to: sourceDirectoryURL.appendingPathComponent("manifest.json"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
try makeArchive(from: sourceDirectoryURL, to: archiveURL)
|
||||
|
||||
let inspection = try MinecraftPackageInspector.inspectArchive(at: archiveURL)
|
||||
defer { MinecraftPackageInspector.cleanup(inspection) }
|
||||
|
||||
#expect(inspection.contentType == .resourcePack)
|
||||
#expect(inspection.displayName == "Resource Pack A")
|
||||
#expect(inspection.manifestMetadata?.uuid == "b92836dc-f5a4-4f10-9d29-6a2d2ea3a2f7")
|
||||
#expect(inspection.manifestMetadata?.version == "2.1.0")
|
||||
#expect(inspection.manifestMetadata?.minimumEngineVersion == "1.21.0")
|
||||
}
|
||||
|
||||
@Test func minecraftPackageInspectorAcceptsSingleNestedTopLevelFolder() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
let archiveRootURL = workingURL.appendingPathComponent("ArchiveRoot", isDirectory: true)
|
||||
let nestedPackURL = archiveRootURL.appendingPathComponent("Nested Pack", isDirectory: true)
|
||||
let archiveURL = workingURL.appendingPathComponent("Nested.mcpack", isDirectory: false)
|
||||
defer { try? fileManager.removeItem(at: workingURL) }
|
||||
|
||||
try fileManager.createDirectory(at: nestedPackURL, withIntermediateDirectories: true)
|
||||
let manifest = """
|
||||
{
|
||||
"header": {
|
||||
"name": "Nested Behavior Pack",
|
||||
"uuid": "2bcd9b1a-c558-4906-9521-7cccd2f9ca56",
|
||||
"version": [1, 0, 1]
|
||||
},
|
||||
"modules": [
|
||||
{
|
||||
"type": "data",
|
||||
"uuid": "4fbe707b-7cd1-4d10-80a5-b4fb45f79095",
|
||||
"version": [1, 0, 1]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
try manifest.write(
|
||||
to: nestedPackURL.appendingPathComponent("manifest.json"),
|
||||
atomically: true,
|
||||
encoding: .utf8
|
||||
)
|
||||
try makeArchive(from: archiveRootURL, to: archiveURL)
|
||||
|
||||
let inspection = try MinecraftPackageInspector.inspectArchive(at: archiveURL)
|
||||
defer { MinecraftPackageInspector.cleanup(inspection) }
|
||||
|
||||
#expect(inspection.contentRootURL.lastPathComponent == "Nested Pack")
|
||||
#expect(inspection.contentType == .behaviorPack)
|
||||
#expect(inspection.displayName == "Nested Behavior Pack")
|
||||
}
|
||||
|
||||
@Test func sourcePersistenceStoreRoundTripsCachedSource() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let workingURL = fileManager.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
@ -375,6 +493,20 @@ struct World_Manager_for_MinecraftTests {
|
||||
#expect(values["ConnectionType"] == "USB")
|
||||
}
|
||||
|
||||
@Test func scanNotificationServiceFormatsCompletionMessage() async throws {
|
||||
#expect(ScanNotificationService.completionMessage(itemCount: 0) == "No worlds or packs were found.")
|
||||
#expect(ScanNotificationService.completionMessage(itemCount: 1) == "Found 1 item.")
|
||||
#expect(ScanNotificationService.completionMessage(itemCount: 42) == "Found 42 items.")
|
||||
}
|
||||
|
||||
@Test func scanNotificationServiceOnlyNotifiesForLongBackgroundScans() async throws {
|
||||
let service = ScanNotificationService()
|
||||
|
||||
#expect(service.shouldNotifyAboutCompletedScan(duration: 2, isAppActive: false) == false)
|
||||
#expect(service.shouldNotifyAboutCompletedScan(duration: 8, isAppActive: true) == false)
|
||||
#expect(service.shouldNotifyAboutCompletedScan(duration: 8, isAppActive: false) == true)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private enum TestNBTTagType: UInt8 {
|
||||
@ -470,6 +602,43 @@ private func appendString(_ string: String, to data: inout Data) {
|
||||
data.append(utf8)
|
||||
}
|
||||
|
||||
private func makeArchive(from sourceDirectoryURL: URL, to archiveURL: URL) throws {
|
||||
let process = Process()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/ditto")
|
||||
process.currentDirectoryURL = sourceDirectoryURL
|
||||
process.arguments = [
|
||||
"-c",
|
||||
"-k",
|
||||
"--norsrc",
|
||||
".",
|
||||
archiveURL.path
|
||||
]
|
||||
|
||||
let outputPipe = Pipe()
|
||||
process.standardOutput = outputPipe
|
||||
process.standardError = outputPipe
|
||||
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let output = String(data: outputData, encoding: .utf8) ?? ""
|
||||
throw ArchiveTestError.failedToCreateArchive(output)
|
||||
}
|
||||
}
|
||||
|
||||
private enum ArchiveTestError: LocalizedError {
|
||||
case failedToCreateArchive(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .failedToCreateArchive(let output):
|
||||
return output.isEmpty ? "Failed to create test archive." : output
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func appendLE<T: FixedWidthInteger>(_ value: T, to data: inout Data) {
|
||||
var value = value.littleEndian
|
||||
withUnsafeBytes(of: &value) { bytes in
|
||||
|
||||
144
World-Manager-for-Minecraft-Info.plist
Normal 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
@ -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.
|
||||