mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
379 lines
15 KiB
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|