a neat project
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: init webview approach

+640 -13
+220 -13
huntington/ContentView.swift
··· 1 - // 2 - // ContentView.swift 3 - // huntington 4 - // 5 - // Created by Kieran Klukas on 3/31/26. 6 - // 7 - 8 1 import SwiftUI 9 2 10 3 struct ContentView: View { 4 + @StateObject private var session = HuntingtonSession() 5 + @State private var accounts: [Account] = [] 6 + @State private var transactions: [Transaction] = [] 7 + @State private var showLogin = false 8 + @State private var isLoading = false 9 + @State private var errorMessage: String? 10 + 11 + private var client: HuntingtonClient { HuntingtonClient(session: session) } 12 + 11 13 var body: some View { 12 - VStack { 13 - Image(systemName: "globe") 14 - .imageScale(.large) 15 - .foregroundStyle(.tint) 16 - Text("Hello, world!") 14 + NavigationStack { 15 + Group { 16 + if isLoading { 17 + ProgressView("Loading…") 18 + } else if let error = errorMessage { 19 + ContentUnavailableView(error, systemImage: "exclamationmark.triangle") 20 + } else if !session.isAuthenticated { 21 + ContentUnavailableView("Not Signed In", systemImage: "lock", 22 + description: Text("Tap Sign In to connect your Huntington account.")) 23 + } else { 24 + List { 25 + if !accounts.isEmpty { 26 + Section("Accounts") { 27 + ForEach(accounts) { acct in 28 + NavigationLink(destination: AccountDetailView(account: acct, allTransactions: transactions)) { 29 + AccountRow(account: acct) 30 + } 31 + } 32 + } 33 + } 34 + if !transactions.isEmpty { 35 + Section("Last 30 Days") { 36 + ForEach(transactions) { tx in 37 + NavigationLink(destination: TransactionDetailView(transaction: tx, accounts: accounts)) { 38 + TransactionRow(transaction: tx) 39 + } 40 + } 41 + } 42 + } 43 + } 44 + .refreshable { await loadData() } 45 + } 46 + } 47 + .navigationTitle("Huntington") 48 + .toolbar { 49 + ToolbarItem(placement: .primaryAction) { 50 + if session.isAuthenticated { 51 + Button("Sign Out", role: .destructive) { session.signOut() } 52 + } else { 53 + Button("Sign In") { showLogin = true } 54 + } 55 + } 56 + } 57 + } 58 + // Hidden WKWebView kept in hierarchy for API calls 59 + .background(WebViewRepresentable(webView: session.webView).frame(width: 0, height: 0)) 60 + .sheet(isPresented: $showLogin) { 61 + LoginView(session: session) 62 + } 63 + .task { 64 + await session.initialize() 65 + if session.isAuthenticated { 66 + await loadData() 67 + } else { 68 + showLogin = true 69 + } 70 + } 71 + .onChange(of: session.isAuthenticated) { _, authenticated in 72 + if authenticated { Task { await loadData() } } 73 + } 74 + } 75 + 76 + private func loadData() async { 77 + isLoading = true 78 + errorMessage = nil 79 + defer { isLoading = false } 80 + do { 81 + let response = try await client.getAccounts() 82 + accounts = response.result.entities 83 + .flatMap { $0.userConnection.accounts } 84 + .filter { $0.active && !$0.closed } 85 + transactions = try await client.getRecentTransactions(accounts: accounts) 86 + } catch { 87 + errorMessage = error.localizedDescription 88 + } 89 + } 90 + } 91 + 92 + // MARK: - Rows 93 + 94 + struct AccountRow: View { 95 + let account: Account 96 + 97 + var body: some View { 98 + HStack { 99 + VStack(alignment: .leading, spacing: 2) { 100 + Text(account.alias).fontWeight(.medium) 101 + Text(account.number).font(.caption).foregroundStyle(.secondary) 102 + } 103 + Spacer() 104 + Text(account.availableBalance, format: .currency(code: "USD")) 105 + .fontWeight(.semibold) 106 + } 107 + } 108 + } 109 + 110 + struct TransactionRow: View { 111 + let transaction: Transaction 112 + 113 + var body: some View { 114 + HStack { 115 + VStack(alignment: .leading, spacing: 2) { 116 + Text(transaction.name).lineLimit(1) 117 + Text(transaction.date).font(.caption).foregroundStyle(.secondary) 118 + } 119 + Spacer() 120 + Text(transaction.amount, format: .currency(code: "USD")) 121 + .foregroundStyle(transaction.amount >= 0 ? .green : .primary) 17 122 } 18 - .padding() 123 + } 124 + } 125 + 126 + struct AccountDetailView: View { 127 + let account: Account 128 + let allTransactions: [Transaction] 129 + 130 + private var transactions: [Transaction] { 131 + allTransactions.filter { $0.accId == account.id } 132 + } 133 + 134 + var body: some View { 135 + List { 136 + Section { 137 + HStack { 138 + Spacer() 139 + VStack(spacing: 4) { 140 + Text(account.availableBalance, format: .currency(code: "USD")) 141 + .font(.system(size: 36, weight: .semibold)) 142 + Text("Available Balance") 143 + .font(.caption) 144 + .foregroundStyle(.secondary) 145 + if account.availableBalance != account.balance { 146 + Text("\(account.balance, format: .currency(code: "USD")) current") 147 + .font(.caption2) 148 + .foregroundStyle(.tertiary) 149 + } 150 + } 151 + Spacer() 152 + } 153 + .listRowBackground(Color.clear) 154 + .padding(.vertical, 8) 155 + } 156 + 157 + Section("Account Info") { 158 + LabeledContent("Account", value: account.number) 159 + LabeledContent("Type", value: account.huntingtonType) 160 + } 161 + 162 + if transactions.isEmpty { 163 + Section("Transactions") { 164 + Text("No transactions in the last 30 days") 165 + .foregroundStyle(.secondary) 166 + } 167 + } else { 168 + Section("Last 30 Days (\(transactions.count))") { 169 + ForEach(transactions) { tx in 170 + NavigationLink(destination: TransactionDetailView(transaction: tx, accounts: [account])) { 171 + TransactionRow(transaction: tx) 172 + } 173 + } 174 + } 175 + } 176 + } 177 + .navigationTitle(account.alias) 178 + .navigationBarTitleDisplayMode(.large) 179 + } 180 + } 181 + 182 + struct TransactionDetailView: View { 183 + let transaction: Transaction 184 + let accounts: [Account] 185 + 186 + private var account: Account? { 187 + accounts.first { $0.id == transaction.accId } 188 + } 189 + 190 + private var isCredit: Bool { transaction.amount >= 0 } 191 + 192 + var body: some View { 193 + List { 194 + Section { 195 + HStack { 196 + Spacer() 197 + VStack(spacing: 6) { 198 + Text(transaction.amount, format: .currency(code: "USD")) 199 + .font(.system(size: 40, weight: .semibold)) 200 + .foregroundStyle(isCredit ? .green : .primary) 201 + Text(transaction.transactionType.capitalized) 202 + .font(.caption) 203 + .foregroundStyle(.secondary) 204 + .padding(.horizontal, 10) 205 + .padding(.vertical, 3) 206 + .background(.quaternary, in: Capsule()) 207 + } 208 + Spacer() 209 + } 210 + .listRowBackground(Color.clear) 211 + .padding(.vertical, 8) 212 + } 213 + 214 + Section("Details") { 215 + LabeledContent("Date", value: transaction.date) 216 + LabeledContent("Name", value: transaction.name) 217 + if let account { 218 + LabeledContent("Account", value: "\(account.alias) \(account.number)") 219 + } 220 + LabeledContent("Type", value: isCredit ? "Credit" : "Debit") 221 + LabeledContent("Transaction ID", value: String(transaction.id)) 222 + } 223 + } 224 + .navigationTitle(transaction.name) 225 + .navigationBarTitleDisplayMode(.inline) 19 226 } 20 227 } 21 228
+88
huntington/HuntingtonClient.swift
··· 1 + import Foundation 2 + 3 + @MainActor 4 + class HuntingtonClient { 5 + private let session: HuntingtonSession 6 + private let bankingHost = "https://m.huntington.com" 7 + 8 + init(session: HuntingtonSession) { 9 + self.session = session 10 + } 11 + 12 + // MARK: - Accounts 13 + 14 + func getAccounts() async throws -> AccountsResponse { 15 + try await session.fetch("\(bankingHost)//dmm/fm-p/accounts/get/all.action?_=\(ts())") 16 + } 17 + 18 + // MARK: - Transactions 19 + 20 + func getTransactions(accountIds: [Int], startDate: String, endDate: String) async throws -> CalendarResponse { 21 + let ids = accountIds.map { "productIds=\($0)" }.joined(separator: "&") 22 + let url = "\(bankingHost)//dmm/fm-p/financialcalendar/get.action" 23 + + "?startStr=\(startDate)&endStr=\(endDate)&\(ids)" 24 + + "&includeBalances=false&includeSystemPatterns=false&_=\(ts())" 25 + return try await session.fetch(url) 26 + } 27 + 28 + func getRecentTransactions(accounts: [Account], days: Int = 30) async throws -> [Transaction] { 29 + let eligibleIds = accounts 30 + .filter { $0.eligibleWidgets.contains("financial-calendar") } 31 + .map { $0.id } 32 + guard !eligibleIds.isEmpty else { return [] } 33 + 34 + let fmt = DateFormatter() 35 + fmt.dateFormat = "yyyy-MM-dd" 36 + let end = Date() 37 + let start = Calendar.current.date(byAdding: .day, value: -days, to: end)! 38 + 39 + let calendar = try await getTransactions( 40 + accountIds: eligibleIds, 41 + startDate: fmt.string(from: start), 42 + endDate: fmt.string(from: end) 43 + ) 44 + return flatten(calendar) 45 + } 46 + 47 + // MARK: - Categories 48 + 49 + func getCategories() async throws -> CategoriesResponse { 50 + try await session.fetch("\(bankingHost)//dmm/fm-p/categories/get/all.action?_=\(ts())") 51 + } 52 + 53 + // MARK: - Helpers 54 + 55 + private func flatten(_ response: CalendarResponse) -> [Transaction] { 56 + guard let result = response.result else { return [] } 57 + return result.days 58 + .flatMap { date, day in 59 + day.transactions.map { 60 + Transaction( 61 + id: $0.id, accId: $0.accId, name: $0.name, 62 + amount: $0.amount, catId: $0.catId, 63 + transactionType: $0.transactionType, 64 + date: String(date.prefix(10)) 65 + ) 66 + } 67 + } 68 + .sorted { $0.date > $1.date } 69 + } 70 + 71 + private func ts() -> Int { Int(Date().timeIntervalSince1970 * 1000) } 72 + } 73 + 74 + // MARK: - Categories types (minimal) 75 + 76 + struct CategoriesResponse: Decodable { 77 + let result: [Category] 78 + } 79 + 80 + struct Category: Decodable, Identifiable { 81 + let catId: Int 82 + let catName: String 83 + let catHexrgbcolor: String 84 + let catIsIncome: Bool 85 + let categories: [Category] 86 + 87 + var id: Int { catId } 88 + }
+221
huntington/HuntingtonSession.swift
··· 1 + import WebKit 2 + import Combine 3 + 4 + enum HuntingtonError: LocalizedError { 5 + case invalidResponse 6 + case httpError(Int, String) 7 + 8 + var errorDescription: String? { 9 + switch self { 10 + case .invalidResponse: return "Invalid response from server" 11 + case .httpError(let code, let body): return "HTTP \(code): \(body)" 12 + } 13 + } 14 + } 15 + 16 + @MainActor 17 + class HuntingtonSession: NSObject, ObservableObject { 18 + @Published var isAuthenticated = false 19 + @Published var loginDidComplete = false // fires when auto-detect dismisses login sheet 20 + 21 + let webView: WKWebView 22 + private let cookiesKey = "huntington_cookies" 23 + private var pendingNavigation: CheckedContinuation<Void, Error>? 24 + private var isInLoginFlow = false 25 + 26 + override init() { 27 + let config = WKWebViewConfiguration() 28 + config.userContentController.addUserScript(HuntingtonSession.loginResponsiveScript) 29 + webView = WKWebView(frame: .zero, configuration: config) 30 + super.init() 31 + webView.navigationDelegate = self 32 + } 33 + 34 + private static let loginResponsiveScript: WKUserScript = { 35 + let css = """ 36 + html, body { min-width: 0 !important; width: 100% !important; overflow-x: hidden !important; } 37 + *, *::before, *::after { box-sizing: border-box !important; } 38 + #header, #footerNav, #footerBottom, hr, .Messages, .buttonsCentered img, #fab-area, #site-survey { display: none !important; } 39 + #container, .container_16 { width: 100% !important; max-width: 100% !important; margin: 0 !important; padding: 0 !important; } 40 + .grid_1,.grid_2,.grid_3,.grid_4,.grid_5,.grid_6,.grid_7,.grid_8,.grid_9,.grid_10,.grid_11,.grid_12,.grid_13,.grid_14,.grid_15,.grid_16 { width: 100% !important; max-width: 100% !important; float: none !important; margin: 0 !important; padding: 0 !important; display: block !important; } 41 + #content { padding: 32px 20px !important; } 42 + .login { width: 100% !important; max-width: 100% !important; } 43 + .login .widget { max-width: 360px !important; margin: 0 auto !important; border-radius: 12px !important; overflow: hidden !important; box-shadow: 0 2px 12px rgba(0,0,0,0.12) !important; } 44 + div.widget-title { padding: 20px 20px 16px !important; height: auto !important; line-height: normal !important; } 45 + div.widget-title h3 { height: auto !important; font-size: 20px !important; line-height: 1.3 !important; white-space: normal !important; } 46 + #removebottomborder { padding: 20px !important; background: #fff !important; border: none !important; } 47 + dl.loginForm { width: 100% !important; margin: 0 !important; } 48 + dl.loginForm dt { float: none !important; width: 100% !important; font-size: 14px !important; font-weight: 600 !important; margin-bottom: 6px !important; color: #444 !important; } 49 + dl.loginForm dd { margin: 0 0 16px 0 !important; } 50 + dl.loginForm dd input { width: 100% !important; font-size: 16px !important; padding: 12px !important; border: 1px solid #ccc !important; border-radius: 8px !important; background: #f9f9f9 !important; } 51 + dl.loginForm dd input:focus { outline: none !important; border-color: #5ba63c !important; background: #fff !important; box-shadow: 0 0 0 3px rgba(91,166,60,0.15) !important; } 52 + .widget-footer { padding: 0 20px 20px !important; background: #fff !important; } 53 + .buttonsCentered { padding: 0 !important; margin: 0 !important; } 54 + .buttonsCentered input[type=submit] { display: block !important; width: 100% !important; padding: 14px !important; font-size: 16px !important; font-weight: 600 !important; margin-top: 4px !important; border-radius: 8px !important; border: none !important; background: #5ba63c !important; color: #fff !important; cursor: pointer !important; } 55 + .signonLinks { width: 100% !important; text-align: center !important; margin-top: 20px !important; line-height: 2.2 !important; } 56 + """ 57 + let js = """ 58 + (function() { 59 + if (!window.location.href.includes('Auth') && !window.location.href.includes('login')) return; 60 + const s = document.createElement('style'); 61 + s.textContent = `\(css)`; 62 + (document.head || document.documentElement).appendChild(s); 63 + })(); 64 + """ 65 + return WKUserScript(source: js, injectionTime: .atDocumentEnd, forMainFrameOnly: true) 66 + }() 67 + 68 + // MARK: - Lifecycle 69 + 70 + func initialize() async { 71 + guard let cookies = loadSavedCookies(), !cookies.isEmpty else { return } 72 + await restoreCookies(cookies) 73 + try? await navigate(to: URL(string: "https://m.huntington.com/")!) 74 + isAuthenticated = await checkAuthenticated() 75 + } 76 + 77 + func startLogin() async { 78 + isInLoginFlow = true 79 + try? await navigate(to: URL(string: "https://onlinebanking.huntington.com/rol/Auth/login.aspx")!) 80 + } 81 + 82 + func completeLogin() async { 83 + isInLoginFlow = false 84 + let cookies = await webView.configuration.websiteDataStore.httpCookieStore.allCookies() 85 + saveCookies(cookies) 86 + try? await navigate(to: URL(string: "https://m.huntington.com/")!) 87 + isAuthenticated = true 88 + loginDidComplete = true 89 + } 90 + 91 + func signOut() { 92 + UserDefaults.standard.removeObject(forKey: cookiesKey) 93 + isAuthenticated = false 94 + Task { 95 + await webView.configuration.websiteDataStore.removeData( 96 + ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(), 97 + modifiedSince: .distantPast 98 + ) 99 + } 100 + } 101 + 102 + // MARK: - Fetch 103 + 104 + func fetch<T: Decodable>(_ url: String) async throws -> T { 105 + let js = """ 106 + const res = await fetch(url, { 107 + headers: { 108 + 'accept': 'application/json, text/javascript, */*; q=0.01', 109 + 'x-requested-with': 'XMLHttpRequest', 110 + 'referer': 'https://m.huntington.com/' 111 + } 112 + }); 113 + if (!res.ok) { 114 + const body = await res.text(); 115 + throw new Error('HTTP_' + res.status + ':' + body.slice(0, 300)); 116 + } 117 + return JSON.stringify(await res.json()); 118 + """ 119 + 120 + let result = try await callJS(js, arguments: ["url": url]) 121 + 122 + guard let jsonString = result as? String else { 123 + throw HuntingtonError.invalidResponse 124 + } 125 + 126 + return try JSONDecoder().decode(T.self, from: Data(jsonString.utf8)) 127 + } 128 + 129 + private func callJS(_ functionBody: String, arguments: [String: Any]) async throws -> Any? { 130 + try await withCheckedThrowingContinuation { continuation in 131 + webView.callAsyncJavaScript(functionBody, arguments: arguments, in: nil, in: .page) { result in 132 + switch result { 133 + case .success(let value): continuation.resume(returning: value) 134 + case .failure(let error): continuation.resume(throwing: error) 135 + } 136 + } 137 + } 138 + } 139 + 140 + // MARK: - Navigation 141 + 142 + private func navigate(to url: URL) async throws { 143 + try await withCheckedThrowingContinuation { continuation in 144 + pendingNavigation = continuation 145 + webView.load(URLRequest(url: url)) 146 + } 147 + } 148 + 149 + private func checkAuthenticated() async -> Bool { 150 + let url = "https://m.huntington.com//dmm/fm-p/accounts/get/all.action?_=\(timestamp())" 151 + let js = """ 152 + try { 153 + const res = await fetch(url, { 154 + headers: { 'accept': 'application/json', 'x-requested-with': 'XMLHttpRequest' } 155 + }); 156 + return res.ok; 157 + } catch { return false; } 158 + """ 159 + let result = try? await callJS(js, arguments: ["url": url]) 160 + return result as? Bool ?? false 161 + } 162 + 163 + // MARK: - Cookie persistence 164 + 165 + private func saveCookies(_ cookies: [HTTPCookie]) { 166 + let data = cookies.compactMap { cookie -> [String: Any]? in 167 + guard let props = cookie.properties else { return nil } 168 + return Dictionary(uniqueKeysWithValues: props.map { ($0.key.rawValue, $0.value) }) 169 + } 170 + UserDefaults.standard.set(data, forKey: cookiesKey) 171 + } 172 + 173 + private func loadSavedCookies() -> [HTTPCookie]? { 174 + guard let data = UserDefaults.standard.array(forKey: cookiesKey) as? [[String: Any]] else { 175 + return nil 176 + } 177 + return data.compactMap { dict in 178 + let props = Dictionary(uniqueKeysWithValues: dict.map { 179 + (HTTPCookiePropertyKey($0.key), $0.value) 180 + }) 181 + return HTTPCookie(properties: props) 182 + } 183 + } 184 + 185 + private func restoreCookies(_ cookies: [HTTPCookie]) async { 186 + let store = webView.configuration.websiteDataStore.httpCookieStore 187 + for cookie in cookies { 188 + await store.setCookie(cookie) 189 + } 190 + } 191 + 192 + private func timestamp() -> Int { 193 + Int(Date().timeIntervalSince1970 * 1000) 194 + } 195 + } 196 + 197 + // MARK: - WKNavigationDelegate 198 + 199 + extension HuntingtonSession: WKNavigationDelegate { 200 + func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { 201 + pendingNavigation?.resume() 202 + pendingNavigation = nil 203 + 204 + // Auto-detect login completion: once we leave the auth pages, we're in 205 + if isInLoginFlow, let url = webView.url?.absoluteString, 206 + url.contains("onlinebanking.huntington.com"), 207 + !url.contains("/Auth/"), !url.contains("/login") { 208 + Task { await completeLogin() } 209 + } 210 + } 211 + 212 + func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { 213 + pendingNavigation?.resume(throwing: error) 214 + pendingNavigation = nil 215 + } 216 + 217 + func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { 218 + pendingNavigation?.resume(throwing: error) 219 + pendingNavigation = nil 220 + } 221 + }
+44
huntington/LoginView.swift
··· 1 + import SwiftUI 2 + import WebKit 3 + 4 + struct LoginView: View { 5 + @ObservedObject var session: HuntingtonSession 6 + @Environment(\.dismiss) var dismiss 7 + @State private var isCompleting = false 8 + 9 + var body: some View { 10 + NavigationStack { 11 + WebViewRepresentable(webView: session.webView) 12 + .ignoresSafeArea(edges: .bottom) 13 + .navigationTitle("Sign In") 14 + .navigationBarTitleDisplayMode(.inline) 15 + .toolbar { 16 + ToolbarItem(placement: .confirmationAction) { 17 + Button("Done") { 18 + isCompleting = true 19 + Task { 20 + await session.completeLogin() 21 + isCompleting = false 22 + dismiss() 23 + } 24 + } 25 + .disabled(isCompleting) 26 + } 27 + ToolbarItem(placement: .cancellationAction) { 28 + Button("Cancel") { dismiss() } 29 + } 30 + } 31 + } 32 + .task { await session.startLogin() } 33 + .onChange(of: session.loginDidComplete) { _, completed in 34 + if completed { dismiss() } 35 + } 36 + } 37 + } 38 + 39 + struct WebViewRepresentable: UIViewRepresentable { 40 + let webView: WKWebView 41 + 42 + func makeUIView(context: Context) -> WKWebView { webView } 43 + func updateUIView(_ uiView: WKWebView, context: Context) {} 44 + }
+67
huntington/Models.swift
··· 1 + import Foundation 2 + 3 + // MARK: - Accounts 4 + 5 + struct AccountsResponse: Decodable { 6 + let result: AccountsResult 7 + } 8 + 9 + struct AccountsResult: Decodable { 10 + let amount: Double 11 + let entities: [AccountEntity] 12 + } 13 + 14 + struct AccountEntity: Decodable { 15 + let id: Int 16 + let name: String 17 + let userConnection: UserConnection 18 + } 19 + 20 + struct UserConnection: Decodable { 21 + let accounts: [Account] 22 + } 23 + 24 + struct Account: Decodable, Identifiable { 25 + let id: Int 26 + let alias: String 27 + let number: String 28 + let availableBalance: Double 29 + let balance: Double 30 + let eligibleWidgets: [String] 31 + let active: Bool 32 + let closed: Bool 33 + let huntingtonType: String 34 + } 35 + 36 + // MARK: - Transactions 37 + 38 + struct CalendarResponse: Decodable { 39 + let result: CalendarResult? 40 + } 41 + 42 + struct CalendarResult: Decodable { 43 + let days: [String: CalendarDay] 44 + } 45 + 46 + struct CalendarDay: Decodable { 47 + let transactions: [RawTransaction] 48 + } 49 + 50 + struct RawTransaction: Decodable { 51 + let id: Int 52 + let accId: Int 53 + let name: String 54 + let amount: Double 55 + let catId: Int 56 + let transactionType: String 57 + } 58 + 59 + struct Transaction: Identifiable { 60 + let id: Int 61 + let accId: Int 62 + let name: String 63 + let amount: Double 64 + let catId: Int 65 + let transactionType: String 66 + let date: String 67 + }