304 lines
10 KiB
Swift
304 lines
10 KiB
Swift
//
|
|
// ConnectedDeviceSourcePickerView.swift
|
|
// World Manager for Minecraft
|
|
//
|
|
// Created by OpenAI on 2026-05-26.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct ConnectedDeviceSourcePickerView: View {
|
|
let deviceDiscoveryService: ConnectedDeviceSourceAccessMethod
|
|
let sourceFactory: ConnectedDeviceSourceFactory
|
|
let onAddSource: (MinecraftSource) -> Void
|
|
|
|
@Environment(\.dismiss) private var dismiss
|
|
@State private var devices: [ConnectedDevice] = []
|
|
@State private var containers: [DeviceAppContainer] = []
|
|
@State private var selectedDeviceID: ConnectedDevice.ID?
|
|
@State private var selectedContainerID: DeviceAppContainer.ID?
|
|
@State private var preferredMinecraftSubpath = "Documents/games/com.mojang"
|
|
@State private var isLoadingDevices = false
|
|
@State private var isLoadingContainers = false
|
|
@State private var availabilityMessage: String?
|
|
@State private var errorMessage: String?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
header
|
|
|
|
if let availabilityMessage {
|
|
Text(availabilityMessage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 18)
|
|
}
|
|
|
|
if let errorMessage {
|
|
Text(errorMessage)
|
|
.font(.footnote)
|
|
.foregroundStyle(.red)
|
|
.padding(.horizontal, 18)
|
|
}
|
|
|
|
HStack(alignment: .top, spacing: 18) {
|
|
deviceColumn
|
|
containerColumn
|
|
}
|
|
.frame(maxHeight: .infinity, alignment: .top)
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
Text("Minecraft Subpath")
|
|
.font(.subheadline.weight(.semibold))
|
|
TextField("Documents/games/com.mojang", text: $preferredMinecraftSubpath)
|
|
.textFieldStyle(.roundedBorder)
|
|
Text("Used after the app container is mounted. The current proven path is `Documents/games/com.mojang`.")
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
|
|
footer
|
|
}
|
|
.frame(minWidth: 760, minHeight: 420)
|
|
.padding(.vertical, 18)
|
|
.task {
|
|
await loadDevices()
|
|
}
|
|
.onChange(of: selectedDeviceID) { _, _ in
|
|
Task {
|
|
await loadContainersForSelectedDevice()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Add Connected Device Source")
|
|
.font(.title2.weight(.semibold))
|
|
Text("Choose a connected device source and scan its Minecraft documents just like a local folder.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
}
|
|
|
|
private var deviceColumn: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
HStack {
|
|
Text("Devices")
|
|
.font(.headline)
|
|
|
|
Spacer()
|
|
|
|
Button("Refresh") {
|
|
Task {
|
|
await loadDevices()
|
|
}
|
|
}
|
|
.disabled(isLoadingDevices)
|
|
}
|
|
|
|
Group {
|
|
if isLoadingDevices {
|
|
loadingState("Searching for connected devices...")
|
|
} else if devices.isEmpty {
|
|
emptyState("No devices found")
|
|
} else {
|
|
List(devices, selection: $selectedDeviceID) { device in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(device.name)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(deviceSubtitle(device))
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.tag(device.id)
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding(.horizontal, 18)
|
|
}
|
|
|
|
private var containerColumn: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Accessible Apps")
|
|
.font(.headline)
|
|
|
|
Group {
|
|
if selectedDevice == nil {
|
|
emptyState("Select a device to list file-sharing apps")
|
|
} else if isLoadingContainers {
|
|
loadingState("Listing accessible app containers...")
|
|
} else if containers.isEmpty {
|
|
emptyState("No accessible apps found for this device")
|
|
} else {
|
|
List(containers, selection: $selectedContainerID) { container in
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(container.appName)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(container.appID)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.tag(container.id)
|
|
}
|
|
.listStyle(.inset)
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
|
|
.padding(.horizontal, 18)
|
|
}
|
|
|
|
private var footer: some View {
|
|
HStack {
|
|
Button("Cancel") {
|
|
dismiss()
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button("Add Device Source") {
|
|
guard let selectedDevice, let selectedContainer else {
|
|
return
|
|
}
|
|
|
|
var adjustedContainer = selectedContainer
|
|
let trimmedSubpath = preferredMinecraftSubpath.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
adjustedContainer.minecraftFolderRelativePath = trimmedSubpath.isEmpty ? nil : trimmedSubpath
|
|
let source = sourceFactory.makeSource(device: selectedDevice, container: adjustedContainer)
|
|
onAddSource(source)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(selectedDevice == nil || selectedContainer == nil)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
}
|
|
|
|
private var selectedDevice: ConnectedDevice? {
|
|
devices.first(where: { $0.id == selectedDeviceID })
|
|
}
|
|
|
|
private var selectedContainer: DeviceAppContainer? {
|
|
containers.first(where: { $0.id == selectedContainerID })
|
|
}
|
|
|
|
private func loadDevices() async {
|
|
isLoadingDevices = true
|
|
availabilityMessage = nil
|
|
errorMessage = nil
|
|
let previousSelectedDeviceID = selectedDeviceID
|
|
|
|
do {
|
|
let devices = try await deviceDiscoveryService.listConnectedDevices()
|
|
let resolvedSelectedDeviceID = devices.contains(where: { $0.id == previousSelectedDeviceID })
|
|
? previousSelectedDeviceID
|
|
: devices.first?.id
|
|
let shouldReloadContainers = resolvedSelectedDeviceID != nil && resolvedSelectedDeviceID == previousSelectedDeviceID
|
|
|
|
await MainActor.run {
|
|
self.devices = devices
|
|
self.selectedDeviceID = resolvedSelectedDeviceID
|
|
if devices.isEmpty {
|
|
self.containers = []
|
|
self.selectedContainerID = nil
|
|
}
|
|
self.isLoadingDevices = false
|
|
}
|
|
|
|
if shouldReloadContainers {
|
|
await loadContainersForSelectedDevice()
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.devices = []
|
|
self.containers = []
|
|
self.selectedDeviceID = nil
|
|
self.selectedContainerID = nil
|
|
self.errorMessage = error.localizedDescription
|
|
self.isLoadingDevices = false
|
|
self.isLoadingContainers = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func loadContainersForSelectedDevice() async {
|
|
guard let selectedDevice else {
|
|
await MainActor.run {
|
|
containers = []
|
|
selectedContainerID = nil
|
|
isLoadingContainers = false
|
|
}
|
|
return
|
|
}
|
|
|
|
isLoadingContainers = true
|
|
availabilityMessage = nil
|
|
errorMessage = nil
|
|
|
|
do {
|
|
let containers = try await deviceDiscoveryService.listAccessibleContainers(for: selectedDevice)
|
|
await MainActor.run {
|
|
self.containers = containers
|
|
self.selectedContainerID = preferredContainerID(in: containers)
|
|
self.isLoadingContainers = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
self.containers = []
|
|
self.selectedContainerID = nil
|
|
self.errorMessage = error.localizedDescription
|
|
self.isLoadingContainers = false
|
|
}
|
|
}
|
|
}
|
|
|
|
private func preferredContainerID(in containers: [DeviceAppContainer]) -> DeviceAppContainer.ID? {
|
|
if let minecraftContainer = containers.first(where: { $0.appID == "com.mojang.minecraftpe" }) {
|
|
return minecraftContainer.id
|
|
}
|
|
|
|
return containers.first?.id
|
|
}
|
|
|
|
private func deviceSubtitle(_ device: ConnectedDevice) -> String {
|
|
[
|
|
device.productType,
|
|
device.osVersion.map { "iOS/iPadOS \($0)" },
|
|
device.connection == .network ? "Network" : "USB"
|
|
]
|
|
.compactMap { $0 }
|
|
.joined(separator: " • ")
|
|
}
|
|
|
|
private func loadingState(_ title: String) -> some View {
|
|
VStack(spacing: 10) {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
Text(title)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.appCardSurface(.placeholder)
|
|
}
|
|
|
|
private func emptyState(_ title: String) -> some View {
|
|
VStack(spacing: 10) {
|
|
Image(systemName: "externaldrive.badge.questionmark")
|
|
.font(.title2)
|
|
.foregroundStyle(.secondary)
|
|
Text(title)
|
|
.font(.footnote)
|
|
.foregroundStyle(.secondary)
|
|
.multilineTextAlignment(.center)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.appCardSurface(.placeholder)
|
|
}
|
|
}
|