|
1 | | -import SwiftUI |
2 | | -import UniformTypeIdentifiers |
3 | | - |
4 | | -struct SignerView: View { |
5 | | - @StateObject private var ipa = FileItem() |
6 | | - @State private var isProcessing = false |
7 | | - @State private var overallProgress: Double = 0.0 |
8 | | - @State private var currentStage: String = "" |
9 | | - @State private var barColor: Color = .blue |
10 | | - @State private var isError: Bool = false |
11 | | - @State private var errorDetails: String = "" |
12 | | - @State private var showActivity = false |
13 | | - @State private var activityURL: URL? = nil |
14 | | - @State private var showPickerFor: PickerKind? = nil |
15 | | - @State private var selectedCertificateName: String? = nil |
16 | | - @State private var expireStatus: String = "Unknown" |
17 | | - @State private var hasSelectedCertificate: Bool = false |
18 | | - |
19 | | - var body: some View { |
20 | | - Form { |
21 | | - Section(header: Text("Inputs") |
22 | | - .font(.headline) |
23 | | - .foregroundColor(.primary) |
24 | | - .padding(.top, 8)) { |
25 | | - // IPA picker with icon and truncated file name |
26 | | - HStack { |
27 | | - Image(systemName: "doc.fill") |
28 | | - .foregroundColor(.blue) |
29 | | - Text("IPA") |
30 | | - Spacer() |
31 | | - Text(ipa.name.isEmpty ? "No file selected selected" : ipa.name) |
32 | | - .font(.caption) |
33 | | - .lineLimit(1) |
34 | | - .foregroundColor(.secondary) |
35 | | - Button(action: { |
36 | | - showPickerFor = .ipa |
37 | | - }) { |
38 | | - Text("Pick") |
39 | | - .padding(.horizontal, 8) |
40 | | - .padding(.vertical, 4) |
41 | | - .background(Color.blue.opacity(0.1)) |
42 | | - .cornerRadius(8) |
43 | | - } |
44 | | - } |
45 | | - .padding(.vertical, 4) |
46 | | - if hasSelectedCertificate, let name = selectedCertificateName { |
47 | | - Text("The \(name) certificate (\(expireStatus)) will be used to sign the ipa file. If you wish to use a different certificate for signing, please select or add it to the certificates page.") |
48 | | - .font(.subheadline) |
49 | | - .foregroundColor(.secondary) |
50 | | - .padding(.vertical, 4) |
51 | | - } else { |
52 | | - Text("No certificate selected. Please add and select one in the Certificates tab.") |
53 | | - .foregroundColor(.red) |
54 | | - .padding(.vertical, 4) |
55 | | - } |
56 | | - } |
57 | | - Section { |
58 | | - Button(action: runSign) { |
59 | | - HStack { |
60 | | - Spacer() |
61 | | - Text("Sign IPA") |
62 | | - .font(.headline) |
63 | | - .foregroundColor(.white) |
64 | | - .padding() |
65 | | - .frame(maxWidth: .infinity) |
66 | | - .background(isProcessing || ipa.url == nil || !hasSelectedCertificate ? Color.gray : Color.blue) |
67 | | - .cornerRadius(10) |
68 | | - .shadow(radius: 2) |
69 | | - Spacer() |
70 | | - } |
71 | | - } |
72 | | - .disabled(isProcessing || ipa.url == nil || !hasSelectedCertificate) |
73 | | - .scaleEffect(isProcessing ? 0.95 : 1.0) |
74 | | - .animation(.easeInOut(duration: 0.2), value: isProcessing) |
75 | | - } |
76 | | - .padding(.vertical, 8) |
77 | | - if isProcessing || !currentStage.isEmpty { |
78 | | - Section(header: Text("Progress") |
79 | | - .font(.headline) |
80 | | - .foregroundColor(.primary) |
81 | | - .padding(.top, 8)) { |
82 | | - HStack { |
83 | | - Text(currentStage) |
84 | | - .foregroundColor(currentStage == "Error" ? .red : currentStage == "Done!" ? .green : .primary) |
85 | | - .animation(.easeInOut(duration: 0.2), value: currentStage) |
86 | | - ProgressView(value: overallProgress) |
87 | | - .progressViewStyle(.linear) |
88 | | - .tint(barColor) |
89 | | - .frame(maxWidth: .infinity) |
90 | | - .animation(.easeInOut(duration: 0.5), value: overallProgress) |
91 | | - .animation(.default, value: barColor) |
92 | | - Text("\(Int(overallProgress * 100))%") |
93 | | - .foregroundColor(currentStage == "Error" ? .red : currentStage == "Done!" ? .green : .primary) |
94 | | - .animation(nil, value: overallProgress) |
95 | | - } |
96 | | - if isError { |
97 | | - Text(errorDetails) |
98 | | - .foregroundColor(.red) |
99 | | - .font(.subheadline) |
100 | | - } |
101 | | - } |
102 | | - } |
103 | | - } |
104 | | - .accentColor(.blue) |
105 | | - .sheet(item: $showPickerFor, onDismiss: nil) { kind in |
106 | | - DocumentPicker(kind: kind, onPick: { url in |
107 | | - switch kind { |
108 | | - case .ipa: |
109 | | - ipa.url = url |
110 | | - default: |
111 | | - break |
112 | | - } |
113 | | - }) |
114 | | - } |
115 | | - .sheet(isPresented: $showActivity) { |
116 | | - if let u = activityURL { |
117 | | - ActivityView(url: u) |
118 | | - } else { |
119 | | - Text("No file to share") |
120 | | - .foregroundColor(.red) |
121 | | - } |
122 | | - } |
123 | | - .onAppear { |
124 | | - loadSelectedCertificate() |
125 | | - } |
126 | | - } |
127 | | - |
128 | | - private func loadSelectedCertificate() { |
129 | | - guard let selectedFolder = UserDefaults.standard.string(forKey: "selectedCertificateFolder") else { |
130 | | - hasSelectedCertificate = false |
131 | | - return |
132 | | - } |
133 | | - let certDir = CertificateFileManager.shared.certificatesDirectory.appendingPathComponent(selectedFolder) |
134 | | - if let nameData = try? Data(contentsOf: certDir.appendingPathComponent("name.txt")), |
135 | | - let name = String(data: nameData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines), |
136 | | - !name.isEmpty { |
137 | | - selectedCertificateName = name |
138 | | - } else { |
139 | | - selectedCertificateName = "Custom Certificate" |
140 | | - } |
141 | | - let provURL = certDir.appendingPathComponent("profile.mobileprovision") |
142 | | - if let expiry = signer.getExpirationDate(provURL: provURL) { |
143 | | - let now = Date() |
144 | | - let components = Calendar.current.dateComponents([.day], from: now, to: expiry) |
145 | | - let days = components.day ?? 0 |
146 | | - switch days { |
147 | | - case ..<0, 0: |
148 | | - expireStatus = "Expired" |
149 | | - case 1...30: |
150 | | - expireStatus = "Expiring Soon" |
151 | | - default: |
152 | | - expireStatus = "Valid" |
153 | | - } |
154 | | - } else { |
155 | | - expireStatus = "Unknown" |
156 | | - } |
157 | | - hasSelectedCertificate = true |
158 | | - } |
159 | | - |
160 | | - func runSign() { |
161 | | - guard let ipaURL = ipa.url else { |
162 | | - currentStage = "Error" |
163 | | - errorDetails = "Pick IPA file first 😅" |
164 | | - isError = true |
165 | | - withAnimation { |
166 | | - overallProgress = 1.0 |
167 | | - barColor = .red |
168 | | - } |
169 | | - return |
170 | | - } |
171 | | - guard let selectedFolder = UserDefaults.standard.string(forKey: "selectedCertificateFolder") else { |
172 | | - currentStage = "Error" |
173 | | - errorDetails = "No certificate selected 😅" |
174 | | - isError = true |
175 | | - withAnimation { |
176 | | - overallProgress = 1.0 |
177 | | - barColor = .red |
178 | | - } |
179 | | - return |
180 | | - } |
181 | | - let certDir = CertificateFileManager.shared.certificatesDirectory.appendingPathComponent(selectedFolder) |
182 | | - let p12URL = certDir.appendingPathComponent("certificate.p12") |
183 | | - let provURL = certDir.appendingPathComponent("profile.mobileprovision") |
184 | | - let passwordURL = certDir.appendingPathComponent("password.txt") |
185 | | - guard FileManager.default.fileExists(atPath: p12URL.path), FileManager.default.fileExists(atPath: provURL.path) else { |
186 | | - currentStage = "Error" |
187 | | - errorDetails = "Error loading certificate files 😅" |
188 | | - isError = true |
189 | | - withAnimation { |
190 | | - overallProgress = 1.0 |
191 | | - barColor = .red |
192 | | - } |
193 | | - return |
194 | | - } |
195 | | - let p12Password: String |
196 | | - if let passwordData = try? Data(contentsOf: passwordURL), |
197 | | - let passwordStr = String(data: passwordData, encoding: .utf8) { |
198 | | - p12Password = passwordStr |
199 | | - } else { |
200 | | - p12Password = "" |
201 | | - } |
202 | | - isProcessing = true |
203 | | - currentStage = "Preparing" |
204 | | - overallProgress = 0.0 |
205 | | - barColor = .blue |
206 | | - isError = false |
207 | | - errorDetails = "" |
208 | | - signer.sign( |
209 | | - ipaURL: ipaURL, |
210 | | - p12URL: p12URL, |
211 | | - provURL: provURL, |
212 | | - p12Password: p12Password, |
213 | | - progressUpdate: { message in |
214 | | - DispatchQueue.main.async { |
215 | | - updateProgress(from: message) |
216 | | - } |
217 | | - }, |
218 | | - completion: { result in |
219 | | - DispatchQueue.main.async { |
220 | | - isProcessing = false |
221 | | - switch result { |
222 | | - case .success(let signedIPAURL): |
223 | | - activityURL = signedIPAURL |
224 | | - withAnimation { |
225 | | - overallProgress = 1.0 |
226 | | - barColor = .green |
227 | | - currentStage = "Done!" |
228 | | - } |
229 | | - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { |
230 | | - showActivity = true |
231 | | - } |
232 | | - case .failure(let error): |
233 | | - withAnimation { |
234 | | - overallProgress = 1.0 |
235 | | - barColor = .red |
236 | | - currentStage = "Error" |
237 | | - } |
238 | | - isError = true |
239 | | - errorDetails = error.localizedDescription |
240 | | - } |
241 | | - } |
242 | | - } |
243 | | - ) |
244 | | - } |
245 | | - |
246 | | - private func updateProgress(from message: String) { |
247 | | - if message.contains("Preparing") { |
248 | | - currentStage = "Preparing" |
249 | | - overallProgress = 0.0 |
250 | | - } else if message.contains("Unzipping") { |
251 | | - currentStage = "Unzipping" |
252 | | - if let pct = extractPercentage(from: message) { |
253 | | - overallProgress = 0.25 + (pct / 100.0) * 0.25 |
254 | | - } else { |
255 | | - overallProgress = 0.25 |
256 | | - } |
257 | | - } else if message.contains("Signing") { |
258 | | - currentStage = "Signing" |
259 | | - overallProgress = 0.5 |
260 | | - } else if message.contains("Zipping") { |
261 | | - currentStage = "Zipping" |
262 | | - if let pct = extractPercentage(from: message) { |
263 | | - overallProgress = 0.75 + (pct / 100.0) * 0.25 |
264 | | - } else { |
265 | | - overallProgress = 0.75 |
266 | | - } |
267 | | - } |
268 | | - } |
269 | | - |
270 | | - private func extractPercentage(from message: String) -> Double? { |
271 | | - if let range = message.range(of: "(") { |
272 | | - let substring = message[range.lowerBound...] |
273 | | - if let endRange = substring.range(of: "%)") { |
274 | | - let pctString = substring[..<endRange.lowerBound].dropFirst() |
275 | | - return Double(pctString) |
276 | | - } |
277 | | - } |
278 | | - return nil |
279 | | - } |
280 | | - |
281 | | -} |
282 | | - |
0 commit comments