Add misagent profile managment feature, update idevice (#297)

This commit is contained in:
Huge_Black 2025-11-30 22:58:40 +08:00 • committed by GitHub
parent c9e76ba177
commit e59a9fe38f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 3394 additions and 1364 deletions

View file

@ -8,3 +8,4 @@
#include "JSSupport/JSSupport.h"
#include "idevice/ideviceinfo.h"
#include "idevice/ls.h"
#include "idevice/profiles.h"

View file

@ -89,11 +89,10 @@ func isMounted() -> Bool {
}
idevice_provider_free(provider)
var devices: UnsafeMutableRawPointer?
var deviceList: UnsafeMutablePointer<plist_t?>?
var devicesLen: size_t = 0
let listError = image_mounter_copy_devices(client, &devices, &devicesLen)
let listError = image_mounter_copy_devices(client, &deviceList, &devicesLen)
if listError == nil {
let deviceList = devices?.assumingMemoryBound(to: plist_t.self)
var devices: [String] = []
for i in 0..<devicesLen {
let device = deviceList?[i]

View file

@ -49,8 +49,8 @@ struct MainTabView: View {
.tabItem { Label("Scripts", systemImage: "scroll") }
.tag(2)
DeviceInfoView()
.tabItem { Label("Device Info", systemImage: "info.circle.fill") }
ProfileView()
.tabItem { Label("Profiles", systemImage: "magazine.fill") }
.tag(3)
SettingsView()

View file

@ -0,0 +1,408 @@
//
// 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("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) }
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 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: 12) {
Button { saveProfile(profile: entry) } label: { Image(systemName: "square.and.arrow.down") }
Button(role: .destructive) {
removeTargetName = entry.appName
removeTargetUUID = entry.uuid
confirmRemove = true
} label: { Image(systemName: "trash") }
}
}
.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)
}
func loadProfiles() async {
working = true
do {
let profileDatas = try JITEnableContext.shared.fetchAllProfiles()
self.profiles = profileDatas.map { Profile(data: $0) }
} catch {
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
}
}

View file

@ -367,6 +367,23 @@ struct SettingsView: View {
Text("Advanced")
.font(.headline)
.foregroundColor(.primary)
NavigationLink {
DeviceInfoView()
} label: {
HStack {
Image(systemName: "info.circle")
.font(.system(size: 18))
.foregroundColor(.primary.opacity(0.8))
Text("Device Info")
.foregroundColor(.primary.opacity(0.8))
Spacer()
Image(systemName: "chevron.right")
.font(.system(size: 14))
.foregroundColor(.white)
}
.padding(.vertical, 8)
}
Button(action: { openAppFolder() }) {
HStack {

View file

@ -29,4 +29,7 @@ typedef void (^SyslogErrorHandler)(NSError *error);
- (void)startSyslogRelayWithHandler:(SyslogLineHandler)lineHandler
onError:(SyslogErrorHandler)errorHandler NS_SWIFT_NAME(startSyslogRelay(handler:onError:));
- (void)stopSyslogRelay;
- (NSArray<NSData*>*)fetchAllProfiles:(NSError **)error;
- (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error;
- (BOOL)addProfile:(NSData*)profile error:(NSError **)error;
@end

View file

@ -11,6 +11,7 @@
#include "heartbeat.h"
#include "jit.h"
#include "applist.h"
#include "profiles.h"
#include "JITEnableContext.h"
#import "StikDebug-Swift.h"
@ -364,6 +365,33 @@ JITEnableContext* sharedJITContext = nil;
}
}
- (NSArray<NSData*>*)fetchAllProfiles:(NSError **)error {
if (!provider) {
NSLog(@"Provider not initialized!");
*error = [self errorWithStr:@"Provider not initialized!" code:-1];
return nil;
}
return fetchAppProfiles(provider, error);
}
- (BOOL)removeProfileWithUUID:(NSString*)uuid error:(NSError **)error {
if (!provider) {
NSLog(@"Provider not initialized!");
*error = [self errorWithStr:@"Provider not initialized!" code:-1];
return nil;
}
return removeProfile(provider, uuid, error);
}
- (BOOL)addProfile:(NSData*)profile error:(NSError **)error {
if (!provider) {
NSLog(@"Provider not initialized!");
*error = [self errorWithStr:@"Provider not initialized!" code:-1];
return nil;
}
return addProfile(provider, profile, error);
}
- (void)dealloc {
[self stopSyslogRelay];
if (provider) {

View file

@ -152,7 +152,7 @@ static NSDictionary<NSString*, NSString*> *performAppQuery(IdeviceProviderHandle
BOOL (^filter)(plist_t app))
{
InstallationProxyClientHandle *client = NULL;
if (installation_proxy_connect_tcp(provider, &client)) {
if (installation_proxy_connect(provider, &client)) {
*error = @"Failed to connect to installation proxy";
return nil;
}

File diff suppressed because it is too large Load diff

View file

@ -9,7 +9,6 @@
#include <arpa/inet.h>
#include "ideviceinfo.h"
#include "idevice.h"
#include "plist.h"
static struct IdeviceProviderHandle * g_provider = NULL;
static struct HeartbeatClientHandle * g_hb = NULL;
@ -80,7 +79,7 @@ char *ideviceinfo_c_get_xml(void) {
}
void *plist_obj = NULL;
struct IdeviceFfiError *err = lockdownd_get_all_values(g_client, NULL, &plist_obj);
struct IdeviceFfiError *err = lockdownd_get_value(g_client, NULL, NULL, &plist_obj);
if (err) {
idevice_error_free(err);
return NULL;

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,22 @@
//
// profiles.h
// StikDebug
//
// Created by s s on 2025/11/29.
//
#ifndef PROFILES_H
#define PROFILES_H
#include "idevice.h"
#include <Foundation/Foundation.h>
NSArray<NSData*>* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error);
bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error);
bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error);
@interface CMSDecoderHelper : NSObject
// Decode CMS/PKCS7 data and return decoded payload and any embedded certs
+ (NSData*)decodeCMSData:(NSData *)cmsData
// outCerts:(NSArray<id> * _Nullable * _Nullable)outCerts
error:(NSError * _Nullable * _Nullable)error;
@end
#endif

177
StikJIT/idevice/profiles.m Normal file
View file

@ -0,0 +1,177 @@
//
// profiles.m
// StikDebug
//
// Created by s s on 2025/11/29.
//
#include "profiles.h"
@import Foundation;
@import Security;
NSError* makeError(int code, NSString* msg) {
return [NSError errorWithDomain:@"profiles" code:code userInfo:@{NSLocalizedDescriptionKey: msg}];
}
NSArray<NSData*>* fetchAppProfiles(IdeviceProviderHandle* provider, NSError** error) {
MisagentClientHandle* misagentHandle = 0;
IdeviceFfiError * err = misagent_connect(provider, &misagentHandle);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
return nil;
}
uint8_t** profileArr = 0;
size_t profileCount = 0;
size_t* profileLengthArr = 0;
err = misagent_copy_all(misagentHandle, &profileArr, &profileLengthArr, &profileCount);
if (err) {
*error = makeError((err)->code, @((err)->message));
misagent_client_free(misagentHandle);
idevice_error_free(err);
return nil;
}
NSMutableArray* ans = [NSMutableArray array];
for(int i = 0; i < profileCount; ++i) {
size_t len = profileLengthArr[i];
uint8_t* profile = profileArr[i];
NSData* profileData = [NSData dataWithBytes:profile length:len];
[ans addObject:profileData];
}
misagent_free_profiles(profileArr, profileLengthArr, profileCount);
misagent_client_free(misagentHandle);
return ans;
}
bool removeProfile(IdeviceProviderHandle* provider, NSString* uuid, NSError** error) {
MisagentClientHandle* misagentHandle = 0;
IdeviceFfiError * err = misagent_connect(provider, &misagentHandle);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
return false;
}
err = misagent_remove(misagentHandle, [uuid UTF8String]);
if (err) {
*error = makeError((err)->code, @((err)->message));
misagent_client_free(misagentHandle);
idevice_error_free(err);
return false;
}
misagent_client_free(misagentHandle);
return true;
}
bool addProfile(IdeviceProviderHandle* provider, NSData* profile, NSError** error) {
MisagentClientHandle* misagentHandle = 0;
IdeviceFfiError * err = misagent_connect(provider, &misagentHandle);
if (err) {
*error = makeError(err->code, @(err->message));
idevice_error_free(err);
return false;
}
err = misagent_install(misagentHandle, [profile bytes], [profile length]);
if (err) {
*error = makeError((err)->code, @((err)->message));
misagent_client_free(misagentHandle);
idevice_error_free(err);
return false;
}
misagent_client_free(misagentHandle);
return true;
}
typedef CFTypeRef CMSDecoderRef;
OSStatus CMSDecoderCreate(CMSDecoderRef * cmsDecoderOut);
OSStatus CMSDecoderUpdateMessage(CMSDecoderRef cmsDecoder, const void * msgBytes, size_t msgBytesLen);
OSStatus CMSDecoderFinalizeMessage(CMSDecoderRef cmsDecoder);
OSStatus CMSDecoderCopyContent(CMSDecoderRef cmsDecoder, CFDataRef * contentOut);
OSStatus CMSDecoderCopyAllCerts(CMSDecoderRef cmsDecoder, CFArrayRef * certsOut);
// Helper to convert OSStatus -> NSError
static NSError *NSErrorFromOSStatus(OSStatus status, NSString *message) {
if (status == errSecSuccess) return nil;
NSDictionary *userInfo = @{ NSLocalizedDescriptionKey: message ?: @"Security error",
@"OSStatus" : @(status) };
return [NSError errorWithDomain:NSOSStatusErrorDomain code:status userInfo:userInfo];
}
@implementation CMSDecoderHelper
+ (NSData*)decodeCMSData:(NSData *)cmsData
// outCerts:(NSArray<id> * _Nullable * _Nullable)outCerts
error:(NSError * _Nullable * _Nullable)error
{
if (!cmsData) {
if (error) *error = [NSError errorWithDomain:NSCocoaErrorDomain code:NSURLErrorBadURL userInfo:@{NSLocalizedDescriptionKey: @"cmsData is nil"}];
return nil;
}
CMSDecoderRef decoder = NULL;
OSStatus status = CMSDecoderCreate(&decoder);
if (status != errSecSuccess || decoder == NULL) {
if (error) *error = NSErrorFromOSStatus(status, @"Failed to create CMS decoder");
return nil;
}
// Feed data to decoder
status = CMSDecoderUpdateMessage(decoder, cmsData.bytes, cmsData.length);
if (status != errSecSuccess) {
if (error) *error = NSErrorFromOSStatus(status, @"Failed to update CMS decoder with message bytes");
CFRelease(decoder);
return nil;
}
// Finalize (parse) the message
status = CMSDecoderFinalizeMessage(decoder);
if (status != errSecSuccess) {
if (error) *error = NSErrorFromOSStatus(status, @"Failed to finalize CMS message");
CFRelease(decoder);
return nil;
}
// Extract the content (the inner payload). This may be NULL if content is detached.
CFDataRef contentData = NULL;
status = CMSDecoderCopyContent(decoder, &contentData);
if (status != errSecSuccess && status != errSecItemNotFound) {
// errSecItemNotFound could mean no content (detached signature)
if (error) *error = NSErrorFromOSStatus(status, @"Failed to copy CMS content");
if (contentData) CFRelease(contentData);
CFRelease(decoder);
return nil;
}
// // Extract embedded certificates (if any)
// CFArrayRef certsArray = NULL;
// status = CMSDecoderCopyAllCerts(decoder, &certsArray);
// if (status == errSecSuccess && certsArray) {
// // certsArray contains SecCertificateRef items
// NSArray *certs = (__bridge_transfer NSArray *)certsArray;
// if (outCerts) *outCerts = certs;
// } else {
// // no certs or error
// if (status != errSecSuccess && status != errSecItemNotFound) {
// if (error) *error = NSErrorFromOSStatus(status, @"Failed to copy embedded certificates (if any)");
// CFRelease(decoder);
// return NO;
// }
// if (outCerts) *outCerts = @[]; // empty
// }
CFRelease(decoder);
return (__bridge NSData *)(contentData);
}
@end

View file

@ -138,7 +138,7 @@ int install_ipa(const char *ip,
}
InstallationProxyClientHandle *ipc = NULL;
err = installation_proxy_connect_tcp(provider, &ipc);
err = installation_proxy_connect(provider, &ipc);
if (err) {
fprintf(stderr, "installation_proxy connect failed: [%d] %s\n",
err->code, err->message);