StikDebug/StikJIT/Views/SettingsView.swift

379 lines
15 KiB
Swift

// SettingsView.swift
// StikJIT
//
// Created by Stephen on 3/27/25.
import SwiftUI
import UIKit
private enum SettingsLinks {
static let githubStars = URL(string: "https://github.com/StephenDev0/StikDebug/stargazers")!
static let pairingFileGuide = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md")!
static let discord = URL(string: "https://discord.gg/qahjXNTDwS")!
}
struct SettingsView: View {
@AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false
@AppStorage("keepAliveAudio") private var keepAliveAudio = true
@AppStorage("keepAliveLocation") private var keepAliveLocation = true
@AppStorage("customTargetIP") private var customTargetIP = ""
@Environment(LanguageManager.self) private var langManager
@StateObject private var vpnManager = StikDebugVPNManager.shared
@State private var isShowingPairingFilePicker = false
@State private var isImportingFile = false
@State private var pairingImportMessage: (text: String, isError: Bool)?
@State private var showDDIConfirmation = false
@State private var isRedownloadingDDI = false
@State private var ddiDownloadProgress: Double = 0.0
@State private var ddiStatusMessage: String = ""
@State private var ddiResultMessage: (text: String, isError: Bool)?
@State private var vpnErrorMessage: String?
private var appVersion: String {
let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
return marketingVersion
}
var body: some View {
NavigationStack {
Form {
Section {
HStack {
Spacer()
VStack(spacing: 12) {
Image("StikDebug")
.resizable().aspectRatio(contentMode: .fit)
.frame(width: 80, height: 80)
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
Text("StikDebug").font(.title2.weight(.semibold))
}
Spacer()
}
.listRowBackground(Color.clear)
.padding(.vertical, 8)
}
Section {
Link(destination: SettingsLinks.githubStars) {
Label("Star on GitHub".localized, systemImage: "star")
}
}
Section("Language".localized) {
Picker("Language".localized, selection: Binding(
get: { langManager.language },
set: { langManager.language = $0 }
)) {
Text("简体中文").tag("zh-Hans")
Text("English").tag("en")
}
.pickerStyle(.segmented)
}
Section("Pairing File".localized) {
Button {
isShowingPairingFilePicker = true
} label: {
Label("Import Pairing File".localized, systemImage: "doc.badge.plus")
}
.disabled(isImportingFile)
if isImportingFile {
HStack(spacing: 10) {
ProgressView()
.controlSize(.small)
Text("Importing pairing file…".localized)
.font(.caption)
.foregroundStyle(.secondary)
}
} else if let pairingImportMessage {
Label(
pairingImportMessage.text,
systemImage: pairingImportMessage.isError ? "exclamationmark.triangle.fill" : "checkmark.circle.fill"
)
.font(.caption)
.foregroundStyle(pairingImportMessage.isError ? .red : .green)
}
}
Section("Embedded VPN".localized) {
HStack {
Label(vpnStatusTitle.localized, systemImage: vpnStatusIcon)
Spacer()
Circle()
.fill(vpnStatusColor)
.frame(width: 9, height: 9)
}
if vpnManager.currentStatus == .connected || vpnManager.currentStatus == .connecting {
Button {
vpnManager.stop()
vpnErrorMessage = nil
} label: {
Label("Stop VPN".localized, systemImage: "stop.circle")
}
} else {
Button {
startEmbeddedVPN()
} label: {
Label("Start VPN".localized, systemImage: "play.circle")
}
.disabled(vpnManager.currentStatus == .connecting)
}
if let vpnErrorMessage {
Text(vpnErrorMessage)
.font(.caption)
.foregroundStyle(.red)
}
}
Section {
Toggle(isOn: $keepAliveAudio) {
VStack(alignment: .leading, spacing: 2) {
Text("Silent Audio".localized)
Text("Plays inaudible audio so iOS keeps the app running.".localized)
.font(.caption).foregroundStyle(.secondary)
}
}
.onChange(of: keepAliveAudio) { _, enabled in
if enabled { BackgroundAudioManager.shared.start() }
else { BackgroundAudioManager.shared.stop() }
}
Toggle(isOn: $keepAliveLocation) {
VStack(alignment: .leading, spacing: 2) {
Text("Background Location".localized)
Text("Uses low-accuracy location to stay alive when an activity needs it.".localized)
.font(.caption).foregroundStyle(.secondary)
}
}
.onChange(of: keepAliveLocation) { _, enabled in
if !enabled { BackgroundLocationManager.shared.stop() }
}
} header: {
Text("Background Keep-Alive".localized)
}
Section("Behavior".localized) {
Toggle(isOn: $overrideTXMDetection) {
VStack(alignment: .leading, spacing: 2) {
Text("Always Run Scripts".localized)
Text("Treats device as TXM-capable to bypass hardware checks.".localized)
.font(.caption).foregroundStyle(.secondary)
}
}
}
Section("Advanced".localized) {
HStack {
Text("Target Device IP".localized)
Spacer()
TextField("10.7.0.1", text: $customTargetIP)
.multilineTextAlignment(.trailing)
.keyboardType(.numbersAndPunctuation)
.submitLabel(.done)
}
Button { openAppFolder() } label: {
Label("App Folder".localized, systemImage: "folder")
}.foregroundStyle(.primary)
Button { showDDIConfirmation = true } label: {
Label("Redownload DDI".localized, systemImage: "arrow.down.circle")
}.foregroundStyle(.primary).disabled(isRedownloadingDDI)
if isRedownloadingDDI {
VStack(alignment: .leading, spacing: 4) {
ProgressView(value: ddiDownloadProgress, total: 1.0)
Text(ddiStatusMessage).font(.caption).foregroundStyle(.secondary)
}
} else if let result = ddiResultMessage {
Text(result.text).font(.caption).foregroundStyle(result.isError ? .red : .green)
}
}
Section("Help".localized) {
Link(destination: SettingsLinks.pairingFileGuide) {
Label("Pairing File Guide".localized, systemImage: "questionmark.circle")
}
Link(destination: SettingsLinks.discord) {
Label("Discord Support".localized, systemImage: "bubble.left.and.bubble.right")
}
}
Section {
Text(versionFooter)
.font(.footnote).foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .center)
.listRowBackground(Color.clear)
}
}
.navigationTitle("Settings".localized)
}
.fileImporter(
isPresented: $isShowingPairingFilePicker,
allowedContentTypes: PairingFileStore.supportedContentTypes,
allowsMultipleSelection: false
) { result in
switch result {
case .success(let urls):
guard let url = urls.first else { return }
let fileManager = FileManager.default
isImportingFile = true
pairingImportMessage = nil
do {
try PairingFileStore.importFromPicker(url, fileManager: fileManager)
isImportingFile = false
pairingImportMessage = ("Imported successfully".localized, false)
startTunnelInBackground()
schedulePairingStatusDismiss()
} catch {
isImportingFile = false
pairingImportMessage = (String(format: "Import failed: %@".localized, error.localizedDescription), true)
schedulePairingStatusDismiss()
}
case .failure(let error):
isImportingFile = false
pairingImportMessage = (String(format: "Import failed: %@".localized, error.localizedDescription), true)
schedulePairingStatusDismiss()
}
}
.confirmationDialog("Redownload DDI Files?".localized, isPresented: $showDDIConfirmation, titleVisibility: .visible) {
Button("Redownload".localized, role: .destructive) {
redownloadDDIPressed()
}
Button("Cancel".localized, role: .cancel) { }
} message: {
Text("Existing DDI files will be removed before downloading fresh copies.".localized)
}
}
private var versionFooter: String {
let processInfo = ProcessInfo.processInfo
let txmLabel: String
if processInfo.isTXMOverridden {
txmLabel = "TXM (Override)"
} else {
txmLabel = processInfo.hasTXM ? "TXM" : "Non TXM"
}
return "Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)"
}
private var vpnStatusTitle: String {
switch vpnManager.currentStatus {
case .connected:
return "VPN connected"
case .connecting:
return "VPN connecting"
case .disconnecting:
return "VPN connecting"
case .failed:
return "VPN failed"
case .disconnected:
return "VPN disconnected"
}
}
private var vpnStatusIcon: String {
switch vpnManager.currentStatus {
case .connected:
return "checkmark.shield.fill"
case .connecting, .disconnecting:
return "arrow.triangle.2.circlepath"
case .failed:
return "exclamationmark.shield.fill"
case .disconnected:
return "shield.slash"
}
}
private var vpnStatusColor: Color {
switch vpnManager.currentStatus {
case .connected:
return .green
case .connecting, .disconnecting:
return .orange
case .failed:
return .red
case .disconnected:
return .secondary
}
}
private func startEmbeddedVPN() {
vpnErrorMessage = nil
DispatchQueue.global(qos: .userInitiated).async {
do {
try vpnManager.start()
} catch {
DispatchQueue.main.async {
vpnErrorMessage = error.localizedDescription
}
}
}
}
// MARK: - Business Logic
private func openAppFolder() {
guard let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
let path = documentsURL.absoluteString.replacingOccurrences(of: "file://", with: "shareddocuments://")
if let url = URL(string: path) {
UIApplication.shared.open(url, options: [:], completionHandler: nil)
}
}
private func redownloadDDIPressed() {
guard !isRedownloadingDDI else { return }
Task {
await MainActor.run {
isRedownloadingDDI = true
ddiDownloadProgress = 0
ddiStatusMessage = "Preparing download…".localized
ddiResultMessage = nil
}
do {
try await redownloadDDI { progress, status in
Task { @MainActor in
self.ddiDownloadProgress = progress
self.ddiStatusMessage = status
}
}
await MainActor.run {
isRedownloadingDDI = false
ddiResultMessage = ("DDI files refreshed successfully.".localized, false)
}
} catch {
await MainActor.run {
isRedownloadingDDI = false
ddiResultMessage = (String(format: "Failed to redownload DDI files: %@".localized, error.localizedDescription), true)
}
}
}
scheduleDDIStatusDismiss()
}
private func schedulePairingStatusDismiss() {
Task {
try? await Task.sleep(nanoseconds: 4_000_000_000)
await MainActor.run {
if !isImportingFile {
pairingImportMessage = nil
}
}
}
}
private func scheduleDDIStatusDismiss() {
Task {
try? await Task.sleep(nanoseconds: 4_000_000_000)
await MainActor.run {
if !isRedownloadingDDI {
ddiResultMessage = nil
}
}
}
}
}