mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
* Heartbeat rework * remove some debug loggings * stik mini tool poc * Saperate JITEnableContext into extensions and put each feature into their own files. Fix local pairing file is removed after switched to external device by directly loading the remote device's pairing file in place. * custom origin & xmlhttprequest * Fix some memory leak * allow background script access idevice (codes are from the old JSSupport branch) Tests are not carried out but most of the js api should work. Do we need to obfuscate some functions' name? * Fix JITEnableContext singleton initializer race condition * Fix instprox callback --------- Co-authored-by: Stephen B <158498287+StephenDev0@users.noreply.github.com>
433 lines
17 KiB
Swift
433 lines
17 KiB
Swift
//
|
|
// ProfileView.swift
|
|
// StikDebug
|
|
//
|
|
// Created by s s on 2025/11/29.
|
|
//
|
|
import SwiftUI
|
|
import UniformTypeIdentifiers
|
|
|
|
class Profile: ObservableObject {
|
|
private static let dateFormatter = {
|
|
let f = DateFormatter()
|
|
f.dateStyle = .short
|
|
f.timeStyle = .medium
|
|
return f
|
|
}()
|
|
let data: Data
|
|
@Published var appName: String = "unknown"
|
|
@Published var appId: String = "unknown"
|
|
@Published var uuid: String
|
|
@Published var expirationDate: Date? = nil
|
|
@Published var plistDict: [String:Any]? = nil
|
|
|
|
init(data: Data) {
|
|
self.data = data
|
|
do {
|
|
let plistData = try CMSDecoderHelper.decodeCMSData(data)
|
|
let plistDict = try PropertyListSerialization.propertyList(from: plistData, format: nil)
|
|
if let plistDict = plistDict as? [String:Any] {
|
|
self.plistDict = plistDict
|
|
self.appName = plistDict["AppIDName"] as? String ?? "unknown"
|
|
if let entitlementsDict = plistDict["Entitlements"] as? [String:Any] {
|
|
self.appId = entitlementsDict["application-identifier"] as? String ?? "unknown"
|
|
}
|
|
self.expirationDate = plistDict["ExpirationDate"] as? Date
|
|
self.uuid = plistDict["UUID"] as? String ?? UUID().uuidString
|
|
} else {
|
|
self.uuid = UUID().uuidString
|
|
}
|
|
} catch {
|
|
appName = "Failed to decode this profile."
|
|
appId = error.localizedDescription
|
|
uuid = UUID().uuidString
|
|
}
|
|
}
|
|
|
|
var formattedDate: String {
|
|
get {
|
|
if let expirationDate {
|
|
return Profile.dateFormatter.string(from: expirationDate)
|
|
} else {
|
|
return "Unknown"
|
|
}
|
|
}
|
|
}
|
|
|
|
// from AltStore
|
|
var dateColor: Color {
|
|
get {
|
|
guard let expirationDate else {
|
|
return .secondary
|
|
}
|
|
let currentDate = Date()
|
|
let numberOfDays = expirationDate.numberOfCalendarDays(since: currentDate)
|
|
switch numberOfDays
|
|
{
|
|
case 2...3: return .refreshOrange
|
|
case 4...5: return .refreshYellow
|
|
case 6...: return .refreshGreen
|
|
default: return .refreshRed
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct ProfileDocument: FileDocument {
|
|
static var readableContentTypes: [UTType] { [UTType(filenameExtension: "mobileprovision")!] }
|
|
|
|
var data: Data? = nil
|
|
|
|
init() {}
|
|
|
|
init(data: Data) {
|
|
self.data = data
|
|
}
|
|
|
|
init(configuration: ReadConfiguration) throws {
|
|
self.data = configuration.file.regularFileContents ?? Data()
|
|
}
|
|
|
|
func fileWrapper(configuration: WriteConfiguration) throws -> FileWrapper {
|
|
FileWrapper(regularFileWithContents: data ?? Data())
|
|
}
|
|
}
|
|
|
|
// the following 2 extensions are from AltStore
|
|
extension Color {
|
|
static let refreshRed = Color(red: 1.0, green: 0.231, blue: 0.188)
|
|
static let refreshOrange = Color(red: 1.0, green: 0.584, blue: 0.0)
|
|
static let refreshYellow = Color(red: 1.0, green: 0.8, blue: 0.0)
|
|
static let refreshGreen = Color(red: 0.204, green: 0.780, blue: 0.348)
|
|
|
|
}
|
|
|
|
extension Date {
|
|
func numberOfCalendarDays(since date: Date) -> Int
|
|
{
|
|
let today = Calendar.current.startOfDay(for: self)
|
|
let previousDay = Calendar.current.startOfDay(for: date)
|
|
|
|
let components = Calendar.current.dateComponents([.day], from: previousDay, to: today)
|
|
return components.day!
|
|
}
|
|
}
|
|
|
|
struct ProfileView: View {
|
|
@State private var working = true
|
|
@State private var isExporterPresented = false
|
|
@State private var exportFileName = ""
|
|
@State private var exportDoc = ProfileDocument()
|
|
@State private var isImporterPresented = false
|
|
|
|
@State private var searchText = ""
|
|
@State private var alert = false
|
|
@State private var alertTitle = ""
|
|
@State private var alertMsg = ""
|
|
@State private var alertSuccess = false
|
|
|
|
@State private var profiles: [Profile] = []
|
|
@State private var confirmRemove = false
|
|
@State private var removeTargetName: String = ""
|
|
@State private var removeTargetUUID: String = ""
|
|
@State private var expandedAppIds: Set<String> = []
|
|
|
|
// Computed property to group profiles by appId and sort by expiration date
|
|
private var groupedProfiles: [String: [Profile]] {
|
|
Dictionary(grouping: profiles, by: { $0.appId })
|
|
.mapValues { profiles in
|
|
profiles.sorted { profile1, profile2 in
|
|
// Sort by expiration date descending (latest first)
|
|
guard let date1 = profile1.expirationDate else { return false }
|
|
guard let date2 = profile2.expirationDate else { return true }
|
|
return date1 > date2
|
|
}
|
|
}
|
|
}
|
|
|
|
private var sortedAppIds: [String] {
|
|
groupedProfiles.keys.sorted()
|
|
}
|
|
|
|
|
|
@AppStorage("customAccentColor") private var customAccentColorHex: String = ""
|
|
@AppStorage("appTheme") private var appThemeRaw: String = AppTheme.system.rawValue
|
|
@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 accentColor: Color { themeExpansion?.resolvedAccentColor(from: customAccentColorHex) ?? .blue }
|
|
|
|
var body: some View {
|
|
NavigationStack {
|
|
ZStack {
|
|
ThemedBackground(style: backgroundStyle)
|
|
.ignoresSafeArea()
|
|
|
|
ScrollView {
|
|
VStack(spacing: 20) {
|
|
infoCard
|
|
}
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 30)
|
|
}
|
|
|
|
if alert {
|
|
CustomErrorView(title: alertTitle,
|
|
message: alertMsg,
|
|
onDismiss: { alert = false },
|
|
messageType: alertSuccess ? .success : .error)
|
|
}
|
|
|
|
if confirmRemove {
|
|
CustomErrorView(
|
|
title: "Confirm Removal",
|
|
message: "Remove profile for \(removeTargetName) (UUID: \(removeTargetUUID))?\n**Apps associated with this profile may become unavailable.**",
|
|
onDismiss: { confirmRemove = false },
|
|
showButton: true,
|
|
primaryButtonText: "Remove",
|
|
secondaryButtonText: "Cancel",
|
|
onPrimaryButtonTap: {
|
|
Task { await removeProfile(uuid: removeTargetUUID) }
|
|
},
|
|
onSecondaryButtonTap: {
|
|
// Just dismiss
|
|
},
|
|
showSecondaryButton: true,
|
|
messageType: .info
|
|
)
|
|
}
|
|
|
|
}
|
|
.navigationTitle("Profiles")
|
|
.toolbar {
|
|
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
|
Button {
|
|
isImporterPresented = true
|
|
} label: {
|
|
Label("Add", systemImage: "plus")
|
|
}
|
|
|
|
Button {
|
|
Task {await loadProfiles()}
|
|
} label: {
|
|
Label("Reload", systemImage: "arrow.clockwise")
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
.onAppear { Task { await loadProfiles() } }
|
|
.fileImporter(
|
|
isPresented: $isImporterPresented,
|
|
allowedContentTypes: [UTType(filenameExtension: "mobileprovision")!],
|
|
allowsMultipleSelection: false
|
|
) { result in
|
|
Task { await handleImport(result: result) }
|
|
}
|
|
.fileExporter(
|
|
isPresented: $isExporterPresented,
|
|
document: exportDoc,
|
|
contentType: .data,
|
|
defaultFilename: exportFileName
|
|
) { result in
|
|
switch result {
|
|
case .success(let url):
|
|
print("Saved at \(url)")
|
|
case .failure(let error):
|
|
print("Error: \(error.localizedDescription)")
|
|
}
|
|
}
|
|
}
|
|
.preferredColorScheme(preferredScheme)
|
|
}
|
|
|
|
// MARK: - UI Sections
|
|
|
|
private var infoCard: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
|
|
if profiles.isEmpty {
|
|
Text(working ? "Loading Profiles" : "No profile available")
|
|
} else {
|
|
ForEach(sortedAppIds, id: \.self) { appId in
|
|
if let appProfiles = groupedProfiles[appId] {
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
// App ID header with expand/collapse control
|
|
HStack {
|
|
Text(appId)
|
|
.font(.headline)
|
|
.foregroundColor(.primary)
|
|
Spacer()
|
|
if appProfiles.count > 1 {
|
|
Button {
|
|
withAnimation(.easeInOut(duration: 0.3)) {
|
|
if expandedAppIds.contains(appId) {
|
|
expandedAppIds.remove(appId)
|
|
} else {
|
|
expandedAppIds.insert(appId)
|
|
}
|
|
}
|
|
} label: {
|
|
HStack(spacing: 4) {
|
|
Image(systemName: expandedAppIds.contains(appId) ? "chevron.up" : "chevron.down")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(.top, appId != sortedAppIds.first ? 8 : 0)
|
|
|
|
// Profiles for this app (only first if collapsed)
|
|
let displayProfiles: [Profile] = expandedAppIds.contains(appId) ? appProfiles : Array(appProfiles.prefix(1))
|
|
ForEach(Array(displayProfiles.enumerated()), id: \.element.uuid) { pair in
|
|
let index = pair.offset
|
|
let entry = pair.element
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(entry.appName).bold()
|
|
Text(entry.uuid).lineLimit(1)
|
|
Text("Expires: \(entry.formattedDate)")
|
|
.foregroundStyle(index == 0 ? entry.dateColor : Color.secondary)
|
|
}
|
|
.font(.caption.monospaced())
|
|
.foregroundColor(.secondary)
|
|
.textSelection(.enabled)
|
|
Spacer()
|
|
HStack(spacing: 10) {
|
|
profileActionButton(icon: "square.and.arrow.down", color: accentColor) {
|
|
saveProfile(profile: entry)
|
|
}
|
|
profileActionButton(icon: "trash", color: .refreshRed) {
|
|
removeProfilePrompt(entry: entry)
|
|
}
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.transition(.opacity.combined(with: .move(edge: .top)))
|
|
if entry.uuid != displayProfiles.last?.uuid {
|
|
Divider().background(Color.white.opacity(0.08))
|
|
}
|
|
}
|
|
.animation(.easeInOut(duration: 0.3), value: expandedAppIds)
|
|
}
|
|
|
|
// Divider between app groups
|
|
if appId != sortedAppIds.last {
|
|
Divider()
|
|
.background(Color.white.opacity(0.12))
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(.ultraThinMaterial)
|
|
.overlay(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.strokeBorder(Color.white.opacity(0.15), lineWidth: 1)
|
|
)
|
|
)
|
|
.clipShape(RoundedRectangle(cornerRadius: 20, style: .continuous))
|
|
.shadow(color: .black.opacity(0.15), radius: 12, x: 0, y: 4)
|
|
}
|
|
|
|
private func profileActionButton(icon: String, color: Color, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 16, weight: .semibold))
|
|
.foregroundColor(color)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
|
|
private func removeProfilePrompt(entry: Profile) {
|
|
removeTargetName = entry.appName
|
|
removeTargetUUID = entry.uuid
|
|
confirmRemove = true
|
|
}
|
|
|
|
func loadProfiles() async {
|
|
await MainActor.run { working = true }
|
|
do {
|
|
let profileDatas = try await Task.detached(priority: .userInitiated) {
|
|
try JITEnableContext.shared.fetchAllProfiles()
|
|
}.value
|
|
let parsedProfiles = profileDatas.map { Profile(data: $0) }
|
|
await MainActor.run {
|
|
self.profiles = parsedProfiles
|
|
self.working = false
|
|
}
|
|
} catch {
|
|
await MainActor.run {
|
|
alertMsg = error.localizedDescription
|
|
alertTitle = "Failed to Fetch Profiles"
|
|
alertSuccess = false
|
|
alert = true
|
|
working = false
|
|
}
|
|
}
|
|
}
|
|
|
|
func saveProfile(profile: Profile) {
|
|
exportFileName = "\(profile.appName).mobileprovision"
|
|
exportDoc = ProfileDocument(data: profile.data)
|
|
isExporterPresented = true
|
|
}
|
|
|
|
func removeProfile(uuid: String) async {
|
|
working = true
|
|
do {
|
|
try JITEnableContext.shared.removeProfile(withUUID: uuid)
|
|
alertMsg = "Profile removed successfully"
|
|
alertTitle = "Success"
|
|
alertSuccess = true
|
|
alert = true
|
|
// Reload profiles after removal
|
|
await loadProfiles()
|
|
} catch {
|
|
alertMsg = error.localizedDescription
|
|
alertTitle = "Failed to Remove Profile"
|
|
alertSuccess = false
|
|
alert = true
|
|
}
|
|
working = false
|
|
}
|
|
|
|
func handleImport(result: Result<[URL], Error>) async {
|
|
working = true
|
|
do {
|
|
let fileURL = try result.get().first
|
|
guard let fileURL = fileURL else {
|
|
throw NSError(domain: "ProfileView", code: -1, userInfo: [NSLocalizedDescriptionKey: "No file selected"])
|
|
}
|
|
|
|
// Start accessing security-scoped resource
|
|
let accessing = fileURL.startAccessingSecurityScopedResource()
|
|
defer {
|
|
if accessing {
|
|
fileURL.stopAccessingSecurityScopedResource()
|
|
}
|
|
}
|
|
|
|
let profileData = try Data(contentsOf: fileURL)
|
|
try JITEnableContext.shared.addProfile(profileData)
|
|
|
|
alertMsg = "Profile added successfully"
|
|
alertTitle = "Success"
|
|
alertSuccess = true
|
|
alert = true
|
|
|
|
// Reload profiles after adding
|
|
await loadProfiles()
|
|
} catch {
|
|
alertMsg = error.localizedDescription
|
|
alertTitle = "Failed to Add Profile"
|
|
alertSuccess = false
|
|
alert = true
|
|
}
|
|
working = false
|
|
}
|
|
|
|
}
|