mirror of
https://github.com/StikDebug/StikDebug.git
synced 2026-10-15 20:10:30 +00:00
Profiles tab rework, now App Expiry tab, update idevice
This commit is contained in:
parent
e9d9f3578e
commit
16f6615e66
7 changed files with 1192 additions and 155 deletions
|
|
@ -57,7 +57,7 @@ struct MainTabView: View {
|
|||
TabDescriptor(id: "deviceinfo", title: "Device Info", systemImage: "iphone.and.arrow.forward") { AnyView(DeviceInfoView()) }
|
||||
]
|
||||
if FeatureFlags.showBetaTabs {
|
||||
tabs.append(TabDescriptor(id: "profiles", title: "Profiles", systemImage: "magazine.fill") { AnyView(ProfileView()) })
|
||||
tabs.append(TabDescriptor(id: "profiles", title: "App Expiry", systemImage: "calendar.badge.clock") { AnyView(ProfileView()) })
|
||||
tabs.append(TabDescriptor(id: "processes", title: "Processes", systemImage: "rectangle.stack.person.crop") { AnyView(ProcessInspectorView()) })
|
||||
tabs.append(TabDescriptor(id: "devicelibrary", title: "Devices", systemImage: "list.bullet.rectangle") { AnyView(DeviceLibraryView()) })
|
||||
if FeatureFlags.isLocationSpoofingEnabled {
|
||||
|
|
|
|||
|
|
@ -120,36 +120,17 @@ struct ProfileView: View {
|
|||
@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] = []
|
||||
@AppStorage("cachedProfilesData") private var cachedProfilesData: Data?
|
||||
@State private var entries: [AppProfileStatus] = []
|
||||
@State private var expandedApps: Set<String> = []
|
||||
@State private var notMatchedProfiles: [AppProfileStatus] = []
|
||||
|
||||
@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
|
||||
|
|
@ -163,7 +144,6 @@ struct ProfileView: View {
|
|||
ZStack {
|
||||
ThemedBackground(style: backgroundStyle)
|
||||
.ignoresSafeArea()
|
||||
|
||||
ScrollView {
|
||||
VStack(spacing: 20) {
|
||||
infoCard
|
||||
|
|
@ -171,7 +151,6 @@ struct ProfileView: View {
|
|||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 30)
|
||||
}
|
||||
|
||||
if alert {
|
||||
CustomErrorView(title: alertTitle,
|
||||
message: alertMsg,
|
||||
|
|
@ -199,7 +178,7 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
}
|
||||
.navigationTitle("Profiles")
|
||||
.navigationTitle("App Expiry")
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .navigationBarTrailing) {
|
||||
Button {
|
||||
|
|
@ -209,7 +188,7 @@ struct ProfileView: View {
|
|||
}
|
||||
|
||||
Button {
|
||||
Task { await loadProfiles(force: true) }
|
||||
Task { await loadData(force: true) }
|
||||
} label: {
|
||||
Label("Reload", systemImage: "arrow.clockwise")
|
||||
}
|
||||
|
|
@ -217,7 +196,7 @@ struct ProfileView: View {
|
|||
}
|
||||
}
|
||||
|
||||
.onAppear { Task { await loadProfiles() } }
|
||||
.onAppear { Task { await loadData() } }
|
||||
.fileImporter(
|
||||
isPresented: $isImporterPresented,
|
||||
allowedContentTypes: [UTType(filenameExtension: "mobileprovision")!],
|
||||
|
|
@ -245,93 +224,125 @@ struct ProfileView: View {
|
|||
// MARK: - UI Sections
|
||||
|
||||
private var infoCard: some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack {
|
||||
HStack {
|
||||
Text("Sideloaded Apps")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
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 {
|
||||
LazyVStack(alignment: .leading, spacing: 14) {
|
||||
if entries.isEmpty {
|
||||
Text(working ? "Loading Apps" : "No sideloaded apps found")
|
||||
} else {
|
||||
ForEach(entries) { entry in
|
||||
appRow(for: entry)
|
||||
if entry.id != entries.last?.id {
|
||||
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)
|
||||
|
||||
if !notMatchedProfiles.isEmpty {
|
||||
HStack {
|
||||
Text("Other Profiles")
|
||||
.padding(.top, 10.0)
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
LazyVStack(alignment: .leading, spacing: 14) {
|
||||
ForEach(notMatchedProfiles) { entry in
|
||||
appRow(for: entry)
|
||||
if entry.id != notMatchedProfiles.last?.id {
|
||||
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 appRow(for entry: AppProfileStatus) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(entry.name)
|
||||
.font(.headline)
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if let match = entry.bestMatchingProfile {
|
||||
Text("Expires: \(match.profile.formattedDate)")
|
||||
.foregroundStyle(match.profile.dateColor)
|
||||
.font(.caption)
|
||||
} else {
|
||||
Text("No matching profile")
|
||||
.font(.caption)
|
||||
.foregroundColor(.refreshRed)
|
||||
}
|
||||
}
|
||||
Text(entry.id)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
if let recent = entry.mostRecentProfile {
|
||||
profileRow(match: recent, isMostRecent: true)
|
||||
}
|
||||
if let best = entry.bestMatchingProfile, best.profile.uuid != entry.mostRecentProfile?.profile.uuid {
|
||||
profileRow(match: best, isMostRecent: false)
|
||||
}
|
||||
if entry.profileMatches.count > 1 {
|
||||
if expandedApps.contains(entry.id) {
|
||||
ForEach(entry.profileMatches.dropFirst(recentAndBestCount(for: entry)), id: \.profile.uuid) { match in
|
||||
profileRow(match: match, isMostRecent: false)
|
||||
}
|
||||
}
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.25)) {
|
||||
if expandedApps.contains(entry.id) {
|
||||
expandedApps.remove(entry.id)
|
||||
} else {
|
||||
expandedApps.insert(entry.id)
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(expandedApps.contains(entry.id) ? "Hide older profiles" : "Show older profiles",
|
||||
systemImage: expandedApps.contains(entry.id) ? "chevron.up" : "chevron.down")
|
||||
.font(.caption)
|
||||
.labelStyle(.titleAndIcon)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.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 {
|
||||
|
|
@ -343,54 +354,126 @@ struct ProfileView: View {
|
|||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
private func removeProfilePrompt(entry: Profile) {
|
||||
removeTargetName = entry.appName
|
||||
removeTargetUUID = entry.uuid
|
||||
confirmRemove = true
|
||||
}
|
||||
|
||||
private func restoreCachedProfilesIfNeeded() async -> Bool {
|
||||
guard profiles.isEmpty, let cachedProfilesData else { return false }
|
||||
|
||||
let cachedDatas: [Data]? = try? await Task.detached(priority: .userInitiated) {
|
||||
try? JSONDecoder().decode([Data].self, from: cachedProfilesData)
|
||||
}.value
|
||||
|
||||
guard let cachedDatas, !cachedDatas.isEmpty else { return false }
|
||||
let restoredProfiles = cachedDatas.map { Profile(data: $0) }
|
||||
await MainActor.run {
|
||||
guard self.profiles.isEmpty else { return }
|
||||
self.profiles = restoredProfiles
|
||||
self.working = false
|
||||
private func recentAndBestCount(for entry: AppProfileStatus) -> Int {
|
||||
let distinctBest : Int
|
||||
if let bestMatchingProfile = entry.bestMatchingProfile, let recent = entry.mostRecentProfile {
|
||||
distinctBest = (bestMatchingProfile.profile.uuid != recent.profile.uuid) ? 1 : 0
|
||||
} else {
|
||||
distinctBest = 0
|
||||
}
|
||||
return true
|
||||
|
||||
return 1 + distinctBest
|
||||
}
|
||||
|
||||
func loadProfiles(force: Bool = false) async {
|
||||
if !force {
|
||||
if !profiles.isEmpty {
|
||||
await MainActor.run { working = false }
|
||||
return
|
||||
private func profileRow(match: ProfileMatch, isMostRecent: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
Text(isMostRecent ? "Most Recent Profile" : "Profile")
|
||||
.font(.subheadline.bold())
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
Text(match.profile.formattedDate)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(match.profile.dateColor)
|
||||
}
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(match.profile.appName)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("UUID: \(match.profile.uuid)")
|
||||
.font(.caption.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
Spacer()
|
||||
HStack {
|
||||
profileActionButton(icon: "square.and.arrow.down", color: accentColor) {
|
||||
saveProfile(profile: match.profile)
|
||||
}
|
||||
profileActionButton(icon: "trash", color: .refreshRed) {
|
||||
removeProfilePrompt(entry: match.profile)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !match.missingEntitlements.isEmpty {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Missing entitlements:")
|
||||
.font(.caption)
|
||||
.foregroundColor(.refreshRed)
|
||||
ForEach(Array(formattedMissingLines(from: match.missingEntitlements).enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.caption.monospaced())
|
||||
.foregroundColor(.refreshRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
if await restoreCachedProfilesIfNeeded() { return }
|
||||
}
|
||||
|
||||
.padding(10)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.fill(Color.white.opacity(0.05))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||
.strokeBorder(match.missingEntitlements.isEmpty ? Color.white.opacity(0.1) : Color.refreshRed.opacity(0.6), lineWidth: 1)
|
||||
)
|
||||
)
|
||||
.background(
|
||||
match.missingEntitlements.isEmpty ? Color.clear : Color.refreshRed.opacity(0.08)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
|
||||
}
|
||||
|
||||
// MARK: - Data loading
|
||||
|
||||
private func loadData(force: Bool = false) async {
|
||||
if !force, !entries.isEmpty {
|
||||
working = false
|
||||
return
|
||||
}
|
||||
await MainActor.run { working = true }
|
||||
do {
|
||||
let profileDatas = try await Task.detached(priority: .userInitiated) {
|
||||
try JITEnableContext.shared.fetchAllProfiles()
|
||||
let (profiles, apps) = try await Task.detached(priority: .userInitiated) { () throws -> ([Profile], [SideloadedApp]) in
|
||||
let profileDatas = try JITEnableContext.shared.fetchAllProfiles()
|
||||
let parsedProfiles = profileDatas.map { Profile(data: $0) }
|
||||
let rawApps = try JITEnableContext.shared.getSideloadedApps()
|
||||
let parsedApps = rawApps.compactMap { item -> SideloadedApp? in
|
||||
guard let dict = item as? [String: Any] else { return nil }
|
||||
return SideloadedApp(dictionary: dict)
|
||||
}
|
||||
return (parsedProfiles, parsedApps)
|
||||
}.value
|
||||
let parsedProfiles = profileDatas.map { Profile(data: $0) }
|
||||
let encodedProfiles = try? JSONEncoder().encode(profileDatas)
|
||||
let groupedProfiles = Dictionary(grouping: profiles) { profileIdentifier(from: $0) }
|
||||
.mapValues { profiles in
|
||||
profiles.sorted { lhs, rhs in
|
||||
let leftDate = lhs.expirationDate ?? .distantPast
|
||||
let rightDate = rhs.expirationDate ?? .distantPast
|
||||
return leftDate > rightDate
|
||||
}
|
||||
}
|
||||
|
||||
let wildcardGroups = groupedProfiles.filter { $0.key.contains("*") }
|
||||
let appsSorted = apps.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
let computedEntries = appsSorted.map { app in
|
||||
let matches = profileMatches(for: app, groupedProfiles: groupedProfiles, wildcardProfiles: wildcardGroups)
|
||||
return AppProfileStatus(app: app, profileMatches: matches)
|
||||
}
|
||||
|
||||
let appIds = apps.compactMap { $0.applicationIdentifier }
|
||||
notMatchedProfiles = groupedProfiles
|
||||
.filter { !appIds.contains($0.key) }
|
||||
.map { appId, profiles in
|
||||
AppProfileStatus(appId: appId, profiles: profiles)
|
||||
}
|
||||
|
||||
await MainActor.run {
|
||||
self.profiles = parsedProfiles
|
||||
self.cachedProfilesData = encodedProfiles
|
||||
self.entries = computedEntries
|
||||
self.working = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
alertTitle = "Failed to load"
|
||||
alertMsg = error.localizedDescription
|
||||
alertTitle = "Failed to Fetch Profiles"
|
||||
alertSuccess = false
|
||||
alert = true
|
||||
working = false
|
||||
|
|
@ -398,6 +481,117 @@ struct ProfileView: View {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private func profileMatches(for app: SideloadedApp, groupedProfiles: [String: [Profile]], wildcardProfiles: [String: [Profile]]) -> [ProfileMatch] {
|
||||
let targetIdentifier = app.applicationIdentifier ?? app.bundleId
|
||||
var collected: [Profile] = []
|
||||
if let direct = groupedProfiles[targetIdentifier] {
|
||||
collected.append(contentsOf: direct)
|
||||
}
|
||||
if targetIdentifier != app.bundleId, let bundleMatches = groupedProfiles[app.bundleId] {
|
||||
collected.append(contentsOf: bundleMatches)
|
||||
}
|
||||
for (pattern, profiles) in wildcardProfiles {
|
||||
if wildcardMatch(pattern, value: targetIdentifier) || wildcardMatch(pattern, value: app.bundleId) {
|
||||
collected.append(contentsOf: profiles)
|
||||
}
|
||||
}
|
||||
var seen = Set<String>()
|
||||
let uniqueProfiles = collected.filter { profile in
|
||||
guard !profile.uuid.isEmpty else { return false }
|
||||
let inserted = seen.insert(profile.uuid).inserted
|
||||
return inserted
|
||||
}
|
||||
let matches = uniqueProfiles.map { profile -> ProfileMatch in
|
||||
let profileEntitlements = entitlements(from: profile)
|
||||
let missing = missingEntitlements(appEntitlements: app.entitlements, profileEntitlements: profileEntitlements)
|
||||
return ProfileMatch(profile: profile, missingEntitlements: missing)
|
||||
}
|
||||
return matches.sorted { lhs, rhs in
|
||||
let leftDate = lhs.profile.expirationDate ?? .distantPast
|
||||
let rightDate = rhs.profile.expirationDate ?? .distantPast
|
||||
return leftDate > rightDate
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Entitlement helpers
|
||||
|
||||
private func profileIdentifier(from profile: Profile) -> String {
|
||||
entitlements(from: profile)["application-identifier"] as? String ?? profile.appId
|
||||
}
|
||||
|
||||
private func entitlements(from profile: Profile) -> [String: Any] {
|
||||
profile.plistDict?["Entitlements"] as? [String: Any] ?? [:]
|
||||
}
|
||||
|
||||
private func missingEntitlements(appEntitlements: [String: Any], profileEntitlements: [String: Any]) -> [MissingNode] {
|
||||
var missing: [MissingNode] = []
|
||||
for (key, appValue) in appEntitlements {
|
||||
guard let profileValue = profileEntitlements[key] else {
|
||||
missing.append(MissingNode(name: key, children: []))
|
||||
continue
|
||||
}
|
||||
if let node = diffEntitlement(name: key, appValue: appValue, profileValue: profileValue) {
|
||||
missing.append(node)
|
||||
}
|
||||
}
|
||||
return missing.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private func diffEntitlement(name: String, appValue: Any, profileValue: Any) -> MissingNode? {
|
||||
switch (profileValue, appValue) {
|
||||
case let (p as String, a as String):
|
||||
return wildcardMatch(p, value: a) ? nil : MissingNode(name: name, children: [])
|
||||
case let (p as [Any], a as [Any]):
|
||||
let aStrings = a.compactMap { normalizedString(from: $0) }
|
||||
let pStrings = p.compactMap { normalizedString(from: $0) }
|
||||
let missingValues = aStrings.filter { target in
|
||||
!pStrings.contains(where: { wildcardMatch($0, value: target) })
|
||||
}
|
||||
guard !missingValues.isEmpty else { return nil }
|
||||
let children = missingValues.map { MissingNode(name: $0, children: []) }
|
||||
return MissingNode(name: name, children: children)
|
||||
case let (p as [Any], a):
|
||||
let aString = normalizedString(from: a)
|
||||
let pStrings = p.compactMap { normalizedString(from: $0) }
|
||||
if let aString, pStrings.contains(where: { wildcardMatch($0, value: aString) }) {
|
||||
return nil
|
||||
}
|
||||
return MissingNode(name: name, children: aString.map { [MissingNode(name: $0, children: [])] } ?? [])
|
||||
case let (p as [String: Any], a as [String: Any]):
|
||||
let childMissing = missingEntitlements(appEntitlements: a, profileEntitlements: p)
|
||||
return childMissing.isEmpty ? nil : MissingNode(name: name, children: childMissing)
|
||||
case let (p as NSNumber, a as NSNumber):
|
||||
return p == a ? nil : MissingNode(name: name, children: [])
|
||||
case let (p as Bool, a as Bool):
|
||||
return p == a ? nil : MissingNode(name: name, children: [])
|
||||
default:
|
||||
if let pObj = profileValue as? NSObject, let aObj = appValue as? NSObject, pObj == aObj {
|
||||
return nil
|
||||
}
|
||||
return MissingNode(name: name, children: [])
|
||||
}
|
||||
}
|
||||
|
||||
private func wildcardMatch(_ pattern: String, value: String) -> Bool {
|
||||
let escaped = NSRegularExpression.escapedPattern(for: pattern)
|
||||
.replacingOccurrences(of: "\\*", with: ".*")
|
||||
let regex = "^" + escaped + "$"
|
||||
return value.range(of: regex, options: .regularExpression) != nil
|
||||
}
|
||||
|
||||
private func normalizedString(from value: Any) -> String? {
|
||||
if let str = value as? String { return str }
|
||||
if let num = value as? NSNumber { return num.stringValue }
|
||||
return nil
|
||||
}
|
||||
|
||||
private func removeProfilePrompt(entry: Profile) {
|
||||
removeTargetName = entry.appName
|
||||
removeTargetUUID = entry.uuid
|
||||
confirmRemove = true
|
||||
}
|
||||
|
||||
func saveProfile(profile: Profile) {
|
||||
exportFileName = "\(profile.appName).mobileprovision"
|
||||
exportDoc = ProfileDocument(data: profile.data)
|
||||
|
|
@ -413,7 +607,7 @@ struct ProfileView: View {
|
|||
alertSuccess = true
|
||||
alert = true
|
||||
// Reload profiles after removal
|
||||
await loadProfiles(force: true)
|
||||
await loadData(force: true)
|
||||
} catch {
|
||||
alertMsg = error.localizedDescription
|
||||
alertTitle = "Failed to Remove Profile"
|
||||
|
|
@ -448,7 +642,7 @@ struct ProfileView: View {
|
|||
alert = true
|
||||
|
||||
// Reload profiles after adding
|
||||
await loadProfiles(force: true)
|
||||
await loadData(force: true)
|
||||
} catch {
|
||||
alertMsg = error.localizedDescription
|
||||
alertTitle = "Failed to Add Profile"
|
||||
|
|
@ -457,5 +651,90 @@ struct ProfileView: View {
|
|||
}
|
||||
working = false
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private struct MissingNode: Identifiable {
|
||||
let id = UUID()
|
||||
let name: String
|
||||
let children: [MissingNode]
|
||||
}
|
||||
|
||||
private func formattedMissingLines(from nodes: [MissingNode]) -> [String] {
|
||||
var lines: [String] = []
|
||||
for node in nodes {
|
||||
lines.append(node.name)
|
||||
lines.append(contentsOf: formattedMissingChildren(node.children, indentLevel: 1))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private func formattedMissingChildren(_ children: [MissingNode], indentLevel: Int) -> [String] {
|
||||
var lines: [String] = []
|
||||
let prefix = String(repeating: " ", count: max(indentLevel - 1, 0)) + "┗ "
|
||||
for child in children {
|
||||
lines.append(prefix + child.name)
|
||||
lines.append(contentsOf: formattedMissingChildren(child.children, indentLevel: indentLevel + 1))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
private struct SideloadedApp: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let bundleId: String
|
||||
let entitlements: [String: Any]
|
||||
|
||||
var applicationIdentifier: String? {
|
||||
entitlements["application-identifier"] as? String
|
||||
}
|
||||
|
||||
init?(dictionary: [String: Any]) {
|
||||
guard let bundleId = dictionary["CFBundleIdentifier"] as? String else { return nil }
|
||||
self.bundleId = bundleId
|
||||
self.id = bundleId
|
||||
let displayName = dictionary["CFBundleDisplayName"] as? String
|
||||
let name = dictionary["CFBundleName"] as? String
|
||||
self.name = displayName ?? name ?? bundleId
|
||||
self.entitlements = dictionary["Entitlements"] as? [String: Any] ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
private struct ProfileMatch: Identifiable {
|
||||
let id: String
|
||||
let profile: Profile
|
||||
let missingEntitlements: [MissingNode]
|
||||
|
||||
init(profile: Profile, missingEntitlements: [MissingNode]) {
|
||||
self.id = profile.uuid
|
||||
self.profile = profile
|
||||
self.missingEntitlements = missingEntitlements
|
||||
}
|
||||
}
|
||||
|
||||
private struct AppProfileStatus: Identifiable {
|
||||
let id: String
|
||||
let name: String
|
||||
let bundleId: String
|
||||
let entitlements: [String: Any]
|
||||
let profileMatches: [ProfileMatch]
|
||||
|
||||
var mostRecentProfile: ProfileMatch? { profileMatches.first }
|
||||
var bestMatchingProfile: ProfileMatch? { profileMatches.first(where: { $0.missingEntitlements.isEmpty }) }
|
||||
|
||||
init(app: SideloadedApp, profileMatches: [ProfileMatch]) {
|
||||
self.id = app.id
|
||||
self.name = app.name
|
||||
self.bundleId = app.bundleId
|
||||
self.entitlements = app.entitlements
|
||||
self.profileMatches = profileMatches
|
||||
|
||||
}
|
||||
|
||||
init(appId: String, profiles:[Profile]) {
|
||||
self.id = appId
|
||||
self.name = "-"
|
||||
self.bundleId = appId
|
||||
self.profileMatches = profiles.map { ProfileMatch(profile: $0, missingEntitlements: []) }
|
||||
self.entitlements = [:]
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -91,9 +91,8 @@ struct SettingsView: View {
|
|||
TabOption(id: "scripts", title: "Scripts", detail: "Manage automation scripts", icon: "scroll", isBeta: false)
|
||||
]
|
||||
options.append(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: "profiles", title: "Profiles", detail: "Install/remove profiles", icon: "magazine", isBeta: true))
|
||||
}
|
||||
options.append(TabOption(id: "profiles", title: "App Expiry", detail: "Check app expiration date, install/remove profiles", icon: "calendar.badge.clock", 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))
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ typedef void (^SyslogErrorHandler)(NSError *error);
|
|||
- (NSDictionary<NSString*, NSString*>*)getAppListWithError:(NSError**)error;
|
||||
- (NSDictionary<NSString*, NSString*>*)getAllAppsWithError:(NSError**)error;
|
||||
- (NSDictionary<NSString*, NSString*>*)getHiddenSystemAppsWithError:(NSError**)error;
|
||||
- (NSArray<NSDictionary*>*)getSideloadedAppsWithError:(NSError**)error;
|
||||
@end
|
||||
|
||||
@interface JITEnableContext(Syslog)
|
||||
|
|
|
|||
|
|
@ -149,6 +149,65 @@ static NSDictionary<NSString*, NSString*> *buildAppDictionary(void *apps,
|
|||
return result;
|
||||
}
|
||||
|
||||
static NSArray<NSDictionary*>* getSideloadedApps(IdeviceProviderHandle *provider,
|
||||
NSString **error) {
|
||||
InstallationProxyClientHandle *client = NULL;
|
||||
IdeviceFfiError* err = installation_proxy_connect(provider, &client);
|
||||
if (err) {
|
||||
*error = [NSString stringWithFormat:@"Failed to connect to installation proxy: %s", err->message];
|
||||
idevice_error_free(err);
|
||||
return nil;
|
||||
}
|
||||
|
||||
plist_t *apps = NULL;
|
||||
size_t count = 0;
|
||||
err = installation_proxy_get_apps(client, NULL, NULL, 0, (void*)&apps, &count);
|
||||
if (err) {
|
||||
*error = [NSString stringWithFormat:@"Failed to get apps: %s", err->message];
|
||||
idevice_error_free(err);
|
||||
installation_proxy_client_free(client);
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSMutableArray<NSDictionary*>* result = [NSMutableArray new];
|
||||
|
||||
for (size_t i = 0; i < count; i++) {
|
||||
plist_t app = ((plist_t *)apps)[i];
|
||||
|
||||
plist_t profileValidatedNode = 0;
|
||||
if(!(profileValidatedNode = plist_dict_get_item(app, "ProfileValidated"))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
char* bin = 0;
|
||||
uint32_t size = 0;
|
||||
plist_to_bin(app, &bin, &size);
|
||||
if(!bin || size == 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
NSData* d = [NSData dataWithBytes:bin length:size];
|
||||
NSError* err;
|
||||
NSDictionary* dict = [NSPropertyListSerialization propertyListWithData:d options:0 format:nil error:&err];
|
||||
plist_mem_free(bin);
|
||||
|
||||
if(err) {
|
||||
continue;
|
||||
}
|
||||
|
||||
[result addObject:dict];
|
||||
|
||||
}
|
||||
|
||||
installation_proxy_client_free(client);
|
||||
for(int i = 0; i < count; ++i) {
|
||||
plist_free(apps[i]);
|
||||
}
|
||||
idevice_data_free((uint8_t *)apps, sizeof(plist_t)*count);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
static NSDictionary<NSString*, NSString*> *performAppQuery(IdeviceProviderHandle *provider,
|
||||
BOOL requireGetTaskAllow,
|
||||
NSString **error,
|
||||
|
|
@ -265,6 +324,21 @@ UIImage* getAppIcon(IdeviceProviderHandle* provider, NSString* bundleID, NSStrin
|
|||
return apps;
|
||||
}
|
||||
|
||||
- (NSArray<NSDictionary*>*)getSideloadedAppsWithError:(NSError**)error {
|
||||
[self ensureHeartbeatWithError:error];
|
||||
if(*error) {
|
||||
return nil;
|
||||
}
|
||||
|
||||
NSString* errorStr = nil;
|
||||
NSArray<NSDictionary*>* apps = getSideloadedApps(provider, &errorStr);
|
||||
if (errorStr) {
|
||||
*error = [self errorWithStr:errorStr code:-17];
|
||||
return nil;
|
||||
}
|
||||
return apps;
|
||||
}
|
||||
|
||||
- (UIImage*)getAppIconWithBundleId:(NSString*)bundleId error:(NSError**)error {
|
||||
[self ensureHeartbeatWithError:error];
|
||||
if(*error) {
|
||||
|
|
|
|||
|
|
@ -80,6 +80,8 @@ typedef struct AppServiceHandle AppServiceHandle;
|
|||
|
||||
typedef struct CoreDeviceProxyHandle CoreDeviceProxyHandle;
|
||||
|
||||
typedef struct CrashReportCopyMobileHandle CrashReportCopyMobileHandle;
|
||||
|
||||
/**
|
||||
* Opaque handle to a DebugProxyClient
|
||||
*/
|
||||
|
|
@ -94,6 +96,8 @@ typedef struct DiagnosticsServiceHandle DiagnosticsServiceHandle;
|
|||
|
||||
typedef struct HeartbeatClientHandle HeartbeatClientHandle;
|
||||
|
||||
typedef struct HouseArrestClientHandle HouseArrestClientHandle;
|
||||
|
||||
/**
|
||||
* Opaque C-compatible handle to an Idevice connection
|
||||
*/
|
||||
|
|
@ -121,6 +125,8 @@ typedef struct LockdowndClientHandle LockdowndClientHandle;
|
|||
|
||||
typedef struct MisagentClientHandle MisagentClientHandle;
|
||||
|
||||
typedef struct NotificationProxyClientHandle NotificationProxyClientHandle;
|
||||
|
||||
typedef struct OsTraceRelayClientHandle OsTraceRelayClientHandle;
|
||||
|
||||
typedef struct OsTraceRelayReceiverHandle OsTraceRelayReceiverHandle;
|
||||
|
|
@ -150,6 +156,8 @@ typedef struct RsdHandshakeHandle RsdHandshakeHandle;
|
|||
*/
|
||||
typedef struct ScreenshotClientHandle ScreenshotClientHandle;
|
||||
|
||||
typedef struct ScreenshotrClientHandle ScreenshotrClientHandle;
|
||||
|
||||
typedef struct SpringBoardServicesClientHandle SpringBoardServicesClientHandle;
|
||||
|
||||
typedef struct SysdiagnoseStreamHandle SysdiagnoseStreamHandle;
|
||||
|
|
@ -175,6 +183,11 @@ typedef struct IdeviceFfiError {
|
|||
const char *message;
|
||||
} IdeviceFfiError;
|
||||
|
||||
/**
|
||||
* Stub to avoid header problems
|
||||
*/
|
||||
typedef void *plist_t;
|
||||
|
||||
/**
|
||||
* File information structure for C bindings
|
||||
*/
|
||||
|
|
@ -265,11 +278,6 @@ typedef struct DebugserverCommandHandle {
|
|||
uintptr_t argv_count;
|
||||
} DebugserverCommandHandle;
|
||||
|
||||
/**
|
||||
* Stub to avoid header problems
|
||||
*/
|
||||
typedef void *plist_t;
|
||||
|
||||
typedef struct SyslogLabel {
|
||||
const char *subsystem;
|
||||
const char *category;
|
||||
|
|
@ -333,6 +341,14 @@ typedef struct CRsdServiceArray {
|
|||
size_t count;
|
||||
} CRsdServiceArray;
|
||||
|
||||
/**
|
||||
* Represents a screenshot data buffer
|
||||
*/
|
||||
typedef struct ScreenshotData {
|
||||
uint8_t *data;
|
||||
uintptr_t length;
|
||||
} ScreenshotData;
|
||||
|
||||
/**
|
||||
* Creates a new Idevice connection
|
||||
*
|
||||
|
|
@ -446,6 +462,14 @@ struct IdeviceFfiError *idevice_start_session(struct IdeviceHandle *idevice,
|
|||
*/
|
||||
void idevice_free(struct IdeviceHandle *idevice);
|
||||
|
||||
/**
|
||||
* Frees a stream handle
|
||||
*
|
||||
* # Safety
|
||||
* Pass a valid handle allocated by this library
|
||||
*/
|
||||
void idevice_stream_free(struct ReadWriteOpaque *stream_handle);
|
||||
|
||||
/**
|
||||
* Frees a string allocated by this library
|
||||
*
|
||||
|
|
@ -470,6 +494,27 @@ void idevice_string_free(char *string);
|
|||
*/
|
||||
void idevice_data_free(uint8_t *data, uintptr_t len);
|
||||
|
||||
/**
|
||||
* Frees an array of plists allocated by this library
|
||||
*
|
||||
* # Safety
|
||||
* `data` must be a pointer to data allocated by this library,
|
||||
* NOT data allocated by libplist.
|
||||
*/
|
||||
void idevice_plist_array_free(plist_t *plists, uintptr_t len);
|
||||
|
||||
/**
|
||||
* Frees a slice of pointers allocated by this library that had an underlying
|
||||
* vec creation.
|
||||
*
|
||||
* The following functions use an underlying vec and are safe to use:
|
||||
* - idevice_usbmuxd_get_devices
|
||||
*
|
||||
* # Safety
|
||||
* Pass a valid pointer passed by the Vec creating functions
|
||||
*/
|
||||
void idevice_outer_slice_free(void *slice, uintptr_t len);
|
||||
|
||||
/**
|
||||
* Connects the adapter to a specific port
|
||||
*
|
||||
|
|
@ -592,6 +637,23 @@ struct IdeviceFfiError *adapter_recv(struct AdapterStreamHandle *handle,
|
|||
struct IdeviceFfiError *afc_client_connect(struct IdeviceProviderHandle *provider,
|
||||
struct AfcClientHandle **client);
|
||||
|
||||
/**
|
||||
* Connects to the AFC2 service using a TCP provider
|
||||
*
|
||||
* # Arguments
|
||||
* * [`provider`] - An IdeviceProvider
|
||||
* * [`client`] - On success, will be set to point to a newly allocated AfcClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `provider` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *afc2_client_connect(struct IdeviceProviderHandle *provider,
|
||||
struct AfcClientHandle **client);
|
||||
|
||||
/**
|
||||
* Creates a new AfcClient from an existing Idevice connection
|
||||
*
|
||||
|
|
@ -786,12 +848,13 @@ struct IdeviceFfiError *afc_file_open(struct AfcClientHandle *client,
|
|||
struct IdeviceFfiError *afc_file_close(struct AfcFileHandle *handle);
|
||||
|
||||
/**
|
||||
* Reads data from an open file
|
||||
* Reads data from an open file. This advances the cursor of the file.
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - File handle to read from
|
||||
* * [`data`] - Will be set to point to the read data
|
||||
* * [`length`] - Will be set to the length of the read data
|
||||
* * [`len`] - Number of bytes to read from the file
|
||||
* * [`bytes_read`] - The number of bytes read from the file
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
|
|
@ -799,7 +862,75 @@ struct IdeviceFfiError *afc_file_close(struct AfcFileHandle *handle);
|
|||
* # Safety
|
||||
* All pointers must be valid and non-null
|
||||
*/
|
||||
struct IdeviceFfiError *afc_file_read(struct AfcFileHandle *handle, uint8_t **data, size_t *length);
|
||||
struct IdeviceFfiError *afc_file_read(struct AfcFileHandle *handle,
|
||||
uint8_t **data,
|
||||
uintptr_t len,
|
||||
size_t *bytes_read);
|
||||
|
||||
/**
|
||||
* Reads all data from an open file.
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - File handle to read from
|
||||
* * [`data`] - Will be set to point to the read data
|
||||
* * [`length`] - The number of bytes read from the file
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* All pointers must be valid and non-null
|
||||
*/
|
||||
struct IdeviceFfiError *afc_file_read_entire(struct AfcFileHandle *handle,
|
||||
uint8_t **data,
|
||||
size_t *length);
|
||||
|
||||
/**
|
||||
* Moves the read/write cursor in an open file.
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - File handle whose cursor should be moved
|
||||
* * [`offset`] - Distance to move the cursor, interpreted based on `whence`
|
||||
* * [`whence`] - Origin used for the seek operation:
|
||||
* * `0` — Seek from the start of the file (`SeekFrom::Start`)
|
||||
* * `1` — Seek from the current cursor position (`SeekFrom::Current`)
|
||||
* * `2` — Seek from the end of the file (`SeekFrom::End`)
|
||||
* * [`new_pos`] - Output parameter; will be set to the new absolute cursor position
|
||||
*
|
||||
* # Returns
|
||||
* An [`IdeviceFfiError`] on error, or null on success.
|
||||
*
|
||||
* # Safety
|
||||
* All pointers must be valid and non-null.
|
||||
*
|
||||
* # Notes
|
||||
* * If `whence` is invalid, this function returns `FfiInvalidArg`.
|
||||
* * The AFC protocol may restrict seeking beyond certain bounds; such errors
|
||||
* are reported through the returned [`IdeviceFfiError`].
|
||||
*/
|
||||
struct IdeviceFfiError *afc_file_seek(struct AfcFileHandle *handle,
|
||||
int64_t offset,
|
||||
int whence,
|
||||
int64_t *new_pos);
|
||||
|
||||
/**
|
||||
* Returns the current read/write cursor position of an open file.
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - File handle whose cursor should be queried
|
||||
* * [`pos`] - Output parameter; will be set to the current absolute cursor position
|
||||
*
|
||||
* # Returns
|
||||
* An [`IdeviceFfiError`] on error, or null on success.
|
||||
*
|
||||
* # Safety
|
||||
* All pointers must be valid and non-null.
|
||||
*
|
||||
* # Notes
|
||||
* This function is equivalent to performing a seek operation with
|
||||
* `SeekFrom::Current(0)` internally.
|
||||
*/
|
||||
struct IdeviceFfiError *afc_file_tell(struct AfcFileHandle *handle, int64_t *pos);
|
||||
|
||||
/**
|
||||
* Writes data to an open file
|
||||
|
|
@ -1452,6 +1583,148 @@ void core_device_proxy_free(struct CoreDeviceProxyHandle *handle);
|
|||
*/
|
||||
void adapter_free(struct AdapterHandle *handle);
|
||||
|
||||
/**
|
||||
* Automatically creates and connects to the crash report copy mobile service,
|
||||
* returning a client handle
|
||||
*
|
||||
* # Arguments
|
||||
* * [`provider`] - An IdeviceProvider
|
||||
* * [`client`] - On success, will be set to point to a newly allocated handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `provider` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_client_connect(struct IdeviceProviderHandle *provider,
|
||||
struct CrashReportCopyMobileHandle **client);
|
||||
|
||||
/**
|
||||
* Creates a new CrashReportCopyMobile client from an existing Idevice connection
|
||||
*
|
||||
* # Arguments
|
||||
* * [`socket`] - An IdeviceSocket handle
|
||||
* * [`client`] - On success, will be set to point to a newly allocated handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `socket` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_client_new(struct IdeviceHandle *socket,
|
||||
struct CrashReportCopyMobileHandle **client);
|
||||
|
||||
/**
|
||||
* Lists crash report files in the specified directory
|
||||
*
|
||||
* # Arguments
|
||||
* * [`client`] - A valid CrashReportCopyMobile handle
|
||||
* * [`dir_path`] - Optional directory path (NULL for root "/")
|
||||
* * [`entries`] - Will be set to point to an array of C strings
|
||||
* * [`count`] - Will be set to the number of entries
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* All pointers must be valid and non-null
|
||||
* `dir_path` may be NULL (defaults to root)
|
||||
* Caller must free the returned array with `afc_free_directory_entries`
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_client_ls(struct CrashReportCopyMobileHandle *client,
|
||||
const char *dir_path,
|
||||
char ***entries,
|
||||
size_t *count);
|
||||
|
||||
/**
|
||||
* Downloads a crash report file from the device
|
||||
*
|
||||
* # Arguments
|
||||
* * [`client`] - A valid CrashReportCopyMobile handle
|
||||
* * [`log_name`] - Name of the log file to download (C string)
|
||||
* * [`data`] - Will be set to point to the file contents
|
||||
* * [`length`] - Will be set to the size of the data
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* All pointers must be valid and non-null
|
||||
* `log_name` must be a valid C string
|
||||
* Caller must free the returned data with `idevice_data_free`
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_client_pull(struct CrashReportCopyMobileHandle *client,
|
||||
const char *log_name,
|
||||
uint8_t **data,
|
||||
size_t *length);
|
||||
|
||||
/**
|
||||
* Removes a crash report file from the device
|
||||
*
|
||||
* # Arguments
|
||||
* * [`client`] - A valid CrashReportCopyMobile handle
|
||||
* * [`log_name`] - Name of the log file to remove (C string)
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `log_name` must be a valid C string
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_client_remove(struct CrashReportCopyMobileHandle *client,
|
||||
const char *log_name);
|
||||
|
||||
/**
|
||||
* Converts this client to an AFC client for advanced file operations
|
||||
*
|
||||
* # Arguments
|
||||
* * [`client`] - A valid CrashReportCopyMobile handle (will be consumed)
|
||||
* * [`afc_client`] - On success, will be set to an AFC client handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer (will be freed after this call)
|
||||
* `afc_client` must be a valid, non-null pointer where the new AFC client will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_client_to_afc(struct CrashReportCopyMobileHandle *client,
|
||||
struct AfcClientHandle **afc_client);
|
||||
|
||||
/**
|
||||
* Triggers a flush of crash logs from system storage
|
||||
*
|
||||
* This connects to the crashreportmover service to move crash logs
|
||||
* into the AFC-accessible directory. Should be called before listing logs.
|
||||
*
|
||||
* # Arguments
|
||||
* * [`provider`] - An IdeviceProvider
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `provider` must be a valid pointer to a handle allocated by this library
|
||||
*/
|
||||
struct IdeviceFfiError *crash_report_flush(struct IdeviceProviderHandle *provider);
|
||||
|
||||
/**
|
||||
* Frees a CrashReportCopyMobile client handle
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - The handle to free
|
||||
*
|
||||
* # Safety
|
||||
* `handle` must be a valid pointer to the handle that was allocated by this library,
|
||||
* or NULL (in which case this function does nothing)
|
||||
*/
|
||||
void crash_report_client_free(struct CrashReportCopyMobileHandle *handle);
|
||||
|
||||
/**
|
||||
* Creates a new DebugserverCommand
|
||||
*
|
||||
|
|
@ -2205,6 +2478,92 @@ struct IdeviceFfiError *heartbeat_get_marco(struct HeartbeatClientHandle *client
|
|||
*/
|
||||
void heartbeat_client_free(struct HeartbeatClientHandle *handle);
|
||||
|
||||
/**
|
||||
* Connects to the House Arrest service using a TCP provider
|
||||
*
|
||||
* # Arguments
|
||||
* * [`provider`] - An IdeviceProvider
|
||||
* * [`client`] - On success, will be set to point to a newly allocated HouseArrestClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `provider` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *house_arrest_client_connect(struct IdeviceProviderHandle *provider,
|
||||
struct HouseArrestClientHandle **client);
|
||||
|
||||
/**
|
||||
* Creates a new HouseArrestClient from an existing Idevice connection
|
||||
*
|
||||
* # Arguments
|
||||
* * [`socket`] - An IdeviceSocket handle
|
||||
* * [`client`] - On success, will be set to point to a newly allocated HouseArrestClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `socket` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *house_arrest_client_new(struct IdeviceHandle *socket,
|
||||
struct HouseArrestClientHandle **client);
|
||||
|
||||
/**
|
||||
* Vends a container for an app
|
||||
*
|
||||
* # Arguments
|
||||
* * [`client`] - The House Arrest client
|
||||
* * [`bundle_id`] - The bundle ID to vend for
|
||||
* * [`afc_client`] - The new AFC client for the underlying connection
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a allocated by this library
|
||||
* `bundle_id` must be a NULL-terminated string
|
||||
* `afc_client` must be a valid, non-null pointer where the new AFC client will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *house_arrest_vend_container(struct HouseArrestClientHandle *client,
|
||||
const char *bundle_id,
|
||||
struct AfcClientHandle **afc_client);
|
||||
|
||||
/**
|
||||
* Vends documents for an app
|
||||
*
|
||||
* # Arguments
|
||||
* * [`client`] - The House Arrest client
|
||||
* * [`bundle_id`] - The bundle ID to vend for
|
||||
* * [`afc_client`] - The new AFC client for the underlying connection
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a allocated by this library
|
||||
* `bundle_id` must be a NULL-terminated string
|
||||
* `afc_client` must be a valid, non-null pointer where the new AFC client will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *house_arrest_vend_documents(struct HouseArrestClientHandle *client,
|
||||
const char *bundle_id,
|
||||
struct AfcClientHandle **afc_client);
|
||||
|
||||
/**
|
||||
* Frees an HouseArrestClient handle
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - The handle to free
|
||||
*
|
||||
* # Safety
|
||||
* `handle` must be a valid pointer to the handle that was allocated by this library,
|
||||
* or NULL (in which case this function does nothing)
|
||||
*/
|
||||
void house_arrest_client_free(struct HouseArrestClientHandle *handle);
|
||||
|
||||
/**
|
||||
* Automatically creates and connects to Installation Proxy, returning a client handle
|
||||
*
|
||||
|
|
@ -2554,6 +2913,43 @@ struct IdeviceFfiError *lockdownd_get_value(struct LockdowndClientHandle *client
|
|||
const char *domain,
|
||||
plist_t *out_plist);
|
||||
|
||||
/**
|
||||
* Tells the device to enter recovery mode
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid LockdowndClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
*/
|
||||
struct IdeviceFfiError *lockdownd_enter_recovery(struct LockdowndClientHandle *client);
|
||||
|
||||
/**
|
||||
* Sets a value in lockdownd
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid LockdowndClient handle
|
||||
* * `key` - The key to set (null-terminated string)
|
||||
* * `value` - The value to set as a plist
|
||||
* * `domain` - The domain to set in (null-terminated string, optional)
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `key` must be a valid null-terminated string
|
||||
* `value` must be a valid plist
|
||||
* `domain` must be a valid null-terminated string or NULL
|
||||
*/
|
||||
struct IdeviceFfiError *lockdownd_set_value(struct LockdowndClientHandle *client,
|
||||
const char *key,
|
||||
plist_t value,
|
||||
const char *domain);
|
||||
|
||||
/**
|
||||
* Frees a LockdowndClient handle
|
||||
*
|
||||
|
|
@ -3033,6 +3429,148 @@ struct IdeviceFfiError *image_mounter_mount_personalized_with_callback(struct Im
|
|||
void *context),
|
||||
void *context);
|
||||
|
||||
/**
|
||||
* Automatically creates and connects to Notification Proxy, returning a client handle
|
||||
*
|
||||
* # Arguments
|
||||
* * [`provider`] - An IdeviceProvider
|
||||
* * [`client`] - On success, will be set to point to a newly allocated NotificationProxyClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `provider` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_connect(struct IdeviceProviderHandle *provider,
|
||||
struct NotificationProxyClientHandle **client);
|
||||
|
||||
/**
|
||||
* Creates a new NotificationProxyClient from an existing Idevice connection
|
||||
*
|
||||
* # Arguments
|
||||
* * [`socket`] - An IdeviceSocket handle
|
||||
* * [`client`] - On success, will be set to point to a newly allocated NotificationProxyClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `socket` must be a valid pointer to a handle allocated by this library. The socket is consumed,
|
||||
* and may not be used again.
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_new(struct IdeviceHandle *socket,
|
||||
struct NotificationProxyClientHandle **client);
|
||||
|
||||
/**
|
||||
* Posts a notification to the device
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid NotificationProxyClient handle
|
||||
* * `name` - C string containing the notification name
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `name` must be a valid null-terminated C string
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_post(struct NotificationProxyClientHandle *client,
|
||||
const char *name);
|
||||
|
||||
/**
|
||||
* Observes a specific notification
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid NotificationProxyClient handle
|
||||
* * `name` - C string containing the notification name to observe
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `name` must be a valid null-terminated C string
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_observe(struct NotificationProxyClientHandle *client,
|
||||
const char *name);
|
||||
|
||||
/**
|
||||
* Observes multiple notifications at once
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid NotificationProxyClient handle
|
||||
* * `names` - A null-terminated array of C strings containing notification names
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `names` must be a valid pointer to a null-terminated array of null-terminated C strings
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_observe_multiple(struct NotificationProxyClientHandle *client,
|
||||
const char *const *names);
|
||||
|
||||
/**
|
||||
* Receives the next notification from the device
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid NotificationProxyClient handle
|
||||
* * `name_out` - On success, will be set to a newly allocated C string containing the notification name
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `name_out` must be a valid pointer. The returned string must be freed with `notification_proxy_free_string`
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_receive(struct NotificationProxyClientHandle *client,
|
||||
char **name_out);
|
||||
|
||||
/**
|
||||
* Receives the next notification with a timeout
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid NotificationProxyClient handle
|
||||
* * `interval` - Timeout in seconds to wait for a notification
|
||||
* * `name_out` - On success, will be set to a newly allocated C string containing the notification name
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `name_out` must be a valid pointer. The returned string must be freed with `notification_proxy_free_string`
|
||||
*/
|
||||
struct IdeviceFfiError *notification_proxy_receive_with_timeout(struct NotificationProxyClientHandle *client,
|
||||
uint64_t interval,
|
||||
char **name_out);
|
||||
|
||||
/**
|
||||
* Frees a string returned by notification_proxy_receive
|
||||
*
|
||||
* # Safety
|
||||
* `s` must be a valid pointer returned from `notification_proxy_receive`
|
||||
*/
|
||||
void notification_proxy_free_string(char *s);
|
||||
|
||||
/**
|
||||
* Frees a handle
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - The handle to free
|
||||
*
|
||||
* # Safety
|
||||
* `handle` must be a valid pointer to the handle that was allocated by this library,
|
||||
* or NULL (in which case this function does nothing)
|
||||
*/
|
||||
void notification_proxy_client_free(struct NotificationProxyClientHandle *handle);
|
||||
|
||||
/**
|
||||
* Connects to the relay with the given provider
|
||||
*
|
||||
|
|
@ -3444,6 +3982,65 @@ void rsd_free_services(struct CRsdServiceArray *services);
|
|||
*/
|
||||
void rsd_handshake_free(struct RsdHandshakeHandle *handle);
|
||||
|
||||
/**
|
||||
* Connects to screenshotr service using provider
|
||||
*
|
||||
* # Arguments
|
||||
* * [`provider`] - An IdeviceProvider
|
||||
* * [`client`] - On success, will be set to point to a newly allocated ScreenshotrClient handle
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `provider` must be a valid pointer to a handle allocated by this library
|
||||
* `client` must be a valid, non-null pointer to a location where the handle will be stored
|
||||
*/
|
||||
struct IdeviceFfiError *screenshotr_connect(struct IdeviceProviderHandle *provider,
|
||||
struct ScreenshotrClientHandle **client);
|
||||
|
||||
/**
|
||||
* Takes a screenshot from the device
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid ScreenshotrClient handle
|
||||
* * `screenshot` - Pointer to store the screenshot data
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `screenshot` must be a valid pointer to store the screenshot data
|
||||
* The caller is responsible for freeing the screenshot data using screenshotr_screenshot_free
|
||||
*/
|
||||
struct IdeviceFfiError *screenshotr_take_screenshot(struct ScreenshotrClientHandle *client,
|
||||
struct ScreenshotData *screenshot);
|
||||
|
||||
/**
|
||||
* Frees screenshot data
|
||||
*
|
||||
* # Arguments
|
||||
* * `screenshot` - The screenshot data to free
|
||||
*
|
||||
* # Safety
|
||||
* `screenshot` must be a valid ScreenshotData that was allocated by screenshotr_take_screenshot
|
||||
* or NULL (in which case this function does nothing)
|
||||
*/
|
||||
void screenshotr_screenshot_free(struct ScreenshotData screenshot);
|
||||
|
||||
/**
|
||||
* Frees a ScreenshotrClient handle
|
||||
*
|
||||
* # Arguments
|
||||
* * [`handle`] - The handle to free
|
||||
*
|
||||
* # Safety
|
||||
* `handle` must be a valid pointer to the handle that was allocated by this library,
|
||||
* or NULL (in which case this function does nothing)
|
||||
*/
|
||||
void screenshotr_client_free(struct ScreenshotrClientHandle *handle);
|
||||
|
||||
/**
|
||||
* Connects to the Springboard service using a provider
|
||||
*
|
||||
|
|
@ -3499,6 +4096,78 @@ struct IdeviceFfiError *springboard_services_get_icon(struct SpringBoardServices
|
|||
void **out_result,
|
||||
size_t *out_result_len);
|
||||
|
||||
/**
|
||||
* Gets the home screen wallpaper preview as PNG image
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid SpringBoardServicesClient handle
|
||||
* * `out_result` - On success, will be set to point to newly allocated png image
|
||||
* * `out_result_len` - On success, will contain the size of the data in bytes
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `out_result` and `out_result_len` must be valid, non-null pointers
|
||||
*/
|
||||
struct IdeviceFfiError *springboard_services_get_home_screen_wallpaper_preview(struct SpringBoardServicesClientHandle *client,
|
||||
void **out_result,
|
||||
size_t *out_result_len);
|
||||
|
||||
/**
|
||||
* Gets the lock screen wallpaper preview as PNG image
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid SpringBoardServicesClient handle
|
||||
* * `out_result` - On success, will be set to point to newly allocated png image
|
||||
* * `out_result_len` - On success, will contain the size of the data in bytes
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `out_result` and `out_result_len` must be valid, non-null pointers
|
||||
*/
|
||||
struct IdeviceFfiError *springboard_services_get_lock_screen_wallpaper_preview(struct SpringBoardServicesClientHandle *client,
|
||||
void **out_result,
|
||||
size_t *out_result_len);
|
||||
|
||||
/**
|
||||
* Gets the current interface orientation of the device
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid SpringBoardServicesClient handle
|
||||
* * `out_orientation` - On success, will contain the orientation value (0-4)
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `out_orientation` must be a valid, non-null pointer
|
||||
*/
|
||||
struct IdeviceFfiError *springboard_services_get_interface_orientation(struct SpringBoardServicesClientHandle *client,
|
||||
uint8_t *out_orientation);
|
||||
|
||||
/**
|
||||
* Gets the home screen icon layout metrics
|
||||
*
|
||||
* # Arguments
|
||||
* * `client` - A valid SpringBoardServicesClient handle
|
||||
* * `res` - On success, will point to a plist dictionary node containing the metrics
|
||||
*
|
||||
* # Returns
|
||||
* An IdeviceFfiError on error, null on success
|
||||
*
|
||||
* # Safety
|
||||
* `client` must be a valid pointer to a handle allocated by this library
|
||||
* `res` must be a valid, non-null pointer
|
||||
*/
|
||||
struct IdeviceFfiError *springboard_services_get_homescreen_icon_metrics(struct SpringBoardServicesClientHandle *client,
|
||||
plist_t *res);
|
||||
|
||||
/**
|
||||
* Frees an SpringBoardServicesClient handle
|
||||
*
|
||||
|
|
@ -4079,6 +4748,8 @@ extern "C"
|
|||
PLIST_ERR_PARSE = -3, /**< parsing of the input format failed */
|
||||
PLIST_ERR_NO_MEM = -4, /**< not enough memory to handle the operation */
|
||||
PLIST_ERR_IO = -5, /**< I/O error */
|
||||
PLIST_ERR_CIRCULAR_REF = -6, /**< circular reference detected */
|
||||
PLIST_ERR_MAX_NESTING = -7, /**< maximum nesting depth exceeded */
|
||||
PLIST_ERR_UNKNOWN = -255 /**< an unspecified error occurred */
|
||||
} plist_err_t;
|
||||
|
||||
|
|
@ -4333,6 +5004,12 @@ extern "C"
|
|||
*/
|
||||
PLIST_API void plist_array_next_item(plist_t node, plist_array_iter iter, plist_t *item);
|
||||
|
||||
/**
|
||||
* Free #PLIST_ARRAY iterator.
|
||||
*
|
||||
* @param iter Iterator to free.
|
||||
*/
|
||||
PLIST_API void plist_array_free_iter(plist_array_iter iter);
|
||||
|
||||
/********************************************
|
||||
* *
|
||||
|
|
@ -4370,6 +5047,13 @@ extern "C"
|
|||
*/
|
||||
PLIST_API void plist_dict_next_item(plist_t node, plist_dict_iter iter, char **key, plist_t *val);
|
||||
|
||||
/**
|
||||
* Free #PLIST_DICT iterator.
|
||||
*
|
||||
* @param iter Iterator to free.
|
||||
*/
|
||||
PLIST_API void plist_dict_free_iter(plist_dict_iter iter);
|
||||
|
||||
/**
|
||||
* Get key associated key to an item. Item must be member of a dictionary.
|
||||
*
|
||||
|
|
|
|||
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue