From be7a71b7d470499d9c31a5e263cb6771f8772b25 Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Tue, 23 Jun 2026 14:22:49 +0530 Subject: [PATCH 01/12] feat(ipa-inspector): add initial wireframe for IPAInspectorView --- IPASignCraft/App/RootView/RootView.swift | 2 + .../Components/Cards/AppCard.swift | 3 +- .../Components/Common/ConsoleView.swift | 89 ++++ .../Components/Common/ListRow/InfoRow.swift | 77 +++ .../Components/FileBrowserView.swift | 52 ++ .../Components/FileDropView.swift | 33 +- .../Components/HomeFileSection.swift | 55 ++ .../DesignSystem/Extensions/View+Field.swift | 6 +- .../DesignSystem/Tokens/AppColors.swift | 32 +- .../DesignSystem/Tokens/AppFont.swift | 8 + .../DesignSystem/Tokens/Spacing.swift | 1 + .../Models/IPAInspection/FrameworkInfo.swift | 67 +++ .../Models/IPAInspection/IPAInspection.swift | 148 ++++++ .../IPAInspection/IPAInspectionError.swift | 46 ++ .../IPAInspection/IPAInspectorSection.swift | 151 ++++++ .../IPAInspection/InspectorInfoCard.swift | 129 +++++ .../Models/IPAInspection/ParsedIPAInfo.swift | 22 + .../Model/IPAInspectorState.swift | 45 ++ .../View/Components/CollapsibleSection.swift | 204 ++++++++ .../View/Components/StatCard.swift | 146 ++++++ .../IPAInspector/View/IPAInspectorView.swift | 484 ++++++++++++++++++ .../View/sections/IPABinaryView.swift | 237 +++++++++ .../View/sections/IPAEntitlementsView.swift | 215 ++++++++ .../View/sections/IPAFrameworksView.swift | 201 ++++++++ .../View/sections/IPAGeneralView.swift | 165 ++++++ .../View/sections/IPAOverviewView.swift | 162 ++++++ .../View/sections/IPASecurityView.swift | 275 ++++++++++ .../View/sections/IPASigningView.swift | 228 +++++++++ .../View/sections/KeyValueRow.swift | 56 ++ .../IPAInspectorDetailViewModel.swift | 115 +++++ .../Features/Resign/View/HomeView.swift | 65 +-- .../Features/Sidebar/Model/SidebarItem.swift | 1 + .../Features/Sidebar/SidebarView.swift | 15 +- .../Service/IPAExtractorService.swift | 30 +- .../IPAInspection/IPAInspectionService.swift | 266 ++++++++++ .../IPAInspectionServicing.swift | 62 +++ 36 files changed, 3817 insertions(+), 76 deletions(-) create mode 100644 IPASignCraft/DesignSystem/Components/Common/ConsoleView.swift create mode 100644 IPASignCraft/DesignSystem/Components/Common/ListRow/InfoRow.swift create mode 100644 IPASignCraft/DesignSystem/Components/FileBrowserView.swift create mode 100644 IPASignCraft/DesignSystem/Components/HomeFileSection.swift create mode 100644 IPASignCraft/Domain/Models/IPAInspection/FrameworkInfo.swift create mode 100644 IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift create mode 100644 IPASignCraft/Domain/Models/IPAInspection/IPAInspectionError.swift create mode 100644 IPASignCraft/Domain/Models/IPAInspection/IPAInspectorSection.swift create mode 100644 IPASignCraft/Domain/Models/IPAInspection/InspectorInfoCard.swift create mode 100644 IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift create mode 100644 IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/Components/CollapsibleSection.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/Components/StatCard.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPABinaryView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPAEntitlementsView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPAGeneralView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift create mode 100644 IPASignCraft/Features/IPAInspector/View/sections/KeyValueRow.swift create mode 100644 IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift create mode 100644 IPASignCraft/Service/IPAInspection/IPAInspectionService.swift create mode 100644 IPASignCraft/Service/IPAInspection/IPAInspectionServicing.swift diff --git a/IPASignCraft/App/RootView/RootView.swift b/IPASignCraft/App/RootView/RootView.swift index 6243dfd..42708bc 100644 --- a/IPASignCraft/App/RootView/RootView.swift +++ b/IPASignCraft/App/RootView/RootView.swift @@ -28,6 +28,8 @@ struct RootView: View { switch selection { case .home: HomeView() + case .ipaInspector: + IPAInspectorView() } } } diff --git a/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift b/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift index 9caa3af..e60fbc4 100644 --- a/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift +++ b/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift @@ -21,12 +21,13 @@ struct AppCard: View { .padding(Spacing.base) .background( RoundedRectangle(cornerRadius: Radius.xl) - .fill(.ultraThinMaterial) + .fill(AppColors.cardSurface) ) .overlay( RoundedRectangle(cornerRadius: Radius.xl) .stroke(AppColors.border, lineWidth: 1) ) + .shadow(color: AppColors.cardShadow, radius: 8, x: 0, y: 4) .clipShape( RoundedRectangle(cornerRadius: Radius.xl) ) diff --git a/IPASignCraft/DesignSystem/Components/Common/ConsoleView.swift b/IPASignCraft/DesignSystem/Components/Common/ConsoleView.swift new file mode 100644 index 0000000..8bbbb95 --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/Common/ConsoleView.swift @@ -0,0 +1,89 @@ +// +// ConsoleView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 09/06/26. +// + +import SwiftUI + +/// A reusable console/logs component for displaying terminal-style output. +/// +/// Features: +/// - Displays monospaced log text +/// - Dark background with green text (terminal style) +/// - Clear button to reset logs +/// - Scrollable content area +/// - Customizable title and icon +struct ConsoleView: View { + + // MARK: - Properties + + /// Title displayed in the header + let title: String + + /// SF Symbol icon name + let icon: String + + /// Log content to display + @Binding var logContent: String + + /// Callback when clear button is tapped + var onClear: (() -> Void)? + + // MARK: - Body + + var body: some View { + + AppCard { + + VStack(spacing: Spacing.sm) { + + // Header with title and clear button + HStack { + + Label(title, systemImage: icon) + .font(AppFont.body) + + Spacer() + + Button("Clear") { + + logContent = "" + onClear?() + } + .font(AppFont.secondary) + } + + Divider() + .opacity(0.3) + + // Console output + ScrollView { + + Text(logContent.isEmpty ? "No logs yet" : logContent) + .font(.system(.caption, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(Spacing.base) + } + .frame(maxHeight: 260) + .background(Color.black.opacity(0.9)) + .cornerRadius(8) + .foregroundColor(.green) + } + } + } +} + +// MARK: - Preview + +#Preview { + + @State var sampleLogs = "Initializing inspection...\nLoading IPA file...\nAnalyzing bundle...\nāœ“ Inspection complete" + + return ConsoleView( + title: "Console", + icon: "terminal", + logContent: $sampleLogs + ) +} diff --git a/IPASignCraft/DesignSystem/Components/Common/ListRow/InfoRow.swift b/IPASignCraft/DesignSystem/Components/Common/ListRow/InfoRow.swift new file mode 100644 index 0000000..75684b1 --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/Common/ListRow/InfoRow.swift @@ -0,0 +1,77 @@ +// +// InfoRow.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 09/06/26. +// + +import SwiftUI + +/// A reusable row component for displaying file or item information. +/// +/// Typically used to show: +/// - Selected file summaries +/// - Status information +/// - Item details with icon and label +/// +/// Components: +/// - Icon on the left (with custom color) +/// - Title and subtitle in the middle +/// - Spacer on the right +/// - Container styling from the design system +struct InfoRow: View { + + // MARK: - Properties + + /// SF Symbol icon name + let icon: String + + /// Icon color + let color: Color + + /// Primary text (main title) + let title: String + + /// Secondary text (description) + let subtitle: String + + // MARK: - Body + + var body: some View { + + HStack(spacing: Spacing.sm) { + + Image(systemName: icon) + .foregroundColor(color) + + VStack(alignment: .leading, spacing: 2) { + + Text(title) + .font(AppFont.secondary) + .lineLimit(1) + + Text(subtitle) + .font(AppFont.secondary) + .foregroundColor(AppColors.secondaryText) + } + + Spacer() + } + .fieldContainer() + .accessibilityElement(children: .combine) + .accessibilityLabel(title) + .accessibilityValue(subtitle) + } +} + +// MARK: - Preview + +#Preview { + + InfoRow( + icon: "doc.fill", + color: AppColors.accent, + title: "ExampleApp.ipa", + subtitle: "Ready for inspection" + ) +} diff --git a/IPASignCraft/DesignSystem/Components/FileBrowserView.swift b/IPASignCraft/DesignSystem/Components/FileBrowserView.swift new file mode 100644 index 0000000..c344835 --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/FileBrowserView.swift @@ -0,0 +1,52 @@ +import SwiftUI +internal import UniformTypeIdentifiers + +/// A small composed control used across the app for file selection. +/// Combines the existing FilePickerView and FileDropView into a single reusable component. +struct FileBrowserView: View { + let title: String? + @Binding var filePath: String + let supportedTypes: [UTType] + + init(title: String? = nil, filePath: Binding, supportedTypes: [UTType]) { + self.title = title + self._filePath = filePath + self.supportedTypes = supportedTypes + } + + var body: some View { + VStack(alignment: .leading, spacing: Spacing.sm) { + if let title { + Text(title) + .font(AppFont.secondary) + } + + HStack(spacing: Spacing.base) { + FilePickerView( + title: "Browse...", + supportedTypes: supportedTypes, + filePath: $filePath + ) + + FileDropView( + title: nil, + filePath: $filePath, + supportedTypes: supportedTypes + ) + } + .frame(maxHeight: 100) + } + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: Spacing.base) { + FileBrowserView(title: "IPA File", filePath: .constant("/path/to/app.ipa"), supportedTypes: [.ipa]) + .padding() + + FileBrowserView(title: nil, filePath: .constant(""), supportedTypes: [.ipa]) + .padding() + } +} diff --git a/IPASignCraft/DesignSystem/Components/FileDropView.swift b/IPASignCraft/DesignSystem/Components/FileDropView.swift index 885d18d..b3dec98 100644 --- a/IPASignCraft/DesignSystem/Components/FileDropView.swift +++ b/IPASignCraft/DesignSystem/Components/FileDropView.swift @@ -29,11 +29,11 @@ struct FileDropView: View { style: StrokeStyle(lineWidth: 1.2, dash: [6]) ) .foregroundColor( - isHovering ? Color.blue.opacity(0.6) : Color.gray.opacity(0.4) + isHovering ? AppColors.accent.opacity(0.6) : Color.gray.opacity(0.38) ) .background( RoundedRectangle(cornerRadius: 12) - .fill(Color(NSColor.controlBackgroundColor)) + .fill(AppColors.cardSurface) ) VStack(spacing: 6) { @@ -48,7 +48,7 @@ struct FileDropView: View { Text("or click to browse") .font(.caption) - .foregroundColor(.secondary) + .foregroundColor(AppColors.secondaryText) } else { Text("IPA package loaded successfully") .font(.caption) @@ -57,9 +57,20 @@ struct FileDropView: View { } .padding(.vertical, 18) } + .contentShape(RoundedRectangle(cornerRadius: 12)) .onTapGesture { - // optional: trigger file picker + openPanel() } + .focusable(true) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.18)) { + isHovering = hovering + } + } + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(filePath.isEmpty ? "File drop target" : "File loaded") + .accessibilityHint("Press Space or Enter when focused to open file picker. You can also drop an IPA file here.") .onDrop(of: ["public.file-url"], isTargeted: $isHovering) { providers in providers.first?.loadItem(forTypeIdentifier: "public.file-url", @@ -89,6 +100,20 @@ struct FileDropView: View { } } } + + #if os(macOS) + private func openPanel() { + let panel = NSOpenPanel() + panel.allowedContentTypes = supportedTypes + panel.allowsMultipleSelection = false + panel.canChooseDirectories = false + panel.begin { response in + if response == .OK, let url = panel.url { + filePath = url.path + } + } + } + #endif } #Preview { diff --git a/IPASignCraft/DesignSystem/Components/HomeFileSection.swift b/IPASignCraft/DesignSystem/Components/HomeFileSection.swift new file mode 100644 index 0000000..64355e5 --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/HomeFileSection.swift @@ -0,0 +1,55 @@ +import SwiftUI +internal import UniformTypeIdentifiers + +/// Reusable section composed of HomeSectionView + FileBrowserView + helper text +/// Use this in places that need an "IPA File" style input area. +struct HomeFileSection: View { + let title: String + let helper: String? + @Binding var filePath: String + let supportedTypes: [UTType] + var onSelect: ((String) -> Void)? = nil + + var body: some View { + HomeSectionView(title) { + VStack(alignment: .leading, spacing: Spacing.sm) { + if let helper { + Text(helper) + .font(AppFont.secondary) + .foregroundColor(AppColors.secondaryText) + } + + // Large drop area + FileDropView( + title: nil, + filePath: $filePath, + supportedTypes: supportedTypes + ) + .frame(minHeight: 140) + .onChange(of: filePath) { newPath in + guard !newPath.isEmpty else { return } + onSelect?(newPath) + } + + if !filePath.isEmpty { + InfoRow( + icon: "doc.fill", + color: AppColors.accent, + title: (filePath as NSString).lastPathComponent, + subtitle: title == "IPA File" ? "Ready for signing" : "Ready for inspection" + ) + } + } + } + } +} + +#Preview { + VStack(spacing: Spacing.base) { + HomeFileSection(title: "IPA File", helper: "Select or drop the IPA you want to re-sign", filePath: .constant("/path/to/app.ipa"), supportedTypes: [.ipa]) + .padding() + + HomeFileSection(title: "IPA File", helper: "Select or drop the IPA you want to inspect", filePath: .constant(""), supportedTypes: [.ipa]) + .padding() + } +} \ No newline at end of file diff --git a/IPASignCraft/DesignSystem/Extensions/View+Field.swift b/IPASignCraft/DesignSystem/Extensions/View+Field.swift index 9e5761c..7bc0116 100644 --- a/IPASignCraft/DesignSystem/Extensions/View+Field.swift +++ b/IPASignCraft/DesignSystem/Extensions/View+Field.swift @@ -13,11 +13,15 @@ extension View { func fieldContainer() -> some View { self .padding(Spacing.md) - .background(AppColors.cardBackground) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + ) .overlay( RoundedRectangle(cornerRadius: Radius.sm) .stroke(AppColors.border, lineWidth: 1) ) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) .cornerRadius(Radius.sm) } } diff --git a/IPASignCraft/DesignSystem/Tokens/AppColors.swift b/IPASignCraft/DesignSystem/Tokens/AppColors.swift index 147eb7a..99c558b 100644 --- a/IPASignCraft/DesignSystem/Tokens/AppColors.swift +++ b/IPASignCraft/DesignSystem/Tokens/AppColors.swift @@ -13,24 +13,34 @@ enum AppColors { static let primaryText = Color(hexValue: "#1D1D1F") static let secondaryText = Color(hexValue: "#6E6E73") static let disabledText = Color(hexValue: "#A1A1A6") - + // MARK: - Accent (Green System) static let accent = Color(hexValue: "#2F5D3A") // Deep forest (base) static let accentHover = Color(hexValue: "#3E7A4E") // Slightly lighter green static let accentPressed = Color(hexValue: "#254A2E") // Slightly darker - - // MARK: - Backgrounds - static let background = Color(hexValue: "#F4F1EA").opacity(0.9) // warm ivory - static let cardBackground = Color.white.opacity(0.75) - static let dropZoneBackground = Color.white.opacity(0.4) - - // MARK: - Borders - static let border = Color.black.opacity(0.06) - + + // MARK: - Backgrounds & Surfaces + static let background = Color(hexValue: "#F7F6FA") // very light neutral + static let cardBackground = Color.white.opacity(0.92) + static let controlBackground = Color(nsColor: .controlBackgroundColor) + static let dropZoneBackground = Color(hexValue: "#F0F5FF").opacity(0.6) + + // Subtle card surface used for elevated blocks + static let cardSurface = Color(hexValue: "#FFFFFF") + + // Header / decorative gradient + static let headerGradientStart = Color(hexValue: "#F2E9FF") + static let headerGradientEnd = Color(hexValue: "#E9F4FF") + + // MARK: - Borders & Shadows + static let border = Color(hexValue: "#000000").opacity(0.06) + static let cardShadow = Color(hexValue: "#000000").opacity(0.04) + // MARK: - States static let success = Color(hexValue: "#34C759") static let error = Color(hexValue: "#FF453A") - + // MARK: - Logs static let logBackground = Color(hexValue: "#1C1C1E") } + diff --git a/IPASignCraft/DesignSystem/Tokens/AppFont.swift b/IPASignCraft/DesignSystem/Tokens/AppFont.swift index d9278ac..818f846 100644 --- a/IPASignCraft/DesignSystem/Tokens/AppFont.swift +++ b/IPASignCraft/DesignSystem/Tokens/AppFont.swift @@ -9,8 +9,16 @@ import SwiftUI enum AppFont { static let title = Font.system(size: 28, weight: .semibold) + + static let heading1 = Font.system(size: 20, weight: .semibold) + static let heading2 = Font.system(size: 18, weight: .semibold) static let section = Font.system(size: 16, weight: .semibold) + static let heading3 = Font.system(size: 15, weight: .semibold) + static let body = Font.system(size: 13, weight: .regular) static let secondary = Font.system(size: 12, weight: .regular) + static let caption = Font.system(size: 11, weight: .regular) + static let small = Font.system(size: 10, weight: .regular) + static let button = Font.system(size: 13, weight: .medium) } diff --git a/IPASignCraft/DesignSystem/Tokens/Spacing.swift b/IPASignCraft/DesignSystem/Tokens/Spacing.swift index e09068f..8a2505c 100644 --- a/IPASignCraft/DesignSystem/Tokens/Spacing.swift +++ b/IPASignCraft/DesignSystem/Tokens/Spacing.swift @@ -8,6 +8,7 @@ import Foundation enum Spacing { + static let xxs: CGFloat = 2 static let xs: CGFloat = 4 static let sm: CGFloat = 8 static let md: CGFloat = 12 diff --git a/IPASignCraft/Domain/Models/IPAInspection/FrameworkInfo.swift b/IPASignCraft/Domain/Models/IPAInspection/FrameworkInfo.swift new file mode 100644 index 0000000..b17b4b6 --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/FrameworkInfo.swift @@ -0,0 +1,67 @@ +// +// FrameworkInfo.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Represents a single embedded framework +/// discovered inside the IPA bundle. +/// +/// Example locations: +/// Payload/App.app/Frameworks/ +struct FrameworkInfo: Identifiable { + + // MARK: - Identity + + /// Stable identifier used by SwiftUI lists/tables. + let id = UUID() + + // MARK: - Basic Information + + /// Framework bundle name. + /// + /// Example: + /// "Firebase.framework" + /// "Alamofire.framework" + let name: String + + // MARK: - Signing Information + + /// Indicates whether the framework + /// contains a valid code signature. + /// + /// This can be verified using: + /// - codesign + /// - SecStaticCode APIs + let isSigned: Bool + + // MARK: - Size Information + + /// Human-readable framework size. + /// + /// Example: + /// "12 MB" + /// "540 KB" + /// + /// Keep formatted for UI rendering. + /// Raw byte count can be stored separately + /// if advanced sorting/filtering is needed. + let size: String +} + +// MARK: - Mock Data + +extension FrameworkInfo { + + /// Mock framework used for previews + /// and UI development. + static let mock = FrameworkInfo( + name: "Firebase.framework", + isSigned: true, + size: "12 MB" + ) +} \ No newline at end of file diff --git a/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift b/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift new file mode 100644 index 0000000..ee876d2 --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift @@ -0,0 +1,148 @@ +// +// IPAInspection.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Root inspection model representing +/// the analyzed contents of an IPA. +/// +/// This model should remain UI-independent. +/// Avoid adding view formatting logic here. +struct IPAInspection { + + // MARK: - Basic Application Information + + /// Display name of the application. + /// + /// Example: + /// "Instagram" + /// "Lido mBriefing" + let appName: String + + /// Unique bundle identifier from Info.plist. + /// + /// Example: + /// "com.company.app" + let bundleIdentifier: String + + /// Human-readable release version. + /// + /// CFBundleShortVersionString + /// + /// Example: + /// "5.2" + let version: String + + /// Internal build number. + /// + /// CFBundleVersion + /// + /// Example: + /// "402" + let buildNumber: String + + // MARK: - Signing Information + + /// Apple Developer Team Identifier + /// extracted from provisioning profile + /// or code signature. + /// + /// Example: + /// "ABCDE12345" + let teamIdentifier: String + + // MARK: - Binary Information + + /// CPU architectures supported + /// by the application binary. + /// + /// Example: + /// ["arm64"] + /// ["armv7", "arm64"] + let architectures: [String] + + /// Indicates whether the application + /// signature is currently valid. + /// + /// This should represent: + /// - code signature validation + /// - provisioning match + /// - entitlement consistency + let hasValidSignature: Bool + + // MARK: - Entitlements + + /// Human-readable entitlement list. + /// + /// Example: + /// - Push Notifications + /// - App Groups + /// - Keychain Sharing + /// + /// Keep this simplified for UI rendering. + /// Raw entitlement dictionaries should + /// be stored separately if needed. + let entitlements: [String] + + // MARK: - Embedded Frameworks + + /// Embedded frameworks discovered + /// inside the IPA bundle. + let frameworks: [FrameworkInfo] +} + +// MARK: - Mock Data + +extension IPAInspection { + + /// Preview and development mock object. + /// + /// Used for: + /// - SwiftUI previews + /// - UI prototyping + /// - development testing + static let mock = IPAInspection( + + appName: "Lido mBriefing", + + bundleIdentifier: "com.company.mbriefing", + + version: "5.2", + + buildNumber: "402", + + teamIdentifier: "ABCDE12345", + + architectures: [ + "arm64" + ], + + hasValidSignature: true, + + entitlements: [ + "Push Notifications", + "App Groups", + "Keychain Sharing" + ], + + frameworks: [ + + FrameworkInfo( + name: "Firebase.framework", + isSigned: true, + size: "12 MB" + ), + + FrameworkInfo( + name: "Alamofire.framework", + isSigned: true, + size: "3 MB" + ) + ] + ) +} diff --git a/IPASignCraft/Domain/Models/IPAInspection/IPAInspectionError.swift b/IPASignCraft/Domain/Models/IPAInspection/IPAInspectionError.swift new file mode 100644 index 0000000..0a87849 --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/IPAInspectionError.swift @@ -0,0 +1,46 @@ +// +// IPAInspectionError.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Errors thrown during IPA inspection. +enum IPAInspectionError: LocalizedError { + + case fileNotFound + + case invalidIPA + + case extractionFailed + + case appBundleNotFound + + case infoPlistMissing + + // MARK: - LocalizedError + + var errorDescription: String? { + + switch self { + + case .fileNotFound: + return "The selected IPA file could not be found." + + case .invalidIPA: + return "The selected file is not a valid IPA." + + case .extractionFailed: + return "Failed to extract IPA contents." + + case .appBundleNotFound: + return "No .app bundle was found inside the IPA." + + case .infoPlistMissing: + return "Info.plist could not be loaded." + } + } +} \ No newline at end of file diff --git a/IPASignCraft/Domain/Models/IPAInspection/IPAInspectorSection.swift b/IPASignCraft/Domain/Models/IPAInspection/IPAInspectorSection.swift new file mode 100644 index 0000000..be3d57f --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/IPAInspectorSection.swift @@ -0,0 +1,151 @@ +// +// IPAInspectorSection.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Navigation sections available +/// inside the IPA Inspector feature. +/// +/// Each section represents a dedicated +/// inspection domain of the IPA. +enum IPAInspectorSection: String, CaseIterable, Identifiable { + + // MARK: - Sections + + /// High-level summary dashboard. + /// + /// Shows: + /// - app identity + /// - health status + /// - architectures + /// - signing overview + case overview + + /// General application metadata. + /// + /// Shows: + /// - bundle identifier + /// - version + /// - build number + /// - minimum iOS version + /// - supported devices + case general + + /// Signing and provisioning analysis. + /// + /// Shows: + /// - certificate details + /// - provisioning profile + /// - expiration + /// - validation warnings + case signing + + /// Application entitlements. + /// + /// Shows: + /// - push notifications + /// - app groups + /// - keychain access + /// - associated domains + case entitlements + + /// Mach-O binary inspection. + /// + /// Shows: + /// - architectures + /// - encryption status + /// - linked libraries + /// - load commands + case binary + + /// Embedded frameworks and dylibs. + /// + /// Shows: + /// - framework list + /// - signing status + /// - framework sizes + case frameworks + + /// Security and validation analysis. + /// + /// Shows: + /// - ATS configuration + /// - jailbreak indicators + /// - debug symbols + /// - suspicious libraries + case security + + // MARK: - Identifiable + + var id: String { + rawValue + } +} + +// MARK: - UI Helpers + +extension IPAInspectorSection { + + /// Human-readable title + /// used in sidebar navigation. + var title: String { + + switch self { + + case .overview: + return "Overview" + + case .general: + return "General" + + case .signing: + return "Signing" + + case .entitlements: + return "Entitlements" + + case .binary: + return "Binary" + + case .frameworks: + return "Frameworks" + + case .security: + return "Security" + } + } + + /// SF Symbol used for sidebar navigation + /// and section headers. + var systemImage: String { + + switch self { + + case .overview: + return "square.grid.2x2" + + case .general: + return "info.circle" + + case .signing: + return "signature" + + case .entitlements: + return "lock.shield" + + case .binary: + return "cpu" + + case .frameworks: + return "shippingbox" + + case .security: + return "shield" + } + } +} \ No newline at end of file diff --git a/IPASignCraft/Domain/Models/IPAInspection/InspectorInfoCard.swift b/IPASignCraft/Domain/Models/IPAInspection/InspectorInfoCard.swift new file mode 100644 index 0000000..5ea25ba --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/InspectorInfoCard.swift @@ -0,0 +1,129 @@ +// +// InspectorInfoCard.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Reusable information card used throughout +/// the IPA Inspector feature. +/// +/// Purpose: +/// Display short summary values in a compact, +/// visually grouped format. +/// +/// Common usage: +/// - Version +/// - Build Number +/// - Team Identifier +/// - Framework Count +/// - Architecture List +/// +/// The card is intentionally lightweight +/// and should only display concise values. +/// +/// Avoid placing large multiline content here. +struct InspectorInfoCard: View { + + // MARK: - Properties + + /// Small descriptive label. + /// + /// Example: + /// "Version" + /// "Frameworks" + let title: String + + /// Main displayed value. + /// + /// Example: + /// "5.2" + /// "12" + /// "arm64" + let value: String + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 10 + ) { + + titleView + + valueView + } + .padding(16) + .frame( + maxWidth: 180, + alignment: .leading + ) + .background( + cardBackground + ) + } +} + +// MARK: - Title View + +private extension InspectorInfoCard { + + /// Displays the card label/title. + var titleView: some View { + + Text(title) + .font(.caption) + .foregroundStyle(.secondary) + } +} + +// MARK: - Value View + +private extension InspectorInfoCard { + + /// Displays the primary card value. + var valueView: some View { + + Text(value) + .font(.headline) + .lineLimit(2) + } +} + +// MARK: - Background + +private extension InspectorInfoCard { + + /// Shared card background styling. + var cardBackground: some View { + + RoundedRectangle(cornerRadius: 14) + .fill( + Color.gray.opacity(0.08) + ) + } +} + +// MARK: - Preview + +#Preview { + + HStack { + + InspectorInfoCard( + title: "Version", + value: "5.2" + ) + + InspectorInfoCard( + title: "Architectures", + value: "arm64" + ) + } + .padding() +} \ No newline at end of file diff --git a/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift b/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift new file mode 100644 index 0000000..1dbb5d8 --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift @@ -0,0 +1,22 @@ +// +// ParsedIPAInfo.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Lightweight intermediate model +/// used during Info.plist parsing. +struct ParsedIPAInfo { + + let appName: String + + let bundleIdentifier: String + + let version: String + + let buildNumber: String +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift new file mode 100644 index 0000000..ed14073 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift @@ -0,0 +1,45 @@ +// +// IPAInspectorState.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 09/06/26. +// + +import Foundation + +/// Encapsulates all state for the IPA Inspector feature. +/// +/// This state object is owned by the view model +/// and published to views for reactive updates. +@Observable +final class IPAInspectorState { + + // MARK: - File Selection State + + /// Currently selected IPA file URL. + var selectedIPAURL: URL? + + // MARK: - Inspection State + + /// Currently loaded inspection result. + var inspection: IPAInspection? + + /// Indicates whether inspection is currently running. + var isLoading = false + + /// Human-readable error message shown in UI + /// when inspection fails. + var errorMessage: String? + + /// Currently selected inspector section. + /// + /// Used by parent navigation/menu. + var selectedSection: IPAInspectorSection = .overview + + /// Log output for inspection process. + var log: String = "" + + // MARK: - Initialization + + init() {} +} diff --git a/IPASignCraft/Features/IPAInspector/View/Components/CollapsibleSection.swift b/IPASignCraft/Features/IPAInspector/View/Components/CollapsibleSection.swift new file mode 100644 index 0000000..7ee434d --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/Components/CollapsibleSection.swift @@ -0,0 +1,204 @@ +// +// CollapsibleSection.swift +// IPASignCraft +// +// Created by Copilot +// + +import SwiftUI + +/// A collapsible section component for the IPA Inspector dashboard. +/// +/// Displays a header with icon, title, and optional badge, +/// with expandable/collapsible content below. +struct CollapsibleSection: View { + + // MARK: - Properties + + /// Icon name for the section header + let icon: String + + /// Color for the icon + let iconColor: Color + + /// Title of the section + let title: String + + /// Optional subtitle/description + let subtitle: String? + + /// Optional badge value (e.g., count) + let badge: String? + + /// Badge color + let badgeColor: Color + + /// Content to display when expanded + let content: () -> Content + + /// Whether section is expanded + @State private var isExpanded = false + @State private var isFocused = false + + // MARK: - Initialization + + init( + icon: String, + iconColor: Color = .blue, + title: String, + subtitle: String? = nil, + badge: String? = nil, + badgeColor: Color = .blue, + @ViewBuilder content: @escaping () -> Content + ) { + self.icon = icon + self.iconColor = iconColor + self.title = title + self.subtitle = subtitle + self.badge = badge + self.badgeColor = badgeColor + self.content = content + } + + // MARK: - Body + + var body: some View { + + VStack(spacing: 0) { + + // Header + Button(action: { + withAnimation(.easeInOut(duration: 0.22)) { + isExpanded.toggle() + } + }) { + + HStack(spacing: Spacing.base) { + + // Icon + ZStack { + Circle() + .fill(iconColor.opacity(0.1)) + + Image(systemName: icon) + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(iconColor) + } + .frame(width: 32, height: 32) + + // Title and subtitle + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(AppFont.body) + .fontWeight(.semibold) + .foregroundColor(AppColors.primaryText) + + if let subtitle = subtitle { + Text(subtitle) + .font(AppFont.secondary) + .foregroundColor(AppColors.secondaryText) + } + } + + Spacer() + + // Badge + if let badge = badge { + Text(badge) + .font(AppFont.secondary) + .fontWeight(.semibold) + .foregroundColor(badgeColor) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(badgeColor.opacity(0.1)) + .cornerRadius(4) + } + + // Chevron + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(AppColors.secondaryText) + .rotationEffect(.degrees(isExpanded ? 90 : 0)) + .animation(.easeInOut(duration: 0.22), value: isExpanded) + } + .padding(Spacing.base) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .keyboardShortcut(.space, modifiers: []) + .accessibilityElement(children: .combine) + .accessibilityAddTraits(.isButton) + .accessibilityLabel(title) + .accessibilityValue(isExpanded ? "Expanded" : "Collapsed") + + // Content + if isExpanded { + Divider() + .padding(.horizontal, Spacing.base) + + content() + .padding(Spacing.base) + .transition(.opacity.combined(with: .move(edge: .top))) + } + } + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + ) + .onHover { hovering in + // show focus ring when hovered + withAnimation(.easeInOut(duration: 0.18)) { + isFocused = hovering + } + } + .overlay( + RoundedRectangle(cornerRadius: Radius.sm) + .stroke(isFocused ? AppColors.accent.opacity(0.25) : Color.clear, lineWidth: 1) + ) + } +} + +// MARK: - Preview + +#Preview { + VStack(spacing: Spacing.base) { + CollapsibleSection( + icon: "info.circle", + iconColor: .blue, + title: "Overview", + subtitle: "General information about the IPA and app bundle.", + badge: nil + ) { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack { + Text("App Name") + .font(AppFont.secondary) + Spacer() + Text("MyApp") + .font(AppFont.body) + } + HStack { + Text("Bundle ID") + .font(AppFont.secondary) + Spacer() + Text("com.example.app") + .font(AppFont.body) + } + } + } + + CollapsibleSection( + icon: "checkmark.seal.fill", + iconColor: .green, + title: "Signing & Provisioning", + subtitle: "Code signature details and provisioning profile info.", + badge: "Valid", + badgeColor: .green + ) { + Text("Signing details content goes here") + .font(AppFont.secondary) + } + } + .padding() +} diff --git a/IPASignCraft/Features/IPAInspector/View/Components/StatCard.swift b/IPASignCraft/Features/IPAInspector/View/Components/StatCard.swift new file mode 100644 index 0000000..6015640 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/Components/StatCard.swift @@ -0,0 +1,146 @@ +// +// StatCard.swift +// IPASignCraft +// +// Created by Copilot +// + +import SwiftUI + +/// A reusable card displaying a single statistic with +/// icon, label, and value. +/// +/// Used in the IPA Inspector dashboard to show +/// quick insights like Version, Signature, Frameworks, etc. +struct StatCard: View { + + // MARK: - Properties + + /// The icon/symbol representing the stat + let icon: String + + /// Background color for the icon area + let iconColor: Color + + /// Label text describing the stat + let label: String + + /// The value to display (can be multi-line) + let value: String + + /// Optional subtitle/description below value + let subtitle: String? + + // MARK: - Initialization + + init( + icon: String, + iconColor: Color, + label: String, + value: String, + subtitle: String? = nil + ) { + self.icon = icon + self.iconColor = iconColor + self.label = label + self.value = value + self.subtitle = subtitle + } + + // MARK: - Body + + @State private var isHovering = false + + var body: some View { + + VStack(alignment: .leading, spacing: Spacing.sm) { + + // Icon with background + HStack { + + ZStack { + RoundedRectangle(cornerRadius: Spacing.xs) + .fill( + iconColor.opacity(0.15) + ) + + Image(systemName: icon) + .font(.system(size: 20, weight: .semibold)) + .foregroundColor(iconColor) + } + .frame(width: 40, height: 40) + + Spacer() + } + + // Label + Text(label) + .font(AppFont.secondary) + .foregroundColor(AppColors.secondaryText) + .lineLimit(1) + + // Value + Text(value) + .font(AppFont.section) + .fontWeight(.semibold) + .lineLimit(2) + + // Optional subtitle + if let subtitle = subtitle { + Text(subtitle) + .font(.system(size: 10, weight: .regular)) + .foregroundColor(AppColors.secondaryText) + .lineLimit(2) + } + } + .padding(Spacing.base) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + ) + .scaleEffect(isHovering ? 1.02 : 1) + .animation(.easeInOut(duration: 0.18), value: isHovering) + .onHover { hovering in + withAnimation(.easeInOut(duration: 0.18)) { + isHovering = hovering + } + } + .accessibilityElement(children: .combine) + .accessibilityLabel(label) + .accessibilityValue(value) + } +} + + +// MARK: - Preview + +#Preview { + VStack(spacing: Spacing.base) { + StatCard( + icon: "square.and.line.vertical.and.square", + iconColor: .purple, + label: "Version", + value: "5.2", + subtitle: "CFBundleShortVersionString" + ) + + StatCard( + icon: "checkmark.seal.fill", + iconColor: .green, + label: "Signature", + value: "Valid", + subtitle: "Code signature is valid" + ) + + StatCard( + icon: "person.badge.key", + iconColor: .orange, + label: "Team ID", + value: "ABCDE12345", + subtitle: "Development Team" + ) + } + .padding() +} diff --git a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift new file mode 100644 index 0000000..19cb8b8 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift @@ -0,0 +1,484 @@ +import SwiftUI + +/// Main IPA Inspector screen with dashboard layout. +/// +/// Single-column centered layout: +/// - File upload section at top +/// - Quick stat cards +/// - Expandable detail sections +/// - Status indicator at bottom +/// +/// This provides a clean, focused dashboard experience +/// for analyzing IPA file contents. +struct IPAInspectorView: View { + + // MARK: - View Model + + /// Owns IPA inspection state. + @StateObject + private var viewModel = IPAInspectorDetailViewModel() + + // MARK: - Body + + var body: some View { + + ZStack { + + // Background + inspectorBackground + + // Main content with left dashboard and right console/status + ScrollView { + HStack(alignment: .top, spacing: Spacing.lg) { + + // Left column: main dashboard + VStack(alignment: .center, spacing: Spacing.lg) { + + headerSection + + VStack(alignment: .leading, spacing: Spacing.lg) { + + // File upload section + fileUploadSection + + // Selected file info + if let ipaPath = viewModel.state.selectedIPAURL?.path { + selectedFileSection(fileName: (ipaPath as NSString).lastPathComponent) + } + + // Inspection results + if let inspection = viewModel.state.inspection { + + Divider() + + // Quick stat cards + statCardsSection(inspection: inspection) + + // Expandable sections + inspectionDetailsSection(inspection: inspection) + + // Completion status (main) + completionStatusSection + } + } + .frame(maxWidth: 800, alignment: .leading) + .padding(.horizontal, Spacing.lg) + } + .frame(maxWidth: 800) + + // Right column: console and compact status + VStack(alignment: .leading, spacing: Spacing.base) { + + // Console / Logs placeholder + VStack(alignment: .leading, spacing: Spacing.sm) { + Text("Console") + .font(AppFont.heading3) + .fontWeight(.semibold) + + Text("Live logs and inspection output") + .font(AppFont.secondary) + .foregroundColor(AppColors.secondaryText) + + // Simple scrollable console area + ScrollView { + VStack(alignment: .leading, spacing: Spacing.xs) { + ForEach(viewModel.state.log.split(separator: "\n").suffix(50), id: \.self) { line in + Text(String(line)) + .font(AppFont.small) + .foregroundColor(AppColors.secondaryText) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(Spacing.base) + } + .frame(minHeight: 220, maxHeight: 320) + .background(RoundedRectangle(cornerRadius: Radius.sm).fill(AppColors.cardSurface)) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + } + + // Compact completion/status box + compactCompletionStatusSection + + Spacer() + } + .frame(width: 340) + } + .padding(.vertical, Spacing.lg) + .padding(.horizontal, Spacing.lg) + } + } + .toolbar { + ToolbarItemGroup { + refreshButton + } + } + } +} + +// MARK: - Background +private extension IPAInspectorView { + + var inspectorBackground: some View { + + GeometryReader { geo in + + Color(nsColor: .windowBackgroundColor) + .frame(width: geo.size.width, height: geo.size.height) + } + } +} + +// MARK: - Header Section +private extension IPAInspectorView { + + var headerSection: some View { + + VStack(alignment: .leading, spacing: Spacing.xs) { + + HStack(spacing: Spacing.base) { + + // Icon + ZStack { + RoundedRectangle(cornerRadius: Spacing.xs) + .fill( + LinearGradient( + gradient: Gradient(colors: [AppColors.headerGradientStart, AppColors.headerGradientEnd]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + + Image(systemName: "magnifyingglass") + .font(.system(size: 24, weight: .semibold)) + .foregroundColor(.purple) + } + .frame(width: 50, height: 50) + + VStack(alignment: .leading, spacing: 4) { + + Text("IPA Inspector") + .font(AppFont.title) + + Text("Analyze IPA contents, signing, frameworks and security.") + .font(AppFont.secondary) + .foregroundColor(AppColors.secondaryText) + } + + Spacer() + } + .frame(maxWidth: 800, alignment: .leading) + .padding(.horizontal, Spacing.lg) + } + } +} + +// MARK: - File Upload Section +private extension IPAInspectorView { + + var fileUploadSection: some View { + + HomeFileSection( + title: "IPA File", + helper: "Select or drop the IPA you want to inspect", + filePath: Binding( + get: { viewModel.state.selectedIPAURL?.path ?? "" }, + set: { path in + if !path.isEmpty { + let url = URL(fileURLWithPath: path) + viewModel.inspectIPA(at: url) + } + } + ), + supportedTypes: [.ipa], + onSelect: { _ in } + ) + } +} + +// MARK: - Selected File Section +private extension IPAInspectorView { + + func selectedFileSection(fileName: String) -> some View { + + HStack(spacing: Spacing.base) { + + ZStack { + RoundedRectangle(cornerRadius: Spacing.xs) + .fill(AppColors.accent.opacity(0.1)) + + Image(systemName: "doc.fill") + .font(.system(size: 16)) + .foregroundColor(AppColors.accent) + } + .frame(width: 32, height: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(fileName) + .font(AppFont.body) + .fontWeight(.semibold) + + Text("Ready for inspection") + .font(AppFont.caption) + .foregroundColor(AppColors.secondaryText) + } + + Spacer() + + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundColor(AppColors.success) + } + .padding(Spacing.base) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + ) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + } +} + +// MARK: - Quick Stat Cards +private extension IPAInspectorView { + + func statCardsSection(inspection: IPAInspection) -> some View { + + VStack(alignment: .leading, spacing: Spacing.base) { + + // First row: Version, Signature, Frameworks + HStack(spacing: Spacing.base) { + + StatCard( + icon: "square.and.line.vertical.and.square", + iconColor: .purple, + label: "Version", + value: inspection.version, + subtitle: "CFBundleShortVersionString" + ) + + StatCard( + icon: "checkmark.seal.fill", + iconColor: .green, + label: "Signature", + value: inspection.hasValidSignature ? "Valid" : "Invalid", + subtitle: inspection.hasValidSignature ? "Code signature is valid" : "Code signature is invalid" + ) + + StatCard( + icon: "square.3.layers.3d", + iconColor: .blue, + label: "Frameworks", + value: "\(inspection.frameworks.count)", + subtitle: "Embedded frameworks" + ) + } + + // Second row: Team ID, Entitlements, Architecture + HStack(spacing: Spacing.base) { + + StatCard( + icon: "person.badge.key", + iconColor: .orange, + label: "Team ID", + value: inspection.teamIdentifier, + subtitle: "Development Team" + ) + + StatCard( + icon: "list.clipboard.fill", + iconColor: .yellow, + label: "Entitlements", + value: "\(inspection.entitlements.count)", + subtitle: "Total entitlements" + ) + + StatCard( + icon: "cpu", + iconColor: .pink, + label: "Architecture", + value: inspection.architectures.joined(separator: ", "), + subtitle: "Primary architecture" + ) + } + } + } +} + +// MARK: - Inspection Details Sections +private extension IPAInspectorView { + + func inspectionDetailsSection(inspection: IPAInspection) -> some View { + + VStack(spacing: Spacing.base) { + + // Overview Section + CollapsibleSection( + icon: "info.circle", + iconColor: .blue, + title: "Overview", + subtitle: "General information about the IPA and app bundle." + ) { + IPAOverviewView(inspection: inspection) + } + + // General Information Section + CollapsibleSection( + icon: "doc.text", + iconColor: .blue, + title: "General Information", + subtitle: "Bundle ID, version, build, minimum OS, and more." + ) { + IPAGeneralView(inspection: inspection) + } + + // Signing & Provisioning Section + CollapsibleSection( + icon: "checkmark.seal.fill", + iconColor: .green, + title: "Signing & Provisioning", + subtitle: "Code signature details and provisioning profile info.", + badge: inspection.hasValidSignature ? "Valid" : "Invalid", + badgeColor: inspection.hasValidSignature ? .green : .red + ) { + IPASigningView(inspection: inspection) + } + + // Entitlements Section + CollapsibleSection( + icon: "list.clipboard.fill", + iconColor: .yellow, + title: "Entitlements", + subtitle: "View all entitlements included in this IPA.", + badge: "\(inspection.entitlements.count)", + badgeColor: .yellow + ) { + IPAEntitlementsView(inspection: inspection) + } + + // Binary Analysis Section + CollapsibleSection( + icon: "cpu.fill", + iconColor: .orange, + title: "Binary Analysis", + subtitle: "Mach-O information, architectures, encryption and more." + ) { + IPABinaryView(inspection: inspection) + } + + // Frameworks Section + CollapsibleSection( + icon: "cube.transparent", + iconColor: .blue, + title: "Frameworks", + subtitle: "Embedded frameworks and their signing status.", + badge: "\(inspection.frameworks.count)", + badgeColor: .blue + ) { + IPAFrameworksView(inspection: inspection) + } + + // Security Section + CollapsibleSection( + icon: "lock.shield.fill", + iconColor: .red, + title: "Security", + subtitle: "Security checks, warnings and recommendations.", + badge: "No issues", + badgeColor: .green + ) { + IPASecurityView(inspection: inspection) + } + } + } +} + +// MARK: - Completion Status +private extension IPAInspectorView { + + var completionStatusSection: some View { + + HStack(spacing: Spacing.sm) { + + Image(systemName: "info.circle.fill") + .font(.system(size: 14)) + .foregroundColor(.blue) + + Text("Inspection completed successfully") + .font(AppFont.secondary) + + Spacer() + + Text("Today, 10:42 AM") + .font(AppFont.caption) + .foregroundColor(AppColors.secondaryText) + + Image(systemName: "clock") + .font(.system(size: 12)) + .foregroundColor(AppColors.secondaryText) + } + .padding(Spacing.base) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + ) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + } + + /// Compact variant of completion status suitable for sidebar. + var compactCompletionStatusSection: some View { + + HStack(spacing: Spacing.xs) { + Image(systemName: "info.circle.fill") + .font(.system(size: 12)) + .foregroundColor(.blue) + + VStack(alignment: .leading, spacing: 2) { + Text("Inspection completed") + .font(AppFont.small) + .fontWeight(.semibold) + + Text("Today, 10:42 AM") + .font(AppFont.small) + .foregroundColor(AppColors.secondaryText) + } + + Spacer() + } + .padding(Spacing.xs) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + ) + .shadow(color: AppColors.cardShadow, radius: 4, x: 0, y: 1) + } +} + +// MARK: - Toolbar +private extension IPAInspectorView { + + /// Re-runs IPA inspection. + var refreshButton: some View { + + Button { + + if let ipaURL = viewModel.state.selectedIPAURL { + viewModel.inspectIPA(at: ipaURL) + } + + } label: { + + Label( + "Refresh", + systemImage: "arrow.clockwise" + ) + } + .disabled( + viewModel.state.selectedIPAURL == nil + ) + } +} + +// MARK: - Preview + +#Preview { + IPAInspectorView() +} diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPABinaryView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPABinaryView.swift new file mode 100644 index 0000000..d7ec776 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPABinaryView.swift @@ -0,0 +1,237 @@ +// +// IPABinaryView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Displays binary-level inspection data +/// extracted from the IPA executable. +/// +/// Purpose: +/// Provide technical information related to: +/// - CPU architectures +/// - Mach-O binary analysis +/// - encryption state +/// - linked binary information +/// +/// This screen is more technical than +/// General or Entitlements views and is +/// primarily useful for developers, +/// reverse engineering analysis and +/// signing diagnostics. +/// +/// Future improvements may include: +/// - Mach-O load commands +/// - segment analysis +/// - linked dylibs +/// - bitcode detection +/// - binary size analysis +/// - symbol inspection +struct IPABinaryView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + headerSection + + architectureSection + + binaryStatusSection + } + } +} + +// MARK: - Header Section + +private extension IPABinaryView { + + /// Displays introductory information + /// about binary inspection. + var headerSection: some View { + + VStack( + alignment: .leading, + spacing: 8 + ) { + + Text("Binary Analysis") + .font(.largeTitle.bold()) + + Text( + "Technical inspection details extracted from the application executable." + ) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Architecture Section + +private extension IPABinaryView { + + /// Displays supported CPU architectures. + /// + /// Examples: + /// - arm64 + /// - armv7 + /// - x86_64 + var architectureSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Architectures" + ) + + LazyVStack(spacing: 12) { + + ForEach( + inspection.architectures, + id: \.self + ) { architecture in + + architectureRow( + title: architecture + ) + } + } + } + } +} + +// MARK: - Binary Status Section + +private extension IPABinaryView { + + /// Displays simplified binary validation + /// and executable metadata. + /// + /// Future versions may include: + /// - encryption detection + /// - bitcode availability + /// - executable size + /// - PIE support + var binaryStatusSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Binary Status" + ) + + statusRow( + title: "Executable Present", + passed: true + ) + + statusRow( + title: "Architectures Detected", + passed: !inspection.architectures.isEmpty + ) + + statusRow( + title: "Signature Linked", + passed: inspection.hasValidSignature + ) + } + } +} + +// MARK: - Reusable Rows + +private extension IPABinaryView { + + /// Displays architecture information row. + @ViewBuilder + func architectureRow( + title: String + ) -> some View { + + HStack(spacing: 12) { + + Image(systemName: "cpu") + .foregroundStyle(.blue) + + Text(title) + + Spacer() + } + .padding(14) + .background( + Color.gray.opacity(0.08) + ) + .clipShape( + RoundedRectangle(cornerRadius: 12) + ) + } + + /// Displays validation/status row. + @ViewBuilder + func statusRow( + title: String, + passed: Bool + ) -> some View { + + HStack(spacing: 10) { + + Image( + systemName: passed + ? "checkmark.circle.fill" + : "xmark.circle.fill" + ) + .foregroundStyle( + passed + ? .green + : .red + ) + + Text(title) + + Spacer() + } + } +} + +// MARK: - Shared Helpers + +private extension IPABinaryView { + + /// Shared section title styling. + func sectionHeader( + title: String + ) -> some View { + + Text(title) + .font(.title3.bold()) + } +} + +// MARK: - Preview + +#Preview { + + IPABinaryView( + inspection: .mock + ) +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPAEntitlementsView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPAEntitlementsView.swift new file mode 100644 index 0000000..6e3a285 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAEntitlementsView.swift @@ -0,0 +1,215 @@ +// +// IPAEntitlementsView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Displays entitlement capabilities +/// discovered inside the IPA. +/// +/// Purpose: +/// Help developers quickly understand +/// which Apple capabilities are enabled. +/// +/// Examples: +/// - Push Notifications +/// - App Groups +/// - Keychain Sharing +/// - Associated Domains +/// +/// The UI intentionally presents +/// simplified human-readable capabilities +/// instead of raw entitlement XML. +/// +/// Future versions may include: +/// - raw entitlement viewer +/// - entitlement diffing +/// - validation warnings +/// - entitlement search/filtering +struct IPAEntitlementsView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + headerSection + + capabilitiesSection + + entitlementSummarySection + } + } +} + +// MARK: - Header Section + +private extension IPAEntitlementsView { + + /// Displays introductory information + /// about entitlement inspection. + var headerSection: some View { + + VStack( + alignment: .leading, + spacing: 8 + ) { + + Text("Entitlements") + .font(.largeTitle.bold()) + + Text( + "Capabilities and permissions enabled for this application." + ) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Capabilities Section + +private extension IPAEntitlementsView { + + /// Displays human-readable + /// entitlement capabilities. + var capabilitiesSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Enabled Capabilities" + ) + + if inspection.entitlements.isEmpty { + + emptyCapabilitiesView + + } else { + + LazyVStack(spacing: 12) { + + ForEach( + inspection.entitlements, + id: \.self + ) { entitlement in + + entitlementRow( + title: entitlement + ) + } + } + } + } + } +} + +// MARK: - Summary Section + +private extension IPAEntitlementsView { + + /// Displays entitlement statistics + /// and quick metadata. + var entitlementSummarySection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Summary" + ) + + KeyValueRow( + key: "Total Capabilities", + value: "\(inspection.entitlements.count)" + ) + } + } +} + +// MARK: - Empty State + +private extension IPAEntitlementsView { + + /// Displayed when no entitlements + /// are discovered. + var emptyCapabilitiesView: some View { + + ContentUnavailableView( + "No Entitlements Found", + systemImage: "lock.slash", + description: Text( + "The application does not contain any detected capabilities." + ) + ) + } +} + +// MARK: - Reusable Rows + +private extension IPAEntitlementsView { + + /// Displays a single entitlement capability. + @ViewBuilder + func entitlementRow( + title: String + ) -> some View { + + HStack(spacing: 12) { + + Image(systemName: "checkmark.circle.fill") + .foregroundStyle(.green) + + Text(title) + + Spacer() + } + .padding(14) + .background( + Color.gray.opacity(0.08) + ) + .clipShape( + RoundedRectangle(cornerRadius: 12) + ) + } +} + +// MARK: - Shared Helpers + +private extension IPAEntitlementsView { + + /// Shared section title styling. + func sectionHeader( + title: String + ) -> some View { + + Text(title) + .font(.title3.bold()) + } +} + +// MARK: - Preview + +#Preview { + + IPAEntitlementsView( + inspection: .mock + ) +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift new file mode 100644 index 0000000..a9d5956 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift @@ -0,0 +1,201 @@ +// +// IPAFrameworksView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Displays embedded frameworks discovered +/// inside the IPA bundle. +/// +/// Purpose: +/// Help developers inspect: +/// - embedded frameworks +/// - signing state +/// - framework size +/// - dependency structure +/// +/// Example frameworks: +/// - Firebase.framework +/// - Alamofire.framework +/// - GoogleMaps.framework +/// +/// Future improvements may include: +/// - framework version detection +/// - duplicate framework warnings +/// - unsigned framework detection +/// - weak-linked framework analysis +/// - framework search/filtering +struct IPAFrameworksView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + headerSection + + frameworkTableSection + } + .padding(24) + } +} + +// MARK: - Header Section + +private extension IPAFrameworksView { + + /// Displays introductory framework + /// inspection information. + var headerSection: some View { + + VStack( + alignment: .leading, + spacing: 8 + ) { + + Text("Frameworks") + .font(.largeTitle.bold()) + + Text( + "Embedded frameworks and dependency inspection." + ) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Framework Table + +private extension IPAFrameworksView { + + /// Displays embedded frameworks + /// using a macOS table layout. + /// + /// Columns: + /// - framework name + /// - signing status + /// - size + var frameworkTableSection: some View { + + Group { + + if inspection.frameworks.isEmpty { + + emptyFrameworkView + + } else { + + Table(inspection.frameworks) { + + // Framework name + TableColumn("Framework") { framework in + + frameworkNameCell( + framework + ) + } + + // Signature status + TableColumn("Signed") { framework in + + frameworkSigningCell( + framework + ) + } + + // Human-readable size + TableColumn("Size") { framework in + + Text(framework.size) + } + } + } + } + } +} + +// MARK: - Empty State + +private extension IPAFrameworksView { + + /// Displayed when no embedded + /// frameworks are detected. + var emptyFrameworkView: some View { + + ContentUnavailableView( + "No Frameworks Found", + systemImage: "shippingbox", + description: Text( + "The application does not contain embedded frameworks." + ) + ) + } +} + +// MARK: - Table Cells + +private extension IPAFrameworksView { + + /// Displays framework name cell. + @ViewBuilder + func frameworkNameCell( + _ framework: FrameworkInfo + ) -> some View { + + HStack(spacing: 10) { + + Image(systemName: "shippingbox") + + Text(framework.name) + } + } + + /// Displays framework signing status. + @ViewBuilder + func frameworkSigningCell( + _ framework: FrameworkInfo + ) -> some View { + + HStack(spacing: 8) { + + Image( + systemName: framework.isSigned + ? "checkmark.circle.fill" + : "xmark.circle.fill" + ) + .foregroundStyle( + framework.isSigned + ? .green + : .red + ) + + Text( + framework.isSigned + ? "Valid" + : "Invalid" + ) + } + } +} + +// MARK: - Preview + +#Preview { + + IPAFrameworksView( + inspection: .mock + ) +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPAGeneralView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPAGeneralView.swift new file mode 100644 index 0000000..856ce6f --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAGeneralView.swift @@ -0,0 +1,165 @@ +// +// IPAGeneralView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Displays general metadata information +/// extracted from the IPA. +/// +/// Purpose: +/// Present human-readable application details +/// commonly found inside: +/// - Info.plist +/// - bundle metadata +/// - application configuration +/// +/// This screen should remain simple, +/// readable and non-technical compared +/// to binary/security inspection screens. +struct IPAGeneralView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + applicationSection + + versionSection + + bundleSection + } + } +} + +// MARK: - Application Information + +private extension IPAGeneralView { + + /// Displays high-level application identity. + /// + /// Example: + /// - app name + /// - display name + var applicationSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Application" + ) + + KeyValueRow( + key: "App Name", + value: inspection.appName + ) + } + } +} + +// MARK: - Version Information + +private extension IPAGeneralView { + + /// Displays version-related metadata. + /// + /// Derived from: + /// - CFBundleShortVersionString + /// - CFBundleVersion + var versionSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Version Information" + ) + + KeyValueRow( + key: "Version", + value: inspection.version + ) + + KeyValueRow( + key: "Build Number", + value: inspection.buildNumber + ) + } + } +} + +// MARK: - Bundle Information + +private extension IPAGeneralView { + + /// Displays bundle-level identifiers. + /// + /// Example: + /// - bundle identifier + /// - team identifier + var bundleSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Bundle Information" + ) + + KeyValueRow( + key: "Bundle Identifier", + value: inspection.bundleIdentifier + ) + + KeyValueRow( + key: "Team Identifier", + value: inspection.teamIdentifier + ) + } + } +} + +// MARK: - Shared Helpers + +private extension IPAGeneralView { + + /// Shared section title styling + /// used throughout the inspector. + func sectionHeader( + title: String + ) -> some View { + + Text(title) + .font(.title3.bold()) + } +} + +// MARK: - Preview + +#Preview { + + IPAGeneralView( + inspection: .mock + ) +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift new file mode 100644 index 0000000..b6ec9d3 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift @@ -0,0 +1,162 @@ +// +// IPAOverviewView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Overview dashboard for the inspected IPA. +/// +/// Purpose: +/// Provide a quick high-level understanding +/// of the application before diving into +/// technical inspection details. +/// +/// This screen should remain lightweight, +/// readable and visually structured. +/// +/// Recommended focus: +/// - app identity +/// - signing status +/// - architectures +/// - capabilities +/// - framework count +struct IPAOverviewView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + headerSection + + summaryCardsSection + + capabilitiesSection + } + } +} + +// MARK: - Header Section + +private extension IPAOverviewView { + + /// Displays app identity information. + /// + /// Example: + /// - app name + /// - bundle identifier + /// - version/build + var headerSection: some View { + + VStack( + alignment: .leading, + spacing: 8 + ) { + + Text(inspection.appName) + .font(.largeTitle.bold()) + + Text(inspection.bundleIdentifier) + .foregroundStyle(.secondary) + + Text( + "Version \(inspection.version) (\(inspection.buildNumber))" + ) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Summary Cards + +private extension IPAOverviewView { + + /// Displays quick inspection highlights. + /// + /// These cards should provide + /// instant visual understanding + /// of the IPA state. + var summaryCardsSection: some View { + + HStack(spacing: 16) { + InspectorInfoCard( + title: "Team", + value: inspection.teamIdentifier + ) + + InspectorInfoCard( + title: "Architectures", + value: inspection.architectures.joined(separator: ", ") + ) + + InspectorInfoCard( + title: "Frameworks", + value: "\(inspection.frameworks.count)" + ) + + InspectorInfoCard( + title: "Signature", + value: inspection.hasValidSignature + ? "Valid" + : "Invalid" + ) + } + } +} + +// MARK: - Capabilities + +private extension IPAOverviewView { + + /// Displays simplified entitlement capabilities. + /// + /// Examples: + /// - Push Notifications + /// - App Groups + /// - Keychain Sharing + var capabilitiesSection: some View { + + VStack( + alignment: .leading, + spacing: 12 + ) { + + Text("Capabilities") + .font(.headline) + + ForEach( + inspection.entitlements, + id: \.self + ) { entitlement in + + Label( + entitlement, + systemImage: "checkmark.circle.fill" + ) + .foregroundStyle(.green) + } + } + } +} + +// MARK: - Preview + +#Preview { + + IPAOverviewView( + inspection: .mock + ) +} diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift new file mode 100644 index 0000000..f02a11e --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift @@ -0,0 +1,275 @@ +// +// IPASecurityView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Displays security-related inspection +/// and validation results for the IPA. +/// +/// Purpose: +/// Help developers identify: +/// - weak security configurations +/// - risky application settings +/// - suspicious binary indicators +/// - validation concerns +/// +/// This screen is intentionally designed +/// as a high-level diagnostics dashboard +/// instead of a low-level security audit. +/// +/// Future improvements may include: +/// - ATS analysis +/// - jailbreak detection indicators +/// - debug symbol detection +/// - insecure URL scheme detection +/// - weak cryptography checks +/// - binary hardening analysis +struct IPASecurityView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + headerSection + + securityStatusSection + + validationChecksSection + + recommendationsSection + } + } +} + +// MARK: - Header Section + +private extension IPASecurityView { + + /// Displays introductory security + /// inspection information. + var headerSection: some View { + + VStack( + alignment: .leading, + spacing: 8 + ) { + + Text("Security") + .font(.largeTitle.bold()) + + Text( + "Security diagnostics and validation checks for the inspected IPA." + ) + .foregroundStyle(.secondary) + } + } +} + +// MARK: - Security Status + +private extension IPASecurityView { + + /// Displays overall security state. + /// + /// Future versions may calculate + /// a real security score based on: + /// - ATS configuration + /// - binary encryption + /// - entitlement risks + /// - unsigned frameworks + var securityStatusSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Security Status" + ) + + HStack(spacing: 14) { + + Image(systemName: "shield.checkered") + .font(.largeTitle) + .foregroundStyle(.green) + + VStack( + alignment: .leading, + spacing: 4 + ) { + + Text("No Major Issues Detected") + .font(.headline) + + Text( + "The IPA passed the currently available security checks." + ) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - Validation Checks + +private extension IPASecurityView { + + /// Displays simplified validation checks. + /// + /// Future checks may become dynamic + /// once binary and plist analysis + /// are implemented. + var validationChecksSection: some View { + + VStack( + alignment: .leading, + spacing: 14 + ) { + + sectionHeader( + title: "Validation Checks" + ) + + validationRow( + title: "Valid Code Signature", + passed: inspection.hasValidSignature + ) + + validationRow( + title: "Signed Frameworks", + passed: inspection.frameworks.allSatisfy { + $0.isSigned + } + ) + + validationRow( + title: "Architectures Present", + passed: !inspection.architectures.isEmpty + ) + } + } +} + +// MARK: - Recommendations + +private extension IPASecurityView { + + /// Displays developer-facing + /// recommendations and guidance. + /// + /// Future versions may generate + /// contextual recommendations + /// dynamically from inspection results. + var recommendationsSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Recommendations" + ) + + recommendationRow( + text: "Verify provisioning profile expiration dates regularly." + ) + + recommendationRow( + text: "Ensure all embedded frameworks are properly signed." + ) + + recommendationRow( + text: "Review entitlements before distributing production builds." + ) + } + } +} + +// MARK: - Reusable Components + +private extension IPASecurityView { + + /// Displays validation result row. + @ViewBuilder + func validationRow( + title: String, + passed: Bool + ) -> some View { + + HStack(spacing: 10) { + + Image( + systemName: passed + ? "checkmark.circle.fill" + : "xmark.circle.fill" + ) + .foregroundStyle( + passed + ? .green + : .red + ) + + Text(title) + + Spacer() + } + } + + /// Displays recommendation/help row. + @ViewBuilder + func recommendationRow( + text: String + ) -> some View { + + HStack(alignment: .top, spacing: 10) { + + Image(systemName: "lightbulb") + .foregroundStyle(.yellow) + + Text(text) + + Spacer() + } + } +} + +// MARK: - Shared Helpers + +private extension IPASecurityView { + + /// Shared section title styling. + func sectionHeader( + title: String + ) -> some View { + + Text(title) + .font(.title3.bold()) + } +} + +// MARK: - Preview + +#Preview { + + IPASecurityView( + inspection: .mock + ) +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift new file mode 100644 index 0000000..10dc72b --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift @@ -0,0 +1,228 @@ +// +// IPASigningView.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Displays signing and provisioning +/// information for the inspected IPA. +/// +/// Purpose: +/// Help developers quickly validate: +/// - signing identity +/// - provisioning information +/// - signature health +/// - team consistency +/// +/// This screen becomes especially useful +/// when diagnosing installation or +/// resigning issues. +struct IPASigningView: View { + + // MARK: - Properties + + /// Complete IPA inspection result. + let inspection: IPAInspection + + // MARK: - Body + + var body: some View { + + VStack( + alignment: .leading, + spacing: 24 + ) { + + signatureStatusSection + + signingInformationSection + + validationSection + } + } +} + +// MARK: - Signature Status + +private extension IPASigningView { + + /// Displays the overall signature state. + /// + /// Example: + /// - Valid + /// - Invalid + /// - Expired + var signatureStatusSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Signature Status" + ) + + HStack(spacing: 12) { + + Image( + systemName: inspection.hasValidSignature + ? "checkmark.seal.fill" + : "xmark.seal.fill" + ) + .foregroundStyle( + inspection.hasValidSignature + ? .green + : .red + ) + .font(.title2) + + VStack( + alignment: .leading, + spacing: 4 + ) { + + Text( + inspection.hasValidSignature + ? "Valid Signature" + : "Invalid Signature" + ) + .font(.headline) + + Text( + inspection.hasValidSignature + ? "The IPA signature appears valid." + : "The IPA signature validation failed." + ) + .foregroundStyle(.secondary) + } + } + } + } +} + +// MARK: - Signing Information + +private extension IPASigningView { + + /// Displays signing-related metadata. + /// + /// Example: + /// - team identifier + /// - certificate info + /// - provisioning profile + var signingInformationSection: some View { + + VStack( + alignment: .leading, + spacing: 16 + ) { + + sectionHeader( + title: "Signing Information" + ) + + KeyValueRow( + key: "Team Identifier", + value: inspection.teamIdentifier + ) + + KeyValueRow( + key: "Bundle Identifier", + value: inspection.bundleIdentifier + ) + } + } +} + +// MARK: - Validation Checks + +private extension IPASigningView { + + /// Displays simplified validation results. + /// + /// Future improvements may include: + /// - entitlement mismatch detection + /// - expired profile warnings + /// - unsupported device checks + var validationSection: some View { + + VStack( + alignment: .leading, + spacing: 12 + ) { + + sectionHeader( + title: "Validation" + ) + + validationRow( + title: "Signature Verification", + passed: inspection.hasValidSignature + ) + + validationRow( + title: "Bundle Identifier Check", + passed: true + ) + + validationRow( + title: "Provision Match", + passed: true + ) + } + } +} + +// MARK: - Shared Helpers + +private extension IPASigningView { + + /// Shared section title styling. + func sectionHeader( + title: String + ) -> some View { + + Text(title) + .font(.title3.bold()) + } + + /// Reusable validation status row. + @ViewBuilder + func validationRow( + title: String, + passed: Bool + ) -> some View { + + HStack(spacing: 10) { + + Image( + systemName: passed + ? "checkmark.circle.fill" + : "xmark.circle.fill" + ) + .foregroundStyle( + passed + ? .green + : .red + ) + + Text(title) + + Spacer() + } + } +} + +// MARK: - Preview + +#Preview { + + IPASigningView( + inspection: .mock + ) +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/View/sections/KeyValueRow.swift b/IPASignCraft/Features/IPAInspector/View/sections/KeyValueRow.swift new file mode 100644 index 0000000..aeaf7a9 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/KeyValueRow.swift @@ -0,0 +1,56 @@ +// +// KeyValueRow.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import SwiftUI + +/// Reusable key-value row used throughout +/// the IPA Inspector feature. +/// +/// Example: +/// Bundle Identifier → com.company.app +/// +/// Designed to provide a consistent +/// metadata presentation style. +struct KeyValueRow: View { + + // MARK: - Properties + + /// Left-side label/title. + let key: String + + /// Right-side displayed value. + let value: String + + // MARK: - Body + + var body: some View { + + HStack(alignment: .top) { + + Text(key) + .foregroundStyle(.secondary) + .frame(width: 180, alignment: .leading) + + Text(value) + .textSelection(.enabled) + + Spacer() + } + } +} + +// MARK: - Preview + +#Preview { + + KeyValueRow( + key: "Bundle Identifier", + value: "com.company.app" + ) + .padding() +} \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift new file mode 100644 index 0000000..b1460d8 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift @@ -0,0 +1,115 @@ +// +// IPAInspectorDetailViewModel.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation +import Combine + +/// ViewModel responsible for managing +/// IPA inspection state and loading flow. +/// +/// Responsibilities: +/// - open IPA files +/// - trigger inspection pipeline +/// - expose loading/error states +/// - provide inspection result to UI +@MainActor +final class IPAInspectorDetailViewModel: ObservableObject { + + // MARK: - Published State + + /// Centralized state object for IPA Inspector. + @Published var state = IPAInspectorState() + + // MARK: - Dependencies + + private let inspectionService: IPAInspectionServicing + + // MARK: - Initialization + + init( + inspectionService: IPAInspectionServicing = IPAInspectionService() + ) { + self.inspectionService = inspectionService + } + + // MARK: - Public Methods + + /// Opens and inspects an IPA file. + func inspectIPA(at url: URL) { + + state.selectedIPAURL = url + + Task { + + await runInspection(for: url) + } + } + + /// Clears currently loaded inspection. + func resetInspection() { + + state.inspection = nil + state.errorMessage = nil + state.selectedSection = .overview + state.selectedIPAURL = nil + state.log = "" + } + + /// Clears inspection logs. + func clearLogs() { + + state.log = "" + } +} + +private extension IPAInspectorDetailViewModel { + + /// Logs a message with timestamp. + func addLog(_ message: String) { + + let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium) + let logEntry = "[\(timestamp)] \(message)" + + if state.log.isEmpty { + state.log = logEntry + } else { + state.log.append("\n\(logEntry)") + } + } + + /// Executes the inspection pipeline. + func runInspection( + for url: URL + ) async { + + state.isLoading = true + state.errorMessage = nil + addLog("Starting inspection...") + + defer { + state.isLoading = false + } + + do { + + addLog("Loading IPA file: \((url.lastPathComponent))") + + let result = try await inspectionService.inspectIPA( + at: url + ) + + state.inspection = result + addLog("āœ“ Inspection completed successfully") + + } catch { + + state.errorMessage = error.localizedDescription + addLog("āœ— Inspection failed: \(error.localizedDescription)") + } + } +} diff --git a/IPASignCraft/Features/Resign/View/HomeView.swift b/IPASignCraft/Features/Resign/View/HomeView.swift index d6dd7ad..18598c7 100644 --- a/IPASignCraft/Features/Resign/View/HomeView.swift +++ b/IPASignCraft/Features/Resign/View/HomeView.swift @@ -97,35 +97,16 @@ private extension HomeView { private extension HomeView { /// IPA input: drag/drop or browse file var ipaSection: some View { - HomeSectionView("IPA File") { - VStack(alignment: .leading, spacing: Spacing.sm) { - - /// Helper text for clarity - Text("Select or drop the IPA you want to re-sign") - .font(AppFont.secondary) - .foregroundColor(AppColors.secondaryText) - - /// File input binding to ViewModel - FileDropView( - title: nil, - filePath: Binding( - get: { viewModel.state.ipaURL?.path ?? ""}, - set: { viewModel.updateIPAPath($0) } - ), - supportedTypes: [.ipa] - ) - - /// Show file summary when selected - if let ipaPath = viewModel.state.ipaURL?.path { - infoRow( - icon: "doc.fill", - color: AppColors.accent, - title: (ipaPath as NSString).lastPathComponent, - subtitle: "Ready for signing" - ) - } - } - } + HomeFileSection( + title: "IPA File", + helper: "Select or drop the IPA you want to re-sign", + filePath: Binding( + get: { viewModel.state.ipaURL?.path ?? "" }, + set: { viewModel.updateIPAPath($0) } + ), + supportedTypes: [.ipa], + onSelect: { _ in } + ) } } @@ -151,7 +132,7 @@ private extension HomeView { /// Display selected profile info if let profilePath = viewModel.state.profileURL?.path { - infoRow( + InfoRow( icon: "checkmark.seal.fill", color: AppColors.success, title: (profilePath as NSString).lastPathComponent, @@ -604,29 +585,7 @@ fileprivate extension HomeView { } } -//MARK: - Resusable -fileprivate extension HomeView { - /// Small reusable row for selected file summaries - func infoRow(icon: String, color: Color, title: String, subtitle: String) -> some View { - HStack(spacing: Spacing.sm) { - Image(systemName: icon) - .foregroundColor(color) - - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(AppFont.secondary) - .lineLimit(1) - - Text(subtitle) - .font(AppFont.secondary) - .foregroundColor(AppColors.secondaryText) - } - - Spacer() - } - .fieldContainer() - } -} + #Preview { HomeView() diff --git a/IPASignCraft/Features/Sidebar/Model/SidebarItem.swift b/IPASignCraft/Features/Sidebar/Model/SidebarItem.swift index 708dd06..1c46147 100644 --- a/IPASignCraft/Features/Sidebar/Model/SidebarItem.swift +++ b/IPASignCraft/Features/Sidebar/Model/SidebarItem.swift @@ -9,4 +9,5 @@ import Foundation enum SidebarItem: Hashable { case home + case ipaInspector } diff --git a/IPASignCraft/Features/Sidebar/SidebarView.swift b/IPASignCraft/Features/Sidebar/SidebarView.swift index 2ca06cf..b25b2f3 100644 --- a/IPASignCraft/Features/Sidebar/SidebarView.swift +++ b/IPASignCraft/Features/Sidebar/SidebarView.swift @@ -73,7 +73,20 @@ private extension SidebarView { var menuOptions: some View { VStack(alignment: .leading, spacing: 6) { - sidebarItem(icon: "house", title: "Home", item: .home) + + // Main resign workflow screen + sidebarItem( + icon: "signature", + title: "Re-sign IPA", + item: .home + ) + + // IPA inspection and diagnostics feature + sidebarItem( + icon: "magnifyingglass", + title: "IPA Inspector", + item: .ipaInspector + ) } } diff --git a/IPASignCraft/Service/IPAExtractorService.swift b/IPASignCraft/Service/IPAExtractorService.swift index 2f1dc56..ff54e26 100644 --- a/IPASignCraft/Service/IPAExtractorService.swift +++ b/IPASignCraft/Service/IPAExtractorService.swift @@ -9,12 +9,11 @@ import Foundation struct IPAExtractorService { static func extractIPA(at ipaURL: URL, to workspace: URL) throws -> URL { - let payloadURL = workspace.appendingPathComponent("Payload") - - try FileManager.default.createDirectory(at: payloadURL, withIntermediateDirectories: true) - // unzip IPA → Payload + // unzip IPA → creates Payload automatically try unzip(ipaURL, to: workspace) + let payloadURL = workspace.appendingPathComponent("Payload") + guard let appURL = try FileManager.default .contentsOfDirectory(at: payloadURL, includingPropertiesForKeys: nil) .first(where: { $0.pathExtension == "app" }) else { @@ -24,6 +23,20 @@ struct IPAExtractorService { } static func unzip(_ ipaURL: URL, to destination: URL) throws { + + // Verify IPA file exists and is readable + guard FileManager.default.fileExists(atPath: ipaURL.path) else { + throw IPASignCraftError.ipaNotFound(path: ipaURL.path) + } + + // Verify destination directory exists + if !FileManager.default.fileExists(atPath: destination.path) { + try FileManager.default.createDirectory( + at: destination, + withIntermediateDirectories: true + ) + } + let process = Process() process.executableURL = URL(fileURLWithPath: "/usr/bin/unzip") process.arguments = [ @@ -32,11 +45,18 @@ struct IPAExtractorService { destination.path ] + // Capture stderr for better error messages + let errorPipe = Pipe() + process.standardError = errorPipe + try process.run() process.waitUntilExit() if process.terminationStatus != 0 { - throw NSError(domain: "Unzip failed", code: Int(process.terminationStatus)) + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let errorMessage = String(data: errorData, encoding: .utf8) ?? "Unknown error" + + throw IPASignCraftError.extractionFailed(reason: errorMessage) } } } diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift new file mode 100644 index 0000000..4f490f2 --- /dev/null +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -0,0 +1,266 @@ +// +// IPAInspectionService.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Default implementation responsible for +/// running the IPA inspection pipeline. +/// +/// Responsibilities: +/// - validate IPA file +/// - extract IPA contents +/// - locate .app bundle +/// - parse application metadata +/// - inspect frameworks +/// - build IPAInspection model +/// +/// This service should remain focused on +/// orchestration logic. +/// +/// Heavy parsing logic should gradually move into: +/// - parsers +/// - analyzers +/// - helper services +final class IPAInspectionService: IPAInspectionServicing { + + // MARK: - Public API + + /// Runs the complete IPA inspection pipeline. + func inspectIPA( + at url: URL + ) async throws -> IPAInspection { + + // Validate file existence + try validateIPA(at: url) + + // Extract IPA contents + let extractedURL = try extractIPA( + at: url + ) + + // Locate .app bundle + let appBundleURL = try locateAppBundle( + inside: extractedURL + ) + + // Parse basic application metadata + let info = try parseInfoPlist( + from: appBundleURL + ) + + // Parse embedded frameworks + let frameworks = try scanFrameworks( + inside: appBundleURL + ) + + // Build inspection model + return IPAInspection( + + appName: info.appName, + + bundleIdentifier: info.bundleIdentifier, + + version: info.version, + + buildNumber: info.buildNumber, + + teamIdentifier: "UNKNOWN", + + architectures: ["arm64"], + + hasValidSignature: true, + + entitlements: [ + "Push Notifications", + "Keychain Sharing" + ], + + frameworks: frameworks + ) + } +} + +// MARK: - Validation + +private extension IPAInspectionService { + + /// Ensures the selected file + /// is a valid IPA. + func validateIPA( + at url: URL + ) throws { + + guard FileManager.default.fileExists( + atPath: url.path + ) else { + + throw IPAInspectionError.fileNotFound + } + + guard url.pathExtension.lowercased() == "ipa" else { + + throw IPAInspectionError.invalidIPA + } + } +} + +// MARK: - IPA Extraction + +private extension IPAInspectionService { + + /// Extracts the IPA contents + /// into a temporary directory. + func extractIPA( + at url: URL + ) throws -> URL { + + let temporaryDirectory = + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + + try FileManager.default.createDirectory( + at: temporaryDirectory, + withIntermediateDirectories: true + ) + + // Extract IPA using unzip command + try IPAExtractorService.unzip(url, to: temporaryDirectory) + + return temporaryDirectory + } +} + +// MARK: - App Bundle Discovery + +private extension IPAInspectionService { + + /// Locates the main .app bundle + /// inside the extracted IPA contents. + /// + /// Expected structure: + /// Payload/AppName.app + func locateAppBundle( + inside extractedURL: URL + ) throws -> URL { + + let payloadURL = + extractedURL.appendingPathComponent( + "Payload" + ) + + // Verify Payload directory exists + guard FileManager.default.fileExists(atPath: payloadURL.path) else { + throw IPAInspectionError.appBundleNotFound + } + + let contents = try FileManager.default.contentsOfDirectory( + at: payloadURL, + includingPropertiesForKeys: nil + ) + + guard let appBundle = contents.first( + where: { + $0.pathExtension == "app" + } + ) else { + + throw IPAInspectionError.appBundleNotFound + } + + return appBundle + } +} + +// MARK: - Info.plist Parsing + +private extension IPAInspectionService { + + /// Parses application metadata + /// from Info.plist. + func parseInfoPlist( + from appBundleURL: URL + ) throws -> ParsedIPAInfo { + + let plistURL = + appBundleURL.appendingPathComponent( + "Info.plist" + ) + + guard let dictionary = NSDictionary( + contentsOf: plistURL + ) as? [String: Any] else { + + throw IPAInspectionError.infoPlistMissing + } + + return ParsedIPAInfo( + + appName: + dictionary["CFBundleDisplayName"] + as? String + ?? "Unknown App", + + bundleIdentifier: + dictionary["CFBundleIdentifier"] + as? String + ?? "Unknown", + + version: + dictionary["CFBundleShortVersionString"] + as? String + ?? "0.0", + + buildNumber: + dictionary["CFBundleVersion"] + as? String + ?? "0" + ) + } +} + +// MARK: - Framework Scanning + +private extension IPAInspectionService { + + /// Scans embedded frameworks + /// inside the application bundle. + func scanFrameworks( + inside appBundleURL: URL + ) throws -> [FrameworkInfo] { + + let frameworksURL = + appBundleURL.appendingPathComponent( + "Frameworks" + ) + + guard FileManager.default.fileExists( + atPath: frameworksURL.path + ) else { + + return [] + } + + let contents = try FileManager.default.contentsOfDirectory( + at: frameworksURL, + includingPropertiesForKeys: nil + ) + + return contents + .filter { + $0.pathExtension == "framework" + } + .map { frameworkURL in + + FrameworkInfo( + name: frameworkURL.lastPathComponent, + isSigned: true, + size: "Unknown" + ) + } + } +} \ No newline at end of file diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionServicing.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionServicing.swift new file mode 100644 index 0000000..b2a408e --- /dev/null +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionServicing.swift @@ -0,0 +1,62 @@ +// +// IPAInspectionServicing.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 18/05/26. +// + + +import Foundation + +/// Defines the contract for IPA inspection services. +/// +/// Purpose: +/// Abstract the inspection pipeline behind +/// a protocol so the UI layer does not depend +/// directly on concrete inspection implementations. +/// +/// Benefits: +/// - easier testing +/// - mock implementations +/// - dependency injection +/// - future extensibility +/// +/// Typical responsibilities of conforming types: +/// - extract IPA contents +/// - parse Info.plist +/// - inspect binary architectures +/// - validate signatures +/// - parse entitlements +/// - scan frameworks +/// +/// Example implementations: +/// - IPAInspectionService +/// - MockIPAInspectionService +protocol IPAInspectionServicing { + + /// Runs the complete IPA inspection pipeline. + /// + /// Expected flow: + /// 1. Extract IPA + /// 2. Locate .app bundle + /// 3. Parse metadata + /// 4. Analyze binary + /// 5. Inspect frameworks + /// 6. Build IPAInspection model + /// + /// - Parameter url: + /// Local IPA file URL selected by the user. + /// + /// - Returns: + /// Fully constructed inspection result. + /// + /// - Throws: + /// Inspection-related errors such as: + /// - invalid IPA + /// - extraction failure + /// - missing app bundle + /// - parsing failures + func inspectIPA( + at url: URL + ) async throws -> IPAInspection +} From 08a29084a7c4562b72d9abed5ecc29e28ccca2ef Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Thu, 9 Jul 2026 10:15:48 +0530 Subject: [PATCH 02/12] feat(security inspection): add IPA security checks and complete framework discovery --- .../Components/Common/ScreenShell.swift | 33 +++ .../Common/WatercolorBackground.swift | 20 ++ .../Components/HomeFileSection.swift | 20 +- .../Models/IPAInspection/ParsedIPAInfo.swift | 2 + .../Model/IPAInspectorState.swift | 12 + .../IPAInspector/View/IPAInspectorView.swift | 262 ++++++++---------- .../View/sections/IPAFrameworksView.swift | 1 + .../View/sections/IPAOverviewView.swift | 37 +++ .../IPAInspectorDetailViewModel.swift | 30 +- .../Features/Resign/View/HomeView.swift | 18 +- .../IPAInspection/IPAInspectionService.swift | 229 +++++++++++++-- 11 files changed, 466 insertions(+), 198 deletions(-) create mode 100644 IPASignCraft/DesignSystem/Components/Common/ScreenShell.swift create mode 100644 IPASignCraft/DesignSystem/Components/Common/WatercolorBackground.swift diff --git a/IPASignCraft/DesignSystem/Components/Common/ScreenShell.swift b/IPASignCraft/DesignSystem/Components/Common/ScreenShell.swift new file mode 100644 index 0000000..9f22666 --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/Common/ScreenShell.swift @@ -0,0 +1,33 @@ +import SwiftUI + +/// A small screen shell that provides a background and scrollable centered content area. +/// Use this to standardize page layout across screens. +struct ScreenShell: View { + + private let background: AnyView + private let content: () -> Content + + init(background: B = WatercolorBackground(), @ViewBuilder content: @escaping () -> Content) { + self.background = AnyView(background) + self.content = content + } + + var body: some View { + ZStack { + background + + ScrollView { + content() + } + } + } +} + +#Preview { + ScreenShell { + VStack { + Text("Screen Shell") + } + .frame(maxWidth: 800) + } +} diff --git a/IPASignCraft/DesignSystem/Components/Common/WatercolorBackground.swift b/IPASignCraft/DesignSystem/Components/Common/WatercolorBackground.swift new file mode 100644 index 0000000..55604ae --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/Common/WatercolorBackground.swift @@ -0,0 +1,20 @@ +import SwiftUI + +/// Reusable watercolor background used across multiple screens. +struct WatercolorBackground: View { + + var body: some View { + GeometryReader { geo in + Image("homeBgWatercolor") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: geo.size.width, height: geo.size.height) + .clipped() + .overlay(Color.white.opacity(0.6)) + } + } +} + +#Preview { + WatercolorBackground() +} diff --git a/IPASignCraft/DesignSystem/Components/HomeFileSection.swift b/IPASignCraft/DesignSystem/Components/HomeFileSection.swift index 64355e5..afb2283 100644 --- a/IPASignCraft/DesignSystem/Components/HomeFileSection.swift +++ b/IPASignCraft/DesignSystem/Components/HomeFileSection.swift @@ -9,6 +9,10 @@ struct HomeFileSection: View { @Binding var filePath: String let supportedTypes: [UTType] var onSelect: ((String) -> Void)? = nil + /// Optional subtitle to show when a file is selected. Parent views can pass + /// a context-appropriate label such as "Ready For Signing" or + /// "Ready For Inspection". If nil, suffix-based fallback is used. + var selectedSubtitle: String? = nil var body: some View { HomeSectionView(title) { @@ -32,11 +36,21 @@ struct HomeFileSection: View { } if !filePath.isEmpty { + // If parent provided a subtitle, use it; otherwise fallback + // to a simple suffix-based heuristic. + let subtitleText = selectedSubtitle ?? { + let lower = filePath.lowercased() + if lower.hasSuffix(".ipa") { + return "Ready For Signing" + } + return "Ready for inspection" + }() + InfoRow( icon: "doc.fill", color: AppColors.accent, title: (filePath as NSString).lastPathComponent, - subtitle: title == "IPA File" ? "Ready for signing" : "Ready for inspection" + subtitle: subtitleText ) } } @@ -46,10 +60,10 @@ struct HomeFileSection: View { #Preview { VStack(spacing: Spacing.base) { - HomeFileSection(title: "IPA File", helper: "Select or drop the IPA you want to re-sign", filePath: .constant("/path/to/app.ipa"), supportedTypes: [.ipa]) + HomeFileSection(title: "IPA File", helper: "Select or drop the IPA you want to re-sign", filePath: .constant("/path/to/app.ipa"), supportedTypes: [.ipa], selectedSubtitle: "Ready For Signing") .padding() - HomeFileSection(title: "IPA File", helper: "Select or drop the IPA you want to inspect", filePath: .constant(""), supportedTypes: [.ipa]) + HomeFileSection(title: "IPA File", helper: "Select or drop the IPA you want to inspect", filePath: .constant(""), supportedTypes: [.ipa], selectedSubtitle: "Ready For Inspection") .padding() } } \ No newline at end of file diff --git a/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift b/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift index 1dbb5d8..f1ffe9f 100644 --- a/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift +++ b/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift @@ -19,4 +19,6 @@ struct ParsedIPAInfo { let version: String let buildNumber: String + + let executableName: String? } \ No newline at end of file diff --git a/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift index ed14073..56a1f9e 100644 --- a/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift +++ b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift @@ -19,6 +19,18 @@ final class IPAInspectorState { /// Currently selected IPA file URL. var selectedIPAURL: URL? + /// Lightweight summary of the selected file populated immediately + /// when a file is chosen. Used to render a placeholder while the + /// full inspection runs in the background. + struct SelectedFileSummary { + let fileName: String + let humanSize: String + let modifiedDate: String? + } + + /// Short summary shown immediately after selection. + var selectedFileSummary: SelectedFileSummary? + // MARK: - Inspection State /// Currently loaded inspection result. diff --git a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift index 19cb8b8..3f0c091 100644 --- a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift +++ b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift @@ -22,90 +22,88 @@ struct IPAInspectorView: View { var body: some View { - ZStack { + ScreenShell { + HStack(alignment: .top, spacing: Spacing.lg) { - // Background - inspectorBackground + // Left column: main dashboard + VStack(alignment: .center, spacing: Spacing.lg) { - // Main content with left dashboard and right console/status - ScrollView { - HStack(alignment: .top, spacing: Spacing.lg) { + headerSection - // Left column: main dashboard - VStack(alignment: .center, spacing: Spacing.lg) { + VStack(alignment: .leading, spacing: Spacing.lg) { - headerSection + // File upload section + fileUploadSection - VStack(alignment: .leading, spacing: Spacing.lg) { + // Immediate placeholder summary while background inspection runs. + if viewModel.state.inspection == nil, let summary = viewModel.state.selectedFileSummary { + VStack(alignment: .leading, spacing: Spacing.sm) { + HStack(alignment: .center, spacing: Spacing.base) { + InfoRow( + icon: "doc.fill", + color: AppColors.accent, + title: summary.fileName, + subtitle: "\(summary.humanSize)\(summary.modifiedDate != nil ? " • \(summary.modifiedDate!)" : "")" + ) - // File upload section - fileUploadSection + Spacer() - // Selected file info - if let ipaPath = viewModel.state.selectedIPAURL?.path { - selectedFileSection(fileName: (ipaPath as NSString).lastPathComponent) + // Small inline activity indicator + if viewModel.state.isLoading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + .scaleEffect(0.8) + } + } + .padding(Spacing.base) + .background(RoundedRectangle(cornerRadius: Radius.sm).fill(AppColors.cardSurface)) + .shadow(color: AppColors.cardShadow, radius: 4, x: 0, y: 1) } + } - // Inspection results - if let inspection = viewModel.state.inspection { + // Inspection results + if let inspection = viewModel.state.inspection { - Divider() + Divider() - // Quick stat cards - statCardsSection(inspection: inspection) + // Quick stat cards + statCardsSection(inspection: inspection) - // Expandable sections - inspectionDetailsSection(inspection: inspection) + // Expandable sections + inspectionDetailsSection(inspection: inspection) - // Completion status (main) - completionStatusSection - } } - .frame(maxWidth: 800, alignment: .leading) - .padding(.horizontal, Spacing.lg) } - .frame(maxWidth: 800) - - // Right column: console and compact status - VStack(alignment: .leading, spacing: Spacing.base) { - - // Console / Logs placeholder - VStack(alignment: .leading, spacing: Spacing.sm) { - Text("Console") - .font(AppFont.heading3) - .fontWeight(.semibold) - - Text("Live logs and inspection output") - .font(AppFont.secondary) - .foregroundColor(AppColors.secondaryText) - - // Simple scrollable console area - ScrollView { - VStack(alignment: .leading, spacing: Spacing.xs) { - ForEach(viewModel.state.log.split(separator: "\n").suffix(50), id: \.self) { line in - Text(String(line)) - .font(AppFont.small) - .foregroundColor(AppColors.secondaryText) - .frame(maxWidth: .infinity, alignment: .leading) - } - } - .padding(Spacing.base) - } - .frame(minHeight: 220, maxHeight: 320) - .background(RoundedRectangle(cornerRadius: Radius.sm).fill(AppColors.cardSurface)) - .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + .frame(maxWidth: 800, alignment: .leading) + .padding(.horizontal, Spacing.lg) + } + .frame(maxWidth: 800) + + // Right column: console and compact status + VStack(alignment: .leading, spacing: Spacing.base) { + + // Compact completion/status box (shows waiting/processing/complete) + compactCompletionStatusSection + + // Reusable console component (shared with HomeView) + ConsoleView( + title: "Console", + icon: "terminal", + logContent: Binding( + get: { viewModel.state.log }, + set: { viewModel.state.log = $0 } + ), + onClear: { + viewModel.clearLogs() } + ) - // Compact completion/status box - compactCompletionStatusSection - - Spacer() - } - .frame(width: 340) + Spacer() } - .padding(.vertical, Spacing.lg) - .padding(.horizontal, Spacing.lg) + .frame(width: 340) } + .padding(.vertical, Spacing.lg) + .padding(.horizontal, Spacing.lg) } .toolbar { ToolbarItemGroup { @@ -119,12 +117,7 @@ struct IPAInspectorView: View { private extension IPAInspectorView { var inspectorBackground: some View { - - GeometryReader { geo in - - Color(nsColor: .windowBackgroundColor) - .frame(width: geo.size.width, height: geo.size.height) - } + WatercolorBackground() } } @@ -190,50 +183,9 @@ private extension IPAInspectorView { } ), supportedTypes: [.ipa], - onSelect: { _ in } - ) - } -} - -// MARK: - Selected File Section -private extension IPAInspectorView { - - func selectedFileSection(fileName: String) -> some View { - - HStack(spacing: Spacing.base) { - - ZStack { - RoundedRectangle(cornerRadius: Spacing.xs) - .fill(AppColors.accent.opacity(0.1)) - - Image(systemName: "doc.fill") - .font(.system(size: 16)) - .foregroundColor(AppColors.accent) - } - .frame(width: 32, height: 32) - - VStack(alignment: .leading, spacing: 2) { - Text(fileName) - .font(AppFont.body) - .fontWeight(.semibold) - - Text("Ready for inspection") - .font(AppFont.caption) - .foregroundColor(AppColors.secondaryText) - } - - Spacer() - - Image(systemName: "checkmark.circle.fill") - .font(.system(size: 16)) - .foregroundColor(AppColors.success) - } - .padding(Spacing.base) - .background( - RoundedRectangle(cornerRadius: Radius.sm) - .fill(AppColors.cardSurface) + onSelect: { _ in }, + selectedSubtitle: "Ready For Inspection" ) - .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) } } @@ -393,54 +345,64 @@ private extension IPAInspectorView { // MARK: - Completion Status private extension IPAInspectorView { + /// Compact variant of completion status suitable for sidebar. + var compactCompletionStatusSection: some View { - var completionStatusSection: some View { - - HStack(spacing: Spacing.sm) { + // Derive status pieces from view model state + let (iconName, iconColor, titleText, subtitleText): (String, Color, String, String) = { + if let error = viewModel.state.errorMessage { + return ("xmark.octagon.fill", .red, "Inspection failed", error) + } - Image(systemName: "info.circle.fill") - .font(.system(size: 14)) - .foregroundColor(.blue) + if viewModel.state.selectedIPAURL == nil { + return ("tray", .gray, "Waiting for IPA", "Select or drop an IPA") + } - Text("Inspection completed successfully") - .font(AppFont.secondary) + if viewModel.state.isLoading { + return ("arrow.triangle.2.circlepath", AppColors.accentHover, "Processing IPA", "Scanning...") + } - Spacer() + if viewModel.state.inspection != nil { + let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) + return ("checkmark.circle.fill", .green, "Inspection completed", ts) + } - Text("Today, 10:42 AM") - .font(AppFont.caption) - .foregroundColor(AppColors.secondaryText) + return ("info.circle.fill", .blue, "Ready", "") + }() - Image(systemName: "clock") - .font(.system(size: 12)) - .foregroundColor(AppColors.secondaryText) - } - .padding(Spacing.base) - .background( - RoundedRectangle(cornerRadius: Radius.sm) - .fill(AppColors.cardSurface) - ) - .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) - } + return HStack(spacing: Spacing.sm) { - /// Compact variant of completion status suitable for sidebar. - var compactCompletionStatusSection: some View { + // Icon badge + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(iconColor.opacity(0.15)) + .frame(width: 44, height: 44) - HStack(spacing: Spacing.xs) { - Image(systemName: "info.circle.fill") - .font(.system(size: 12)) - .foregroundColor(.blue) + Image(systemName: iconName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(iconColor) + } - VStack(alignment: .leading, spacing: 2) { - Text("Inspection completed") + // Textual info + optional progress + VStack(alignment: .leading, spacing: 4) { + Text(titleText) .font(AppFont.small) .fontWeight(.semibold) - Text("Today, 10:42 AM") - .font(AppFont.small) - .foregroundColor(AppColors.secondaryText) - } + if !subtitleText.isEmpty { + Text(subtitleText) + .font(AppFont.caption) + .foregroundColor(AppColors.secondaryText) + } + if viewModel.state.isLoading { + ProgressView() + .progressViewStyle(LinearProgressViewStyle(tint: AppColors.accent)) + .frame(height: 6) + .cornerRadius(3) + .padding(.top, 6) + } + } Spacer() } .padding(Spacing.xs) @@ -448,7 +410,7 @@ private extension IPAInspectorView { RoundedRectangle(cornerRadius: Radius.sm) .fill(AppColors.cardSurface) ) - .shadow(color: AppColors.cardShadow, radius: 4, x: 0, y: 1) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) } } diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift index a9d5956..1832e62 100644 --- a/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift @@ -122,6 +122,7 @@ private extension IPAFrameworksView { Text(framework.size) } } + .frame(minHeight: 220, maxHeight: 420) } } } diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift index b6ec9d3..5f91d5c 100644 --- a/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift @@ -45,6 +45,8 @@ struct IPAOverviewView: View { summaryCardsSection capabilitiesSection + + frameworkNamesSection } } } @@ -152,6 +154,41 @@ private extension IPAOverviewView { } } +// MARK: - Framework Names + +private extension IPAOverviewView { + + /// Displays discovered framework names. + var frameworkNamesSection: some View { + + VStack( + alignment: .leading, + spacing: 12 + ) { + + Text("Framework Names") + .font(.headline) + + if inspection.frameworks.isEmpty { + Text("No embedded frameworks detected.") + .foregroundStyle(.secondary) + } else { + ForEach( + inspection.frameworks + .sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }, + id: \.id + ) { framework in + + Label( + framework.name, + systemImage: "shippingbox" + ) + } + } + } + } +} + // MARK: - Preview #Preview { diff --git a/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift index b1460d8..16985f9 100644 --- a/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift +++ b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift @@ -41,9 +41,34 @@ final class IPAInspectorDetailViewModel: ObservableObject { /// Opens and inspects an IPA file. func inspectIPA(at url: URL) { - + // Set selection immediately so the UI can react. state.selectedIPAURL = url - + + // Populate a lightweight summary from file attributes so the + // view can show an immediate placeholder while inspection runs. + do { + let attrs = try FileManager.default.attributesOfItem(atPath: url.path) + let size = (attrs[.size] as? NSNumber)?.int64Value ?? 0 + let date = attrs[.modificationDate] as? Date + + let byteFormatter = ByteCountFormatter() + byteFormatter.allowedUnits = [.useMB, .useKB, .useGB] + byteFormatter.countStyle = .file + let humanSize = byteFormatter.string(fromByteCount: size) + + let dateStr: String? + if let d = date { + dateStr = DateFormatter.localizedString(from: d, dateStyle: .short, timeStyle: .short) + } else { + dateStr = nil + } + + state.selectedFileSummary = .init(fileName: url.lastPathComponent, humanSize: humanSize, modifiedDate: dateStr) + } catch { + // Ignore attribute failures — summary is optional. + state.selectedFileSummary = .init(fileName: url.lastPathComponent, humanSize: "-", modifiedDate: nil) + } + Task { await runInspection(for: url) @@ -57,6 +82,7 @@ final class IPAInspectorDetailViewModel: ObservableObject { state.errorMessage = nil state.selectedSection = .overview state.selectedIPAURL = nil + state.selectedFileSummary = nil state.log = "" } diff --git a/IPASignCraft/Features/Resign/View/HomeView.swift b/IPASignCraft/Features/Resign/View/HomeView.swift index 18598c7..29a0582 100644 --- a/IPASignCraft/Features/Resign/View/HomeView.swift +++ b/IPASignCraft/Features/Resign/View/HomeView.swift @@ -14,11 +14,7 @@ struct HomeView: View { @StateObject private var viewModel = HomeViewModel() var body: some View { - ZStack { - /// Background layer (visual only, no interaction) - homeBackground - - /// Main two-column layout + ScreenShell { HStack(alignment: .top, spacing: Spacing.xxl) { leftSection.frame(maxWidth: 720) // Input + configuration rightSection // Status + logs @@ -34,14 +30,7 @@ struct HomeView: View { private extension HomeView { /// Watercolor background with soft overlay for readability var homeBackground: some View { - GeometryReader { geo in - Image("homeBgWatercolor") - .resizable() - .aspectRatio(contentMode: .fill) - .frame(width: geo.size.width, height: geo.size.height) - .clipped() - .overlay(Color.white.opacity(0.6)) - } + WatercolorBackground() } } @@ -105,7 +94,8 @@ private extension HomeView { set: { viewModel.updateIPAPath($0) } ), supportedTypes: [.ipa], - onSelect: { _ in } + onSelect: { _ in }, + selectedSubtitle: "Ready For Signing" ) } } diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift index 4f490f2..d20d642 100644 --- a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -28,6 +28,13 @@ import Foundation /// - helper services final class IPAInspectionService: IPAInspectionServicing { + private struct SecurityInspectionReport { + let hasValidSignature: Bool + let teamIdentifier: String + let architectures: [String] + let entitlements: [String] + } + // MARK: - Public API /// Runs the complete IPA inspection pipeline. @@ -58,6 +65,11 @@ final class IPAInspectionService: IPAInspectionServicing { inside: appBundleURL ) + let securityReport = inspectSecurity( + for: appBundleURL, + executableName: info.executableName + ) + // Build inspection model return IPAInspection( @@ -69,16 +81,13 @@ final class IPAInspectionService: IPAInspectionServicing { buildNumber: info.buildNumber, - teamIdentifier: "UNKNOWN", + teamIdentifier: securityReport.teamIdentifier, - architectures: ["arm64"], + architectures: securityReport.architectures, - hasValidSignature: true, + hasValidSignature: securityReport.hasValidSignature, - entitlements: [ - "Push Notifications", - "Keychain Sharing" - ], + entitlements: securityReport.entitlements, frameworks: frameworks ) @@ -218,11 +227,163 @@ private extension IPAInspectionService { buildNumber: dictionary["CFBundleVersion"] as? String - ?? "0" + ?? "0", + + executableName: + dictionary["CFBundleExecutable"] + as? String ) } } +// MARK: - Security Inspection + +private extension IPAInspectionService { + + private func inspectSecurity( + for appBundleURL: URL, + executableName: String? + ) -> SecurityInspectionReport { + let appExecutableURL = appBundleURL.appendingPathComponent(executableName ?? "") + let embeddedProfileURL = appBundleURL.appendingPathComponent("embedded.mobileprovision") + + let hasValidSignature = isSignedItem(at: appBundleURL) || isSignedItem(at: appExecutableURL) + let teamIdentifier = extractTeamIdentifier(from: appExecutableURL, profileURL: embeddedProfileURL) + let architectures = extractArchitectures(from: appExecutableURL) + let entitlements = extractEntitlements(from: appExecutableURL, profileURL: embeddedProfileURL) + + return SecurityInspectionReport( + hasValidSignature: hasValidSignature, + teamIdentifier: teamIdentifier, + architectures: architectures, + entitlements: entitlements + ) + } + + private func isSignedItem(at url: URL) -> Bool { + guard FileManager.default.fileExists(atPath: url.path) else { return false } + + let result = try? ShellExecutor.runWithOutput("/usr/bin/codesign --verify --deep --strict \"\(url.path)\"") + return result?.status == 0 + } + + private func extractTeamIdentifier(from executableURL: URL, profileURL: URL) -> String { + let profileResult = try? ShellExecutor.runWithOutput("/usr/bin/security cms -D -i \"\(profileURL.path)\"") + guard let profileOutput = profileResult?.output.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: profileOutput, options: [], format: nil) as? [String: Any], + let entitlements = plist["Entitlements"] as? [String: Any], + let appIdentifier = entitlements["application-identifier"] as? String else { + return "UNKNOWN" + } + + let parts = appIdentifier.split(separator: ".") + if parts.count >= 2 { + return String(parts[0]) + } + + return "UNKNOWN" + } + + private func extractArchitectures(from executableURL: URL) -> [String] { + guard FileManager.default.fileExists(atPath: executableURL.path) else { return [] } + + let result = try? ShellExecutor.runWithOutput("/usr/bin/lipo -info \"\(executableURL.path)\"") + guard let output = result?.output else { return [] } + + let line = output.split(separator: "\n").first.map(String.init) ?? output + let architectures = line.components(separatedBy: CharacterSet(charactersIn: ":, ")) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + + return architectures.filter { $0 != "architecture" } + } + + private func extractEntitlements(from executableURL: URL, profileURL: URL) -> [String] { + let appEntitlementsResult = try? ShellExecutor.runWithOutput("/usr/bin/codesign -d --entitlements :- \"\(executableURL.path)\"") + let profileResult = try? ShellExecutor.runWithOutput("/usr/bin/security cms -D -i \"\(profileURL.path)\"") + + var extracted: [String] = [] + + if let output = appEntitlementsResult?.output, + let plist = try? PropertyListSerialization.propertyList(from: Data(output.utf8), options: [], format: nil) as? [String: Any] { + extracted = plist.keys.sorted().map { key in + if key == "com.apple.security.application-groups" { + return "App Groups" + } + if key == "keychain-access-groups" { + return "Keychain Sharing" + } + if key == "aps-environment" { + return "Push Notifications" + } + return key + } + } + + if let profileOutput = profileResult?.output.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: profileOutput, options: [], format: nil) as? [String: Any], + let entitlements = plist["Entitlements"] as? [String: Any] { + let profileKeys = entitlements.keys.sorted().map { key in + switch key { + case "com.apple.security.application-groups": return "App Groups" + case "keychain-access-groups": return "Keychain Sharing" + case "aps-environment": return "Push Notifications" + default: return key + } + } + extracted = Array(Set(extracted + profileKeys)).sorted() + } + + return extracted + } + + private func humanReadableSize(for url: URL) -> String { + let size = byteSize(for: url) + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useMB, .useKB, .useGB] + formatter.countStyle = .file + return formatter.string(fromByteCount: size) + } + + private func byteSize(for url: URL) -> Int64 { + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: url.path, isDirectory: &isDirectory) else { + return 0 + } + + if !isDirectory.boolValue { + let attrs = try? FileManager.default.attributesOfItem(atPath: url.path) + return (attrs?[.size] as? NSNumber)?.int64Value ?? 0 + } + + guard let enumerator = FileManager.default.enumerator( + at: url, + includingPropertiesForKeys: [.isRegularFileKey, .fileAllocatedSizeKey, .totalFileAllocatedSizeKey], + options: [.skipsHiddenFiles] + ) else { + return 0 + } + + var total: Int64 = 0 + + for case let fileURL as URL in enumerator { + guard let values = try? fileURL.resourceValues(forKeys: [.isRegularFileKey, .fileAllocatedSizeKey, .totalFileAllocatedSizeKey]), + values.isRegularFile == true else { + continue + } + + if let allocated = values.totalFileAllocatedSize ?? values.fileAllocatedSize { + total += Int64(allocated) + } else { + let attrs = try? FileManager.default.attributesOfItem(atPath: fileURL.path) + total += (attrs?[.size] as? NSNumber)?.int64Value ?? 0 + } + } + + return total + } +} + // MARK: - Framework Scanning private extension IPAInspectionService { @@ -233,34 +394,44 @@ private extension IPAInspectionService { inside appBundleURL: URL ) throws -> [FrameworkInfo] { - let frameworksURL = - appBundleURL.appendingPathComponent( - "Frameworks" - ) - - guard FileManager.default.fileExists( - atPath: frameworksURL.path + guard let enumerator = FileManager.default.enumerator( + at: appBundleURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] ) else { - return [] } - let contents = try FileManager.default.contentsOfDirectory( - at: frameworksURL, - includingPropertiesForKeys: nil - ) + var discovered: [URL] = [] - return contents - .filter { - $0.pathExtension == "framework" + for case let item as URL in enumerator { + let ext = item.pathExtension.lowercased() + + if ext == "framework" { + discovered.append(item) + continue } - .map { frameworkURL in - FrameworkInfo( - name: frameworkURL.lastPathComponent, - isSigned: true, - size: "Unknown" - ) + if ext == "dylib" { + discovered.append(item) } + } + + let deduplicated = Dictionary( + grouping: discovered, + by: { $0.standardizedFileURL.path } + ) + .compactMap { $0.value.first } + .sorted { + $0.path.localizedCaseInsensitiveCompare($1.path) == .orderedAscending + } + + return deduplicated.map { itemURL in + return FrameworkInfo( + name: itemURL.lastPathComponent, + isSigned: isSignedItem(at: itemURL), + size: humanReadableSize(for: itemURL) + ) + } } } \ No newline at end of file From e9e9ad1c4521e6f5ba7b1eeda52d5f4c90801d81 Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Thu, 16 Jul 2026 17:29:12 +0530 Subject: [PATCH 03/12] Issue(UI): Two activity indicator were visible. --- IPASignCraft.xcodeproj/project.pbxproj | 4 +-- .../IPAInspector/View/IPAInspectorView.swift | 28 +------------------ .../IPAInspection/IPAInspectionService.swift | 12 ++++++-- 3 files changed, 12 insertions(+), 32 deletions(-) diff --git a/IPASignCraft.xcodeproj/project.pbxproj b/IPASignCraft.xcodeproj/project.pbxproj index 91f76d5..6d7a3de 100644 --- a/IPASignCraft.xcodeproj/project.pbxproj +++ b/IPASignCraft.xcodeproj/project.pbxproj @@ -285,7 +285,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.sn.IPASignCraft; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; @@ -331,7 +331,7 @@ "$(inherited)", "@executable_path/../Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = com.sn.IPASignCraft; PRODUCT_NAME = "$(TARGET_NAME)"; REGISTER_APP_GROUPS = YES; diff --git a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift index 3f0c091..e9bdbe4 100644 --- a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift +++ b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift @@ -34,33 +34,7 @@ struct IPAInspectorView: View { // File upload section fileUploadSection - - // Immediate placeholder summary while background inspection runs. - if viewModel.state.inspection == nil, let summary = viewModel.state.selectedFileSummary { - VStack(alignment: .leading, spacing: Spacing.sm) { - HStack(alignment: .center, spacing: Spacing.base) { - InfoRow( - icon: "doc.fill", - color: AppColors.accent, - title: summary.fileName, - subtitle: "\(summary.humanSize)\(summary.modifiedDate != nil ? " • \(summary.modifiedDate!)" : "")" - ) - - Spacer() - - // Small inline activity indicator - if viewModel.state.isLoading { - ProgressView() - .progressViewStyle(CircularProgressViewStyle()) - .scaleEffect(0.8) - } - } - .padding(Spacing.base) - .background(RoundedRectangle(cornerRadius: Radius.sm).fill(AppColors.cardSurface)) - .shadow(color: AppColors.cardShadow, radius: 4, x: 0, y: 1) - } - } - + // Inspection results if let inspection = viewModel.state.inspection { diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift index d20d642..89eaac8 100644 --- a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -290,12 +290,18 @@ private extension IPAInspectionService { let result = try? ShellExecutor.runWithOutput("/usr/bin/lipo -info \"\(executableURL.path)\"") guard let output = result?.output else { return [] } + // lipo -info output formats: + // Fat: "Architectures in the fat file: are: armv7 arm64" + // Thin: "Non-fat file: is architecture: arm64" + // In both cases the architecture names appear after the last colon. let line = output.split(separator: "\n").first.map(String.init) ?? output - let architectures = line.components(separatedBy: CharacterSet(charactersIn: ":, ")) + guard let lastColon = line.lastIndex(of: ":") else { return [] } + let archString = String(line[line.index(after: lastColon)...]) + + return archString + .components(separatedBy: .whitespaces) .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } - - return architectures.filter { $0 != "architecture" } } private func extractEntitlements(from executableURL: URL, profileURL: URL) -> [String] { From efa4ee2925930e390b15fb1b899504f1f852afbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:14:08 +0000 Subject: [PATCH 04/12] fix: add missing import Observation to IPAInspectorState.swift --- IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift index 56a1f9e..6acabbd 100644 --- a/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift +++ b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift @@ -6,6 +6,7 @@ // import Foundation +import Observation /// Encapsulates all state for the IPA Inspector feature. /// From 774544c15045f57f7701e09cf147f5f063e242cf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:28:00 +0000 Subject: [PATCH 05/12] Fix field container shadow --- IPASignCraft/DesignSystem/Extensions/View+Field.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/IPASignCraft/DesignSystem/Extensions/View+Field.swift b/IPASignCraft/DesignSystem/Extensions/View+Field.swift index 7bc0116..d794272 100644 --- a/IPASignCraft/DesignSystem/Extensions/View+Field.swift +++ b/IPASignCraft/DesignSystem/Extensions/View+Field.swift @@ -16,12 +16,11 @@ extension View { .background( RoundedRectangle(cornerRadius: Radius.sm) .fill(AppColors.cardSurface) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) ) .overlay( RoundedRectangle(cornerRadius: Radius.sm) .stroke(AppColors.border, lineWidth: 1) ) - .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) - .cornerRadius(Radius.sm) } } From 4fe7ec6e5e1bdb41c85fb5d6798cfc3c89eb3e2b Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Thu, 16 Jul 2026 17:59:29 +0530 Subject: [PATCH 06/12] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- IPASignCraft/Service/IPAInspection/IPAInspectionService.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift index 89eaac8..c6ba0c3 100644 --- a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -49,6 +49,7 @@ final class IPAInspectionService: IPAInspectionServicing { let extractedURL = try extractIPA( at: url ) + defer { try? FileManager.default.removeItem(at: extractedURL) } // Locate .app bundle let appBundleURL = try locateAppBundle( From da5dff79bbea6dc1f45af2c6c7f1fffc54ecf1e8 Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Thu, 16 Jul 2026 17:59:56 +0530 Subject: [PATCH 07/12] Security View Fix Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../View/sections/IPASecurityView.swift | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift index f02a11e..d7bed39 100644 --- a/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift @@ -103,22 +103,29 @@ private extension IPASecurityView { title: "Security Status" ) + let hasIssues = + !inspection.hasValidSignature || + inspection.architectures.isEmpty || + inspection.frameworks.contains { !$0.isSigned } + HStack(spacing: 14) { - Image(systemName: "shield.checkered") + Image(systemName: hasIssues ? "shield.slash" : "shield.checkered") .font(.largeTitle) - .foregroundStyle(.green) + .foregroundStyle(hasIssues ? .red : .green) VStack( alignment: .leading, spacing: 4 ) { - Text("No Major Issues Detected") + Text(hasIssues ? "Issues Detected" : "No Major Issues Detected") .font(.headline) Text( - "The IPA passed the currently available security checks." + hasIssues + ? "One or more security checks failed. Review the validation section below." + : "The IPA passed the currently available security checks." ) .foregroundStyle(.secondary) } From 2e333791490b0ebeb8f42017afb058f27604b6de Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Thu, 16 Jul 2026 18:00:44 +0530 Subject: [PATCH 08/12] CR Comment: Reflect the security check Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Features/IPAInspector/View/IPAInspectorView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift index e9bdbe4..1fee946 100644 --- a/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift +++ b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift @@ -308,8 +308,8 @@ private extension IPAInspectorView { iconColor: .red, title: "Security", subtitle: "Security checks, warnings and recommendations.", - badge: "No issues", - badgeColor: .green + badge: (inspection.hasValidSignature && !inspection.architectures.isEmpty && inspection.frameworks.allSatisfy({ $0.isSigned })) ? "OK" : "Issues", + badgeColor: (inspection.hasValidSignature && !inspection.architectures.isEmpty && inspection.frameworks.allSatisfy({ $0.isSigned })) ? .green : .red ) { IPASecurityView(inspection: inspection) } From 6d8b7e5d37f81ed70c055dd930a05e3669d5f355 Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Thu, 16 Jul 2026 18:03:38 +0530 Subject: [PATCH 09/12] CR 16: Fix the description. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- IPASignCraft/DesignSystem/Components/FileDropView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/IPASignCraft/DesignSystem/Components/FileDropView.swift b/IPASignCraft/DesignSystem/Components/FileDropView.swift index b3dec98..8076eda 100644 --- a/IPASignCraft/DesignSystem/Components/FileDropView.swift +++ b/IPASignCraft/DesignSystem/Components/FileDropView.swift @@ -70,7 +70,7 @@ struct FileDropView: View { .accessibilityElement(children: .combine) .accessibilityAddTraits(.isButton) .accessibilityLabel(filePath.isEmpty ? "File drop target" : "File loaded") - .accessibilityHint("Press Space or Enter when focused to open file picker. You can also drop an IPA file here.") + .accessibilityHint("Click to browse, or drop an IPA file here.") .onDrop(of: ["public.file-url"], isTargeted: $isHovering) { providers in providers.first?.loadItem(forTypeIdentifier: "public.file-url", From 28b0cb94776112f0ce20fa67de4af4068262df1c Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Fri, 17 Jul 2026 10:18:25 +0530 Subject: [PATCH 10/12] CR Comment: UX tap two times may cause double processing Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../ViewModel/IPAInspectorDetailViewModel.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift index 16985f9..a6e8040 100644 --- a/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift +++ b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift @@ -69,8 +69,12 @@ final class IPAInspectorDetailViewModel: ObservableObject { state.selectedFileSummary = .init(fileName: url.lastPathComponent, humanSize: "-", modifiedDate: nil) } + guard !state.isLoading else { + addLog("Inspection already in progress.") + return + } + Task { - await runInspection(for: url) } } From 583446774ac98d2f5abae9045d6534527c7b9cfd Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Fri, 17 Jul 2026 10:19:53 +0530 Subject: [PATCH 11/12] CR Comment: Address error handling. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Service/IPAInspection/IPAInspectionService.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift index c6ba0c3..d0b6e5c 100644 --- a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -139,8 +139,11 @@ private extension IPAInspectionService { ) // Extract IPA using unzip command - try IPAExtractorService.unzip(url, to: temporaryDirectory) - + do { + try IPAExtractorService.unzip(url, to: temporaryDirectory) + } catch { + throw IPAInspectionError.extractionFailed + } return temporaryDirectory } } From fda285ee4db127170367113aa88861e43be7b35e Mon Sep 17 00:00:00 2001 From: Saurav Nagpal Date: Fri, 17 Jul 2026 10:50:26 +0530 Subject: [PATCH 12/12] Issue(CR Comments): Fix CR comments for inspection view. --- .../Components/Cards/AppCard.swift | 2 +- .../DesignSystem/Extensions/View+Field.swift | 2 +- .../Models/IPAInspection/IPAInspection.swift | 11 ++++- .../View/sections/IPASigningView.swift | 41 ++++++++++++++++++- .../IPAInspection/IPAInspectionService.swift | 34 ++++++++++++++- 5 files changed, 84 insertions(+), 6 deletions(-) diff --git a/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift b/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift index e60fbc4..6d4789b 100644 --- a/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift +++ b/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift @@ -26,8 +26,8 @@ struct AppCard: View { .overlay( RoundedRectangle(cornerRadius: Radius.xl) .stroke(AppColors.border, lineWidth: 1) + .shadow(color: AppColors.cardShadow, radius: 8, x: 0, y: 4) ) - .shadow(color: AppColors.cardShadow, radius: 8, x: 0, y: 4) .clipShape( RoundedRectangle(cornerRadius: Radius.xl) ) diff --git a/IPASignCraft/DesignSystem/Extensions/View+Field.swift b/IPASignCraft/DesignSystem/Extensions/View+Field.swift index 7bc0116..0950641 100644 --- a/IPASignCraft/DesignSystem/Extensions/View+Field.swift +++ b/IPASignCraft/DesignSystem/Extensions/View+Field.swift @@ -20,8 +20,8 @@ extension View { .overlay( RoundedRectangle(cornerRadius: Radius.sm) .stroke(AppColors.border, lineWidth: 1) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) ) - .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) .cornerRadius(Radius.sm) } } diff --git a/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift b/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift index ee876d2..a822284 100644 --- a/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift +++ b/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift @@ -94,6 +94,12 @@ struct IPAInspection { /// Embedded frameworks discovered /// inside the IPA bundle. let frameworks: [FrameworkInfo] + + /// Application identifier extracted from embedded + /// provisioning profile, e.g. "ABCDE12345.com.company.app" or "ABCDE12345.com.company.*" + let provisioningAppIdentifier: String? + /// Expiration date from embedded provisioning profile + let provisioningExpiration: Date? } // MARK: - Mock Data @@ -144,5 +150,8 @@ extension IPAInspection { size: "3 MB" ) ] - ) + , + provisioningAppIdentifier: "ABCDE12345.com.company.mbriefing", + provisioningExpiration: nil + ) } diff --git a/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift b/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift index 10dc72b..8e7adae 100644 --- a/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift @@ -167,17 +167,54 @@ private extension IPASigningView { validationRow( title: "Bundle Identifier Check", - passed: true + passed: bundleIdentifierMatchesProfile ) validationRow( title: "Provision Match", - passed: true + passed: provisionLooksValid ) } } } +private extension IPASigningView { + + /// Returns true when the provisioning profile's + /// application-identifier matches the app bundle id. + var bundleIdentifierMatchesProfile: Bool { + guard let prov = inspection.provisioningAppIdentifier else { return false } + + // provisioning application-identifier format: "TEAMID.com.company.app" or wildcard + let parts = prov.split(separator: ".", maxSplits: 1, omittingEmptySubsequences: false) + let identifierPart: String + if parts.count >= 2 { + identifierPart = String(parts[1]) + } else { + identifierPart = prov + } + + if identifierPart.contains("*") { + // wildcard like com.company.* -> match prefix + let prefix = identifierPart.replacingOccurrences(of: "*", with: "") + return inspection.bundleIdentifier.hasPrefix(prefix) + } + + return inspection.bundleIdentifier == identifierPart + } + + /// Basic sanity check for provisioning profile presence. + var provisionLooksValid: Bool { + guard inspection.provisioningAppIdentifier != nil else { return false } + + if let expiration = inspection.provisioningExpiration { + return expiration > Date() + } + + return true + } +} + // MARK: - Shared Helpers private extension IPASigningView { diff --git a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift index 89eaac8..1da5b63 100644 --- a/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -33,6 +33,8 @@ final class IPAInspectionService: IPAInspectionServicing { let teamIdentifier: String let architectures: [String] let entitlements: [String] + let provisioningAppIdentifier: String? + let provisioningExpiration: Date? } // MARK: - Public API @@ -89,7 +91,9 @@ final class IPAInspectionService: IPAInspectionServicing { entitlements: securityReport.entitlements, - frameworks: frameworks + frameworks: frameworks, + provisioningAppIdentifier: securityReport.provisioningAppIdentifier, + provisioningExpiration: securityReport.provisioningExpiration ) } } @@ -251,15 +255,43 @@ private extension IPAInspectionService { let teamIdentifier = extractTeamIdentifier(from: appExecutableURL, profileURL: embeddedProfileURL) let architectures = extractArchitectures(from: appExecutableURL) let entitlements = extractEntitlements(from: appExecutableURL, profileURL: embeddedProfileURL) + let provisioningAppIdentifier = extractProvisioningApplicationIdentifier(from: embeddedProfileURL) + let provisioningExpiration = extractProvisioningExpiration(from: embeddedProfileURL) return SecurityInspectionReport( hasValidSignature: hasValidSignature, teamIdentifier: teamIdentifier, architectures: architectures, entitlements: entitlements + , + provisioningAppIdentifier: provisioningAppIdentifier, + provisioningExpiration: provisioningExpiration ) } + private func extractProvisioningApplicationIdentifier(from profileURL: URL) -> String? { + let profileResult = try? ShellExecutor.runWithOutput("/usr/bin/security cms -D -i \"\(profileURL.path)\"") + guard let profileOutput = profileResult?.output.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: profileOutput, options: [], format: nil) as? [String: Any], + let entitlements = plist["Entitlements"] as? [String: Any], + let appIdentifier = entitlements["application-identifier"] as? String else { + return nil + } + + return appIdentifier + } + + private func extractProvisioningExpiration(from profileURL: URL) -> Date? { + let profileResult = try? ShellExecutor.runWithOutput("/usr/bin/security cms -D -i \"\(profileURL.path)\"") + guard let profileOutput = profileResult?.output.data(using: .utf8), + let plist = try? PropertyListSerialization.propertyList(from: profileOutput, options: [], format: nil) as? [String: Any], + let expiration = plist["ExpirationDate"] as? Date else { + return nil + } + + return expiration + } + private func isSignedItem(at url: URL) -> Bool { guard FileManager.default.fileExists(atPath: url.path) else { return false }