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/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..6d4789b 100644 --- a/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift +++ b/IPASignCraft/DesignSystem/Components/Cards/AppCard.swift @@ -21,11 +21,12 @@ 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/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/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..8076eda 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("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", @@ -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..afb2283 --- /dev/null +++ b/IPASignCraft/DesignSystem/Components/HomeFileSection.swift @@ -0,0 +1,69 @@ +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 + /// 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) { + 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 { + // 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: subtitleText + ) + } + } + } + } +} + +#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], selectedSubtitle: "Ready For Signing") + .padding() + + 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/DesignSystem/Extensions/View+Field.swift b/IPASignCraft/DesignSystem/Extensions/View+Field.swift index 9e5761c..1f3cef2 100644 --- a/IPASignCraft/DesignSystem/Extensions/View+Field.swift +++ b/IPASignCraft/DesignSystem/Extensions/View+Field.swift @@ -13,10 +13,15 @@ extension View { func fieldContainer() -> some View { self .padding(Spacing.md) - .background(AppColors.cardBackground) + .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) } 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..a822284 --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/IPAInspection.swift @@ -0,0 +1,157 @@ +// +// 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] + + /// 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 + +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" + ) + ] + , + provisioningAppIdentifier: "ABCDE12345.com.company.mbriefing", + provisioningExpiration: nil + ) +} 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..f1ffe9f --- /dev/null +++ b/IPASignCraft/Domain/Models/IPAInspection/ParsedIPAInfo.swift @@ -0,0 +1,24 @@ +// +// 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 + + 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 new file mode 100644 index 0000000..6acabbd --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/Model/IPAInspectorState.swift @@ -0,0 +1,58 @@ +// +// IPAInspectorState.swift +// IPASignCraft +// +// Created by Saurav Nagpal on 09/06/26. +// + +import Foundation +import Observation + +/// 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? + + /// 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. + 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..1fee946 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/IPAInspectorView.swift @@ -0,0 +1,420 @@ +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 { + + ScreenShell { + 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 + + // Inspection results + if let inspection = viewModel.state.inspection { + + Divider() + + // Quick stat cards + statCardsSection(inspection: inspection) + + // Expandable sections + inspectionDetailsSection(inspection: inspection) + + } + } + .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() + } + ) + + Spacer() + } + .frame(width: 340) + } + .padding(.vertical, Spacing.lg) + .padding(.horizontal, Spacing.lg) + } + .toolbar { + ToolbarItemGroup { + refreshButton + } + } + } +} + +// MARK: - Background +private extension IPAInspectorView { + + var inspectorBackground: some View { + WatercolorBackground() + } +} + +// 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 }, + selectedSubtitle: "Ready For Inspection" + ) + } +} + +// 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: (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) + } + } + } +} + +// MARK: - Completion Status +private extension IPAInspectorView { + /// Compact variant of completion status suitable for sidebar. + var compactCompletionStatusSection: some View { + + // 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) + } + + if viewModel.state.selectedIPAURL == nil { + return ("tray", .gray, "Waiting for IPA", "Select or drop an IPA") + } + + if viewModel.state.isLoading { + return ("arrow.triangle.2.circlepath", AppColors.accentHover, "Processing IPA", "Scanning...") + } + + if viewModel.state.inspection != nil { + let ts = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .short) + return ("checkmark.circle.fill", .green, "Inspection completed", ts) + } + + return ("info.circle.fill", .blue, "Ready", "") + }() + + return HStack(spacing: Spacing.sm) { + + // Icon badge + ZStack { + RoundedRectangle(cornerRadius: 8) + .fill(iconColor.opacity(0.15)) + .frame(width: 44, height: 44) + + Image(systemName: iconName) + .font(.system(size: 18, weight: .semibold)) + .foregroundColor(iconColor) + } + + // Textual info + optional progress + VStack(alignment: .leading, spacing: 4) { + Text(titleText) + .font(AppFont.small) + .fontWeight(.semibold) + + 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) + .background( + RoundedRectangle(cornerRadius: Radius.sm) + .fill(AppColors.cardSurface) + ) + .shadow(color: AppColors.cardShadow, radius: 6, x: 0, y: 2) + } +} + +// 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..1832e62 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAFrameworksView.swift @@ -0,0 +1,202 @@ +// +// 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) + } + } + .frame(minHeight: 220, maxHeight: 420) + } + } + } +} + +// 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..5f91d5c --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPAOverviewView.swift @@ -0,0 +1,199 @@ +// +// 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 + + frameworkNamesSection + } + } +} + +// 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: - 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 { + + 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..d7bed39 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPASecurityView.swift @@ -0,0 +1,282 @@ +// +// 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" + ) + + let hasIssues = + !inspection.hasValidSignature || + inspection.architectures.isEmpty || + inspection.frameworks.contains { !$0.isSigned } + + HStack(spacing: 14) { + + Image(systemName: hasIssues ? "shield.slash" : "shield.checkered") + .font(.largeTitle) + .foregroundStyle(hasIssues ? .red : .green) + + VStack( + alignment: .leading, + spacing: 4 + ) { + + Text(hasIssues ? "Issues Detected" : "No Major Issues Detected") + .font(.headline) + + Text( + hasIssues + ? "One or more security checks failed. Review the validation section below." + : "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..8e7adae --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/View/sections/IPASigningView.swift @@ -0,0 +1,265 @@ +// +// 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: bundleIdentifierMatchesProfile + ) + + validationRow( + title: "Provision Match", + 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 { + + /// 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..a6e8040 --- /dev/null +++ b/IPASignCraft/Features/IPAInspector/ViewModel/IPAInspectorDetailViewModel.swift @@ -0,0 +1,145 @@ +// +// 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) { + // 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) + } + + guard !state.isLoading else { + addLog("Inspection already in progress.") + return + } + + Task { + await runInspection(for: url) + } + } + + /// Clears currently loaded inspection. + func resetInspection() { + + state.inspection = nil + state.errorMessage = nil + state.selectedSection = .overview + state.selectedIPAURL = nil + state.selectedFileSummary = 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..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() } } @@ -97,35 +86,17 @@ 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 }, + selectedSubtitle: "Ready For Signing" + ) } } @@ -151,7 +122,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 +575,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..5cf5d5f --- /dev/null +++ b/IPASignCraft/Service/IPAInspection/IPAInspectionService.swift @@ -0,0 +1,479 @@ +// +// 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 { + + private struct SecurityInspectionReport { + let hasValidSignature: Bool + let teamIdentifier: String + let architectures: [String] + let entitlements: [String] + let provisioningAppIdentifier: String? + let provisioningExpiration: Date? + } + + // 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 + ) + defer { try? FileManager.default.removeItem(at: extractedURL) } + + // 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 + ) + + let securityReport = inspectSecurity( + for: appBundleURL, + executableName: info.executableName + ) + + // Build inspection model + return IPAInspection( + + appName: info.appName, + + bundleIdentifier: info.bundleIdentifier, + + version: info.version, + + buildNumber: info.buildNumber, + + teamIdentifier: securityReport.teamIdentifier, + + architectures: securityReport.architectures, + + hasValidSignature: securityReport.hasValidSignature, + + entitlements: securityReport.entitlements, + + frameworks: frameworks, + provisioningAppIdentifier: securityReport.provisioningAppIdentifier, + provisioningExpiration: securityReport.provisioningExpiration + ) + } +} + +// 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 + do { + try IPAExtractorService.unzip(url, to: temporaryDirectory) + } catch { + throw IPAInspectionError.extractionFailed + } + 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", + + 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) + 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 } + + 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 [] } + + // 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 + 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 } + } + + 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 { + + /// Scans embedded frameworks + /// inside the application bundle. + func scanFrameworks( + inside appBundleURL: URL + ) throws -> [FrameworkInfo] { + + guard let enumerator = FileManager.default.enumerator( + at: appBundleURL, + includingPropertiesForKeys: nil, + options: [.skipsHiddenFiles] + ) else { + return [] + } + + var discovered: [URL] = [] + + for case let item as URL in enumerator { + let ext = item.pathExtension.lowercased() + + if ext == "framework" { + discovered.append(item) + continue + } + + 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 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 +}