211 lines
7.9 KiB
Swift
211 lines
7.9 KiB
Swift
#if os(tvOS)
|
|
|
|
import DeviceDiscoveryUI
|
|
import Libbox
|
|
import Library
|
|
import SwiftUI
|
|
|
|
@MainActor
|
|
public struct ImportProfileView: View {
|
|
@EnvironmentObject private var environments: ExtensionEnvironments
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
@State private var isLoading = false
|
|
@State private var selected = false
|
|
@State private var alert: Alert?
|
|
@State private var connection: NWConnection?
|
|
@State private var socket: NWSocket?
|
|
@State private var profiles: [LibboxProfilePreview]?
|
|
@State private var isImporting = false
|
|
|
|
public init() {}
|
|
public var body: some View {
|
|
VStack(alignment: .center) {
|
|
if !selected {
|
|
Form {
|
|
Section {
|
|
EmptyView()
|
|
} footer: {
|
|
Text("To import configurations from your iPhone or iPad, make sure sing-box is the **same version** on both devices and **VPN is disabled**.")
|
|
}
|
|
|
|
DevicePicker(
|
|
.applicationService(name: "sing-box:profile"))
|
|
{ endpoint in
|
|
selected = true
|
|
Task {
|
|
await handleEndpoint(endpoint)
|
|
}
|
|
} label: {
|
|
Text("Select Device")
|
|
} fallback: {
|
|
EmptyView()
|
|
} parameters: {
|
|
.applicationService
|
|
}
|
|
}
|
|
} else if let profiles {
|
|
Form {
|
|
Section {
|
|
EmptyView()
|
|
} footer: {
|
|
Text("\(profiles.count) Profiles")
|
|
}
|
|
ForEach(profiles, id: \.profileID) { profile in
|
|
Button(profile.name) {
|
|
isLoading = true
|
|
Task {
|
|
selectProfile(profileID: profile.profileID)
|
|
isLoading = false
|
|
}
|
|
}.disabled(isLoading || isImporting)
|
|
}
|
|
}
|
|
} else {
|
|
Text("Connecting...")
|
|
}
|
|
}
|
|
.focusSection()
|
|
.alertBinding($alert)
|
|
.navigationTitle("Import Profile")
|
|
}
|
|
|
|
private func reset() {
|
|
if let connection {
|
|
connection.stateUpdateHandler = nil
|
|
connection.cancel()
|
|
self.connection = nil
|
|
}
|
|
if let socket {
|
|
socket.cancel()
|
|
self.socket = nil
|
|
}
|
|
selected = false
|
|
profiles = nil
|
|
}
|
|
|
|
private func handleEndpoint(_ endpoint: NWEndpoint) async {
|
|
let connection = NWConnection(to: endpoint, using: NWParameters.applicationService)
|
|
self.connection = connection
|
|
socket = NWSocket(connection)
|
|
connection.stateUpdateHandler = { state in
|
|
switch state {
|
|
case let .failed(error):
|
|
DispatchQueue.main.async { [self] in
|
|
reset()
|
|
alert = Alert(error)
|
|
}
|
|
default: break
|
|
}
|
|
}
|
|
connection.start(queue: .global())
|
|
do {
|
|
try await loopMessages()
|
|
} catch {
|
|
alert = Alert(error)
|
|
reset()
|
|
}
|
|
}
|
|
|
|
private nonisolated func loopMessages() async throws {
|
|
guard let socket = await socket else {
|
|
return
|
|
}
|
|
var message: Data
|
|
while true {
|
|
do {
|
|
message = try socket.read()
|
|
} catch {
|
|
throw NSError(domain: "read from connection: \(error.localizedDescription)", code: 0)
|
|
}
|
|
var error: NSError?
|
|
switch Int64(message[0]) {
|
|
case LibboxMessageTypeError:
|
|
let message = LibboxDecodeErrorMessage(message, &error)
|
|
if let error {
|
|
throw error
|
|
}
|
|
if let message {
|
|
throw NSError(domain: "remote error: \(message.message)", code: 0)
|
|
}
|
|
case LibboxMessageTypeProfileList:
|
|
let decoder = LibboxProfileDecoder()
|
|
try decoder.decode(message)
|
|
let iterator = decoder.iterator()!
|
|
var profiles = [LibboxProfilePreview]()
|
|
while iterator.hasNext() {
|
|
let profile = iterator.next()!
|
|
if profile.type == LibboxProfileTypeiCloud {
|
|
// not supported on tvOS
|
|
continue
|
|
}
|
|
profiles.append(profile)
|
|
}
|
|
await MainActor.run { [self, profiles] in
|
|
self.profiles = profiles
|
|
isImporting = false
|
|
}
|
|
case LibboxMessageTypeProfileContent:
|
|
let content = LibboxDecodeProfileContent(message, &error)
|
|
if let error {
|
|
throw error
|
|
}
|
|
try await importProfile(content!)
|
|
return
|
|
default:
|
|
throw NSError(domain: "unknown message type \(message[0])", code: 0)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func selectProfile(profileID: Int64) {
|
|
guard let connection else {
|
|
return
|
|
}
|
|
guard let socket else {
|
|
return
|
|
}
|
|
connection.stateUpdateHandler = nil
|
|
let request = LibboxProfileContentRequest()
|
|
request.profileID = profileID
|
|
do {
|
|
try socket.write(request.encode())
|
|
isImporting = true
|
|
} catch {
|
|
alert = Alert(error)
|
|
reset()
|
|
}
|
|
}
|
|
|
|
private nonisolated func importProfile(_ content: LibboxProfileContent) async throws {
|
|
var type: ProfileType = .local
|
|
switch content.type {
|
|
case LibboxProfileTypeLocal:
|
|
type = .local
|
|
case LibboxProfileTypeiCloud:
|
|
type = .icloud
|
|
case LibboxProfileTypeRemote:
|
|
type = .remote
|
|
default:
|
|
break
|
|
}
|
|
let nextProfileID = try await ProfileManager.nextID()
|
|
let profileConfigDirectory = FilePath.sharedDirectory.appendingPathComponent("configs", isDirectory: true)
|
|
try FileManager.default.createDirectory(at: profileConfigDirectory, withIntermediateDirectories: true)
|
|
let profileConfig = profileConfigDirectory.appendingPathComponent("config_\(nextProfileID).json")
|
|
try content.config.write(to: profileConfig, atomically: true, encoding: .utf8)
|
|
var lastUpdated: Date?
|
|
if content.lastUpdated > 0 {
|
|
lastUpdated = Date(timeIntervalSince1970: Double(content.lastUpdated))
|
|
}
|
|
try await ProfileManager.create(Profile(name: content.name, type: type, path: profileConfig.relativePath, remoteURL: content.remotePath, autoUpdate: content.autoUpdate, lastUpdated: lastUpdated))
|
|
await reset()
|
|
await MainActor.run {
|
|
environments.profileUpdate.send()
|
|
dismiss()
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|