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