338 lines · 11.8 KB · Swift
RawDownload
import Foundation#if canImport(FoundationNetworking)import FoundationNetworking#endif public enum MiMoSettingsError: LocalizedError, Sendable, Equatable {    case missingCookie(details: String? = nil)    case invalidCookie     public var errorDescription: String? {        switch self {        case let .missingCookie(details):            [                "No Xiaomi MiMo browser session found. Log in at platform.xiaomimimo.com first.",                details,            ]                .compactMap(\.self)                .joined(separator: " ")        case .invalidCookie:            "Xiaomi MiMo requires the api-platform_serviceToken and userId cookies."        }    }} public enum MiMoUsageError: LocalizedError, Sendable {    case invalidCredentials    case loginRequired    case parseFailed(String)    case networkError(String)     public var errorDescription: String? {        switch self {        case .invalidCredentials:            "Xiaomi MiMo browser session expired. Log in again."        case .loginRequired:            "Xiaomi MiMo login required."        case let .parseFailed(message):            "Could not parse Xiaomi MiMo balance: \(message)"        case let .networkError(message):            "Xiaomi MiMo request failed: \(message)"        }    }} public enum MiMoSettingsReader {    public static let apiURLKey = "MIMO_API_URL"     public static func apiURL(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {        if let override = environment[self.apiURLKey],           let url = URL(string: override.trimmingCharacters(in: .whitespacesAndNewlines)),           let scheme = url.scheme, !scheme.isEmpty        {            return url        }        return URL(string: "https://platform.xiaomimimo.com/api/v1")!    }} public enum MiMoUsageFetcher {    private static let requestTimeout: TimeInterval = 15     public static func fetchUsage(        cookieHeader: String,        environment: [String: String] = ProcessInfo.processInfo.environment,        now: Date = Date(),        session transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> MiMoUsageSnapshot    {        guard let normalizedCookie = MiMoCookieHeader.normalizedHeader(from: cookieHeader) else {            throw MiMoSettingsError.invalidCookie        }         let balanceURL = MiMoSettingsReader.apiURL(environment: environment).appendingPathComponent("balance")        let tokenDetailURL = MiMoSettingsReader.apiURL(environment: environment)            .appendingPathComponent("tokenPlan/detail")        let tokenUsageURL = MiMoSettingsReader.apiURL(environment: environment)            .appendingPathComponent("tokenPlan/usage")         let payloads = try await self.fetchPayloads(            balanceURL: balanceURL,            tokenDetailURL: tokenDetailURL,            tokenUsageURL: tokenUsageURL,            cookie: normalizedCookie,            transport: transport)         return try self.parseCombinedSnapshot(            balanceData: payloads.balance,            tokenDetailData: payloads.tokenDetail,            tokenUsageData: payloads.tokenUsage,            now: now)    }     private enum FetchPart {        case balance(Data)        case tokenDetail(Data?)        case tokenUsage(Data?)    }     private static func fetchPayloads(        balanceURL: URL,        tokenDetailURL: URL,        tokenUsageURL: URL,        cookie: String,        transport: any ProviderHTTPTransport) async throws -> (balance: Data, tokenDetail: Data?, tokenUsage: Data?)    {        try await withThrowingTaskGroup(of: FetchPart.self) { group in            group.addTask {                try await .balance(self.fetchAuthenticated(                    url: balanceURL,                    cookie: cookie,                    transport: transport))            }            group.addTask {                await .tokenDetail(try? self.fetchAuthenticated(                    url: tokenDetailURL,                    cookie: cookie,                    transport: transport))            }            group.addTask {                await .tokenUsage(try? self.fetchAuthenticated(                    url: tokenUsageURL,                    cookie: cookie,                    transport: transport))            }             var balance: Data?            var tokenDetail: Data?            var tokenUsage: Data?             while let part = try await group.next() {                switch part {                case let .balance(data):                    balance = data                case let .tokenDetail(data):                    tokenDetail = data                case let .tokenUsage(data):                    tokenUsage = data                }            }             guard let balance else {                throw MiMoUsageError.networkError("Balance request did not complete")            }            return (balance, tokenDetail, tokenUsage)        }    }     private static func fetchAuthenticated(        url: URL,        cookie: String,        transport: any ProviderHTTPTransport = ProviderHTTPClient.shared) async throws -> Data    {        var request = URLRequest(url: url)        request.httpMethod = "GET"        request.timeoutInterval = Self.requestTimeout        request.setValue("application/json, text/plain, */*", forHTTPHeaderField: "Accept")        request.setValue(cookie, forHTTPHeaderField: "Cookie")        request.setValue("en-US,en;q=0.9", forHTTPHeaderField: "Accept-Language")        request.setValue("UTC+01:00", forHTTPHeaderField: "x-timeZone")        request.setValue("https://platform.xiaomimimo.com", forHTTPHeaderField: "Origin")        request.setValue("https://platform.xiaomimimo.com/#/console/balance", forHTTPHeaderField: "Referer")        request.setValue(            "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " +                "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36",            forHTTPHeaderField: "User-Agent")         let response = try await transport.response(for: request)         switch response.statusCode {        case 200:            break        // Expired browser sessions can redirect API requests to the login flow.        case 300..<400:            throw MiMoUsageError.loginRequired        case 401:            throw MiMoUsageError.loginRequired        case 403:            throw MiMoUsageError.invalidCredentials        default:            throw MiMoUsageError.networkError("HTTP \(response.statusCode)")        }         return response.data    }     static func parseCombinedSnapshot(        balanceData: Data,        tokenDetailData: Data?,        tokenUsageData: Data?,        now: Date = Date()) throws -> MiMoUsageSnapshot    {        let balanceSnapshot = try self.parseUsageSnapshot(from: balanceData, now: now)        let planDetail: (planCode: String?, periodEnd: Date?, expired: Bool) = {            guard let data = tokenDetailData, let result = try? self.parseTokenPlanDetail(from: data) else {                return (planCode: nil, periodEnd: nil, expired: false)            }            return result        }()        let planUsage: (used: Int, limit: Int, percent: Double) = {            guard let data = tokenUsageData, let result = try? self.parseTokenPlanUsage(from: data) else {                return (used: 0, limit: 0, percent: 0)            }            return result        }()         return MiMoUsageSnapshot(            balance: balanceSnapshot.balance,            currency: balanceSnapshot.currency,            cashBalance: balanceSnapshot.cashBalance,            giftBalance: balanceSnapshot.giftBalance,            planCode: planDetail.planCode,            planPeriodEnd: planDetail.periodEnd,            planExpired: planDetail.expired,            tokenUsed: planUsage.used,            tokenLimit: planUsage.limit,            tokenPercent: planUsage.percent,            updatedAt: now)    }     static func parseUsageSnapshot(from data: Data, now: Date = Date()) throws -> MiMoUsageSnapshot {        let decoder = JSONDecoder()        let response = try decoder.decode(BalanceResponse.self, from: data)         guard response.code == 0 else {            let message = response.message?.trimmingCharacters(in: .whitespacesAndNewlines)            if response.code == 401 {                throw MiMoUsageError.loginRequired            }            if response.code == 403 {                throw MiMoUsageError.invalidCredentials            }            throw MiMoUsageError.parseFailed(message?.isEmpty == false ? message! : "code \(response.code)")        }         guard let data = response.data else {            throw MiMoUsageError.parseFailed("Missing balance payload")        }        guard let balance = Double(data.balance) else {            throw MiMoUsageError.parseFailed("Invalid balance value")        }        let cashBalance = data.cashBalance.flatMap(Double.init)        let giftBalance = data.giftBalance.flatMap(Double.init)         let currency = data.currency.trimmingCharacters(in: .whitespacesAndNewlines)        guard !currency.isEmpty else {            throw MiMoUsageError.parseFailed("Missing currency")        }         return MiMoUsageSnapshot(            balance: balance,            currency: currency,            cashBalance: cashBalance,            giftBalance: giftBalance,            updatedAt: now)    }     static func parseTokenPlanDetail(from data: Data) throws -> (planCode: String?, periodEnd: Date?, expired: Bool) {        let decoder = JSONDecoder()        let response = try decoder.decode(TokenPlanDetailResponse.self, from: data)         guard response.code == 0, let payload = response.data else {            return (planCode: nil, periodEnd: nil, expired: false)        }         let formatter = DateFormatter()        formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"        formatter.locale = Locale(identifier: "en_US_POSIX")        formatter.timeZone = TimeZone(secondsFromGMT: 0)         let periodEnd: Date? = if let dateStr = payload.currentPeriodEnd {            formatter.date(from: dateStr)        } else {            nil        }         return (planCode: payload.planCode, periodEnd: periodEnd, expired: payload.expired)    }     static func parseTokenPlanUsage(from data: Data) throws -> (used: Int, limit: Int, percent: Double) {        let decoder = JSONDecoder()        let response = try decoder.decode(TokenPlanUsageResponse.self, from: data)         guard response.code == 0,              let monthUsage = response.data?.monthUsage,              let item = monthUsage.items.first        else {            return (used: 0, limit: 0, percent: 0)        }         return (used: item.used, limit: item.limit, percent: item.percent)    }     private struct BalanceResponse: Decodable {        let code: Int        let message: String?        let data: BalancePayload?    }     private struct BalancePayload: Decodable {        let balance: String        let currency: String        let cashBalance: String?        let giftBalance: String?    }     private struct TokenPlanDetailResponse: Decodable {        let code: Int        let message: String?        let data: TokenPlanDetailPayload?    }     private struct TokenPlanDetailPayload: Decodable {        let planCode: String?        let currentPeriodEnd: String?        let expired: Bool    }     private struct TokenPlanUsageResponse: Decodable {        let code: Int        let message: String?        let data: TokenPlanUsagePayload?    }     private struct TokenPlanUsagePayload: Decodable {        let monthUsage: MonthUsage?    }     private struct MonthUsage: Decodable {        let percent: Double        let items: [UsageItem]    }     private struct UsageItem: Decodable {        let name: String        let used: Int        let limit: Int        let percent: Double    }}