mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-16 04:20:30 +00:00
Introduces DeviceLibraryStore and DeviceConnectionContext to manage external device profiles and connections. Updates app logic to support switching between local loopback VPN and direct external device connections, including UI for device management and improved connection handling throughout the app.
883 lines
35 KiB
Swift
883 lines
35 KiB
Swift
// SettingsView.swift
|
|
// StikJIT
|
|
//
|
|
// Created by Stephen on 3/27/25.
|
|
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
import UIKit
|
|
|
|
struct SettingsView: View {
|
|
@AppStorage("username") private var username = "User"
|
|
@AppStorage("selectedAppIcon") private var selectedAppIcon: String = "AppIcon"
|
|
@AppStorage("enableAdvancedOptions") private var enableAdvancedOptions = false
|
|
@AppStorage("enableAdvancedBetaOptions") private var enableAdvancedBetaOptions = false
|
|
@AppStorage("enableTesting") private var enableTesting = false
|
|
@AppStorage(UserDefaults.Keys.enableContinuedProcessing) private var enableContinuedProcessing = false
|
|
@AppStorage(UserDefaults.Keys.txmOverride) private var overrideTXMDetection = false
|
|
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
|
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
|
@AppStorage(TabConfiguration.storageKey) private var enabledTabIdentifiers = TabConfiguration.defaultRawValue
|
|
@AppStorage("primaryTabSelection") private var tabSelection = TabConfiguration.defaultIDs.first ?? "home"
|
|
@Environment(\.themeExpansionManager) private var themeExpansion
|
|
private var backgroundStyle: BackgroundStyle { themeExpansion?.backgroundStyle(for: appThemeRaw) ?? AppTheme.system.backgroundStyle }
|
|
private var preferredScheme: ColorScheme? { themeExpansion?.preferredColorScheme(for: appThemeRaw) }
|
|
private var isAppStoreBuild: Bool {
|
|
#if APPSTORE
|
|
return true
|
|
#else
|
|
return false
|
|
#endif
|
|
}
|
|
|
|
@State private var isShowingPairingFilePicker = false
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
@State private var showIconPopover = false
|
|
@State private var showPairingFileMessage = false
|
|
@State private var isImportingFile = false
|
|
@State private var importProgress: Float = 0.0
|
|
@State private var pairingStatusMessage: String? = nil
|
|
@State private var showRemovePairingFileDialog = false
|
|
@State private var is_lc = false
|
|
@State private var showColorPickerPopup = false
|
|
@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 showingDisplayView = false
|
|
@State private var tabSelectionMessage: String?
|
|
|
|
private var appVersion: String {
|
|
let marketingVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
|
|
return marketingVersion
|
|
}
|
|
|
|
private var accentColor: Color {
|
|
themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue
|
|
}
|
|
|
|
private var currentThemeName: String {
|
|
AppTheme(rawValue: appThemeRaw)?.displayName ?? "Default"
|
|
}
|
|
|
|
private var accentColorDescription: String {
|
|
customAccentColorHex.isEmpty ? "System Blue" : customAccentColorHex.uppercased()
|
|
}
|
|
struct TabOption: Identifiable {
|
|
let id: String
|
|
let title: String
|
|
let detail: String
|
|
let icon: String
|
|
let isBeta: Bool
|
|
}
|
|
|
|
private let developerProfiles: [String: String] = [
|
|
"Stephen": "https://github.com/StephenDev0.png",
|
|
"jkcoxson": "https://github.com/jkcoxson.png",
|
|
"Stossy11": "https://github.com/Stossy11.png",
|
|
"Neo": "https://github.com/neoarz.png",
|
|
"Se2crid": "https://github.com/Se2crid.png",
|
|
"Huge_Black": "https://github.com/HugeBlack.png",
|
|
"Wynwxst": "https://github.com/Wynwxst.png"
|
|
]
|
|
|
|
private var tabOptions: [TabOption] {
|
|
var options: [TabOption] = [
|
|
TabOption(id: "home", title: "Home", detail: "Dashboard overview", icon: "house", isBeta: false),
|
|
TabOption(id: "console", title: "Console", detail: "Live device logs", icon: "terminal", isBeta: false),
|
|
TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false),
|
|
TabOption(id: "profiles", title: "Profiles", detail: "Install/remove profiles", icon: "magazine.fill", isBeta: false),
|
|
TabOption(id: "deviceinfo", title: "Device Info", detail: "View detailed device metadata", icon: "iphone.and.arrow.forward", isBeta: false)
|
|
]
|
|
if FeatureFlags.showBetaTabs {
|
|
options.append(TabOption(id: "processes", title: "Processes", detail: "Inspect running apps", icon: "rectangle.stack.person.crop", isBeta: true))
|
|
options.append(TabOption(id: "devicelibrary", title: "Devices", detail: "Manage external devices", icon: "list.bullet.rectangle", isBeta: true))
|
|
if FeatureFlags.isLocationSpoofingEnabled && !isAppStoreBuild {
|
|
options.append(TabOption(id: "location", title: "Location Sim", detail: "Sideload only", icon: "location", isBeta: true))
|
|
}
|
|
}
|
|
return options
|
|
}
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
// Subtle depth gradient background
|
|
ThemedBackground(style: backgroundStyle)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
headerCard
|
|
appearanceCard
|
|
tabCustomizationCard
|
|
pairingCard
|
|
behaviorCard
|
|
advancedCard
|
|
helpCard
|
|
versionInfo
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 30)
|
|
}
|
|
|
|
// Busy overlay while importing pairing file
|
|
if isImportingFile {
|
|
Color.black.opacity(0.35).ignoresSafeArea()
|
|
VStack(spacing: 12) {
|
|
ProgressView("Processing pairing file…")
|
|
VStack(spacing: 8) {
|
|
GeometryReader { geometry in
|
|
ZStack(alignment: .leading) {
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color(UIColor.tertiarySystemFill))
|
|
.frame(height: 8)
|
|
|
|
RoundedRectangle(cornerRadius: 6)
|
|
.fill(Color.green)
|
|
.frame(width: geometry.size.width * CGFloat(importProgress), height: 8)
|
|
.animation(.linear(duration: 0.3), value: importProgress)
|
|
}
|
|
}
|
|
.frame(height: 8)
|
|
Text("\(Int(importProgress * 100))%")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
.padding(16)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
|
)
|
|
)
|
|
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
|
}
|
|
|
|
// Success toast after import
|
|
if let pairingStatusMessage,
|
|
showPairingFileMessage,
|
|
!isImportingFile {
|
|
VStack {
|
|
Spacer()
|
|
Text(pairingStatusMessage)
|
|
.font(.footnote.weight(.semibold))
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(.ultraThinMaterial, in: Capsule())
|
|
.overlay(Capsule().strokeBorder(Color.white.opacity(0.15), lineWidth: 1))
|
|
.shadow(color: .black.opacity(0.12), radius: 10, x: 0, y: 3)
|
|
.transition(.move(edge: .bottom).combined(with: .opacity))
|
|
.padding(.bottom, 30)
|
|
}
|
|
.animation(.easeInOut(duration: 0.25), value: showPairingFileMessage)
|
|
}
|
|
}
|
|
.navigationTitle("Settings")
|
|
}
|
|
// Match controls to the active accent color (defaults to blue)
|
|
.tint(accentColor)
|
|
.preferredColorScheme(preferredScheme)
|
|
.fileImporter(
|
|
isPresented: $isShowingPairingFilePicker,
|
|
allowedContentTypes: [UTType(filenameExtension: "mobiledevicepairing", conformingTo: .data)!, .propertyList],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
switch result {
|
|
case .success(let urls):
|
|
guard let url = urls.first else { return }
|
|
|
|
let fileManager = FileManager.default
|
|
let accessing = url.startAccessingSecurityScopedResource()
|
|
|
|
if fileManager.fileExists(atPath: url.path) {
|
|
do {
|
|
if fileManager.fileExists(atPath: URL.documentsDirectory.appendingPathComponent("pairingFile.plist").path) {
|
|
try fileManager.removeItem(at: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
|
|
}
|
|
|
|
try fileManager.copyItem(at: url, to: URL.documentsDirectory.appendingPathComponent("pairingFile.plist"))
|
|
print("File copied successfully!")
|
|
|
|
DispatchQueue.main.async {
|
|
isImportingFile = true
|
|
importProgress = 0.0
|
|
pairingStatusMessage = nil
|
|
showPairingFileMessage = false
|
|
}
|
|
|
|
let progressTimer = Timer.scheduledTimer(withTimeInterval: 0.05, repeats: true) { timer in
|
|
DispatchQueue.main.async {
|
|
if importProgress < 1.0 {
|
|
importProgress += 0.05
|
|
} else {
|
|
timer.invalidate()
|
|
isImportingFile = false
|
|
}
|
|
}
|
|
}
|
|
|
|
RunLoop.current.add(progressTimer, forMode: .common)
|
|
DispatchQueue.main.async {
|
|
startHeartbeatInBackground(requireVPNConnection: false)
|
|
}
|
|
|
|
} catch {
|
|
print("Error copying file: \(error)")
|
|
}
|
|
} else {
|
|
print("Source file does not exist.")
|
|
}
|
|
|
|
if accessing {
|
|
url.stopAccessingSecurityScopedResource()
|
|
}
|
|
case .failure(let error):
|
|
print("Failed to import file: \(error)")
|
|
}
|
|
}
|
|
.confirmationDialog("Redownload DDI Files?", isPresented: $showDDIConfirmation, titleVisibility: .visible) {
|
|
Button("Redownload", role: .destructive) {
|
|
redownloadDDIPressed()
|
|
}
|
|
Button("Cancel", role: .cancel) { }
|
|
} message: {
|
|
Text("Existing DDI files will be removed before downloading fresh copies.")
|
|
}
|
|
}
|
|
|
|
// MARK: - Cards
|
|
|
|
private var headerCard: some View {
|
|
glassCard {
|
|
VStack(spacing: 16) {
|
|
VStack {
|
|
Image("StikDebug")
|
|
.resizable()
|
|
.aspectRatio(contentMode: .fit)
|
|
.frame(width: 80, height: 80)
|
|
.clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 16, style: .continuous)
|
|
.stroke(Color.white.opacity(0.12), lineWidth: 1)
|
|
)
|
|
}
|
|
Text("StikDebug 26")
|
|
.font(.title2.weight(.semibold))
|
|
.foregroundColor(.primary)
|
|
}
|
|
.padding(.vertical, 8)
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
}
|
|
}
|
|
|
|
private var appearanceCard: some View {
|
|
glassCard {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Appearance")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
HStack(spacing: 14) {
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.fill(accentColor)
|
|
.frame(width: 44, height: 44)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.2), lineWidth: 1)
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(currentThemeName)
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(.primary)
|
|
Text("Accent · \(accentColorDescription)")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 4)
|
|
|
|
Button(action: { showingDisplayView = true }) {
|
|
HStack {
|
|
Image(systemName: "paintbrush")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.primary.opacity(0.85))
|
|
Text("Customize Display")
|
|
.foregroundColor(.primary.opacity(0.85))
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.white)
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
.padding(4)
|
|
}
|
|
.sheet(isPresented: $showingDisplayView) {
|
|
if let manager = themeExpansion {
|
|
DisplayView().themeExpansionManager(manager)
|
|
} else {
|
|
DisplayView()
|
|
}
|
|
}
|
|
}
|
|
|
|
private var tabCustomizationCard: some View {
|
|
let selection = selectedTabIDs
|
|
let pinnedOptions = selectedTabIDs.compactMap { id in
|
|
tabOptions.first(where: { $0.id == id })
|
|
}
|
|
let unpinnedOptions = tabOptions.filter { !selection.contains($0.id) }
|
|
return glassCard {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Tab Bar")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
Text("Pick up to \(TabConfiguration.maxSelectableTabs) tabs to pin. Settings is always available as the final tab.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
ForEach(pinnedOptions) { option in
|
|
TabRow(option: option,
|
|
isPinned: true,
|
|
isFirst: pinnedOptions.first?.id == option.id,
|
|
isLast: pinnedOptions.last?.id == option.id,
|
|
isBeta: option.isBeta,
|
|
onMove: { moveTab(option.id, offset: $0) },
|
|
onToggle: { toggleTabOption(option, enable: $0) },
|
|
onSelect: { if $0 { tabSelection = option.id }; switchToTab(option.id) })
|
|
}
|
|
if !unpinnedOptions.isEmpty {
|
|
Divider()
|
|
ForEach(unpinnedOptions) { option in
|
|
TabRow(option: option,
|
|
isPinned: false,
|
|
isFirst: false,
|
|
isLast: false,
|
|
isBeta: option.isBeta,
|
|
onMove: { _ in },
|
|
onToggle: { toggleTabOption(option, enable: $0) },
|
|
onSelect: { _ in switchToTab(option.id) })
|
|
}
|
|
}
|
|
Text("\(selection.count) / \(TabConfiguration.maxSelectableTabs) slots used")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
if let message = tabSelectionMessage {
|
|
Text(message)
|
|
.font(.caption)
|
|
.foregroundColor(.orange)
|
|
}
|
|
}
|
|
.padding(4)
|
|
}
|
|
}
|
|
|
|
private var pairingCard: some View {
|
|
glassCard {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Pairing File")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Button {
|
|
isShowingPairingFilePicker = true
|
|
} label: {
|
|
HStack {
|
|
Image(systemName: "doc.badge.plus")
|
|
.font(.system(size: 18))
|
|
Text("Import New Pairing File")
|
|
.fontWeight(.medium)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 14)
|
|
.foregroundColor(accentColor.contrastText())
|
|
.background(accentColor, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
|
.stroke(Color.white.opacity(0.2), lineWidth: 0.5)
|
|
)
|
|
}
|
|
if showPairingFileMessage && !isImportingFile {
|
|
HStack {
|
|
Image(systemName: "checkmark.circle.fill").foregroundColor(.green)
|
|
Text("Pairing file successfully imported")
|
|
.font(.callout)
|
|
.foregroundColor(.green)
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 6)
|
|
.transition(.opacity)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var behaviorCard: some View {
|
|
glassCard {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Behavior")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
continuedProcessingToggle
|
|
Toggle(isOn: $overrideTXMDetection) {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text("Always Run Scripts")
|
|
.font(.subheadline.weight(.semibold))
|
|
.foregroundColor(.primary.opacity(0.9))
|
|
Text("Treat this device as TXM-capable to bypass hardware checks.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
.tint(accentColor)
|
|
}
|
|
.onChange(of: enableAdvancedOptions) { _, newValue in
|
|
if !newValue {
|
|
enableContinuedProcessing = false
|
|
enableAdvancedBetaOptions = false
|
|
enableTesting = false
|
|
}
|
|
}
|
|
.onChange(of: enableAdvancedBetaOptions) { _, newValue in
|
|
if !newValue {
|
|
enableTesting = false
|
|
}
|
|
}
|
|
.onChange(of: enableContinuedProcessing) { _, newValue in
|
|
if newValue {
|
|
ContinuedProcessingManager.shared.configureIfNeeded()
|
|
} else {
|
|
ContinuedProcessingManager.shared.cancelPendingTasks()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var advancedCard: some View {
|
|
glassCard {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Advanced")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Button(action: { openAppFolder() }) {
|
|
HStack {
|
|
Image(systemName: "folder")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Text("App Folder")
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Spacer()
|
|
Image(systemName: "chevron.right")
|
|
.font(.system(size: 14))
|
|
.foregroundColor(.white)
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
Button(action: { showDDIConfirmation = true }) {
|
|
HStack {
|
|
Image(systemName: "arrow.down.circle")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Text("Redownload DDI")
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.disabled(isRedownloadingDDI)
|
|
|
|
if isRedownloadingDDI {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
ProgressView(value: ddiDownloadProgress, total: 1.0)
|
|
.progressViewStyle(.linear)
|
|
.tint(accentColor)
|
|
Text(ddiStatusMessage)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
} else if let result = ddiResultMessage {
|
|
Text(result.text)
|
|
.font(.caption)
|
|
.foregroundColor(result.isError ? .red : .green)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var continuedProcessingToggle: some View {
|
|
Group {
|
|
if ContinuedProcessingManager.shared.isSupported {
|
|
Toggle("Allow Continued Processing", isOn: $enableContinuedProcessing)
|
|
.tint(accentColor)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Toggle("Allow Continued Processing", isOn: .constant(false))
|
|
.tint(accentColor)
|
|
.disabled(true)
|
|
Text("Requires iOS 26 or later.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var helpCard: some View {
|
|
glassCard {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Help")
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
|
|
Button(action: {
|
|
if let url = URL(string: "https://github.com/StephenDev0/StikDebug-Guide/blob/main/pairing_file.md") {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "questionmark.circle")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Text("Pairing File Guide")
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
Button(action: {
|
|
if let url = URL(string: "https://discord.gg/qahjXNTDwS") {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}) {
|
|
HStack {
|
|
Image(systemName: "questionmark.circle")
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Text("Need support? Join the Discord!")
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
Spacer()
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
|
|
}
|
|
}
|
|
}
|
|
|
|
private var versionInfo: some View {
|
|
let processInfo = ProcessInfo.processInfo
|
|
let txmLabel: String
|
|
if processInfo.isTXMOverridden {
|
|
txmLabel = "TXM (Override)"
|
|
} else {
|
|
txmLabel = processInfo.hasTXM ? "TXM" : "Non TXM"
|
|
}
|
|
return HStack {
|
|
Spacer()
|
|
Text("Version \(appVersion) • iOS \(UIDevice.current.systemVersion) • \(txmLabel)")
|
|
.font(.footnote)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
}
|
|
.padding(.top, 6)
|
|
}
|
|
|
|
// MARK: - Helpers (UI + logic)
|
|
|
|
private var selectedTabIDs: [String] {
|
|
TabConfiguration.sanitize(raw: enabledTabIdentifiers)
|
|
}
|
|
|
|
private func toggleTabOption(_ option: TabOption, enable: Bool) {
|
|
var ids = selectedTabIDs
|
|
if enable {
|
|
guard !ids.contains(option.id) else { return }
|
|
guard ids.count < TabConfiguration.maxSelectableTabs else {
|
|
tabSelectionMessage = "You can only pin \(TabConfiguration.maxSelectableTabs) tabs besides Settings."
|
|
return
|
|
}
|
|
ids.append(option.id)
|
|
} else {
|
|
ids.removeAll { $0 == option.id }
|
|
if ids.isEmpty {
|
|
ids = TabConfiguration.defaultIDs
|
|
}
|
|
}
|
|
tabSelectionMessage = nil
|
|
enabledTabIdentifiers = TabConfiguration.serialize(ids)
|
|
}
|
|
|
|
private func moveTab(_ id: String, offset: Int) {
|
|
var ids = selectedTabIDs
|
|
guard let currentIndex = ids.firstIndex(of: id) else { return }
|
|
let targetIndex = max(0, min(ids.count - 1, currentIndex + offset))
|
|
guard currentIndex != targetIndex else { return }
|
|
let element = ids.remove(at: currentIndex)
|
|
ids.insert(element, at: targetIndex)
|
|
enabledTabIdentifiers = TabConfiguration.serialize(ids)
|
|
}
|
|
|
|
private func switchToTab(_ id: String) {
|
|
NotificationCenter.default.post(name: .switchToTab, object: id)
|
|
}
|
|
|
|
private struct TabRow: View {
|
|
let option: TabOption
|
|
let isPinned: Bool
|
|
let isFirst: Bool
|
|
let isLast: Bool
|
|
let isBeta: Bool
|
|
let onMove: (Int) -> Void
|
|
let onToggle: (Bool) -> Void
|
|
let onSelect: (Bool) -> Void
|
|
|
|
var body: some View {
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
onSelect(isPinned)
|
|
} label: {
|
|
HStack(alignment: .center, spacing: 0) {
|
|
Image(systemName: option.icon)
|
|
.frame(width: 24, height: 24)
|
|
.foregroundColor(.primary.opacity(0.8))
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(option.title)
|
|
.font(.subheadline.weight(.semibold))
|
|
Text(option.detail)
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
}
|
|
.padding(.leading, 8)
|
|
if isBeta {
|
|
Spacer(minLength: 12)
|
|
Text("BETA")
|
|
.font(.caption2.weight(.bold))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 2)
|
|
.foregroundColor(.orange)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color.orange.opacity(0.15))
|
|
)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.buttonStyle(.plain)
|
|
|
|
if isPinned {
|
|
HStack(spacing: 8) {
|
|
Button {
|
|
onMove(-1)
|
|
} label: {
|
|
Image(systemName: "chevron.up")
|
|
}
|
|
.disabled(isFirst)
|
|
|
|
Button {
|
|
onMove(1)
|
|
} label: {
|
|
Image(systemName: "chevron.down")
|
|
}
|
|
.disabled(isLast)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
|
|
Toggle(isOn: Binding(
|
|
get: { isPinned },
|
|
set: { newValue in onToggle(newValue) }
|
|
)) {
|
|
EmptyView()
|
|
}
|
|
.labelsHidden()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func glassCard<Content: View>(@ViewBuilder content: () -> Content) -> some View {
|
|
MaterialCard {
|
|
content()
|
|
}
|
|
}
|
|
|
|
private func changeAppIcon(to iconName: String) {
|
|
selectedAppIcon = iconName
|
|
UIApplication.shared.setAlternateIconName(iconName == "AppIcon" ? nil : iconName) { error in
|
|
if let error = error {
|
|
print("Error changing app icon: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func iconButton(_ label: String, icon: String) -> some View {
|
|
Button(action: {
|
|
changeAppIcon(to: icon)
|
|
showIconPopover = false
|
|
}) {
|
|
HStack {
|
|
Image(uiImage: UIImage(named: icon) ?? UIImage())
|
|
.resizable()
|
|
.frame(width: 24, height: 24)
|
|
.clipShape(RoundedRectangle(cornerRadius: 5))
|
|
Text(label)
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
}
|
|
.padding()
|
|
.background(Color(UIColor.secondarySystemBackground))
|
|
.cornerRadius(10)
|
|
}
|
|
.padding(.horizontal)
|
|
}
|
|
|
|
private func openAppFolder() {
|
|
if let documentsURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
|
|
let path = documentsURL.absoluteString.replacingOccurrences(of: "file://", with: "shareddocuments://")
|
|
if let url = URL(string: path) {
|
|
UIApplication.shared.open(url, options: [:]) { success in
|
|
if !success {
|
|
print("Failed to open app folder")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func redownloadDDIPressed() {
|
|
guard !isRedownloadingDDI else { return }
|
|
Task {
|
|
await MainActor.run {
|
|
isRedownloadingDDI = true
|
|
ddiDownloadProgress = 0
|
|
ddiStatusMessage = "Preparing download…"
|
|
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.", false)
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
isRedownloadingDDI = false
|
|
ddiResultMessage = ("Failed to redownload DDI files: \(error.localizedDescription)", true)
|
|
}
|
|
}
|
|
}
|
|
scheduleDDIStatusDismiss()
|
|
}
|
|
|
|
private func scheduleDDIStatusDismiss() {
|
|
Task {
|
|
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
|
await MainActor.run {
|
|
if !isRedownloadingDDI {
|
|
ddiResultMessage = nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Helper Components
|
|
|
|
struct SettingsCard<Content: View>: View {
|
|
let content: Content
|
|
|
|
init(@ViewBuilder content: () -> Content) {
|
|
self.content = content()
|
|
}
|
|
|
|
var body: some View {
|
|
content
|
|
.background(Color(UIColor.secondarySystemBackground))
|
|
.cornerRadius(16)
|
|
.shadow(color: Color.black.opacity(0.08), radius: 3, x: 0, y: 2)
|
|
}
|
|
}
|
|
|
|
struct InfoRow: View {
|
|
var title: String
|
|
var value: String
|
|
|
|
var body: some View {
|
|
HStack {
|
|
Text(title)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
Text(value)
|
|
.foregroundColor(.primary)
|
|
.fontWeight(.medium)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
|
|
struct LinkRow: View {
|
|
var icon: String
|
|
var title: String
|
|
var url: String
|
|
|
|
var body: some View {
|
|
Button(action: {
|
|
if let url = URL(string: url) {
|
|
UIApplication.shared.open(url)
|
|
}
|
|
}) {
|
|
HStack(alignment: .center) {
|
|
Text(title)
|
|
.foregroundColor(.secondary)
|
|
Spacer()
|
|
Image(systemName: icon)
|
|
.font(.system(size: 18))
|
|
.foregroundColor(.white)
|
|
.frame(width: 24)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
}
|
|
|
|
struct ConsoleLogsView_Preview: PreviewProvider {
|
|
static var previews: some View {
|
|
ConsoleLogsView()
|
|
.themeExpansionManager(ThemeExpansionManager(previewUnlocked: true))
|
|
}
|
|
}
|
|
|
|
class FolderViewController: UIViewController {
|
|
func openAppFolder() {
|
|
let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
|
|
guard let documentsDirectory = paths.first else { return }
|
|
let containerPath = (documentsDirectory as NSString).deletingLastPathComponent
|
|
|
|
if let folderURL = URL(string: "shareddocuments://\(containerPath)") {
|
|
UIApplication.shared.open(folderURL, options: [:]) { success in
|
|
if !success {
|
|
let regularURL = URL(fileURLWithPath: containerPath)
|
|
UIApplication.shared.open(regularURL, options: [:], completionHandler: nil)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|