355 lines
13 KiB
Swift
355 lines
13 KiB
Swift
import Foundation
|
|
#if canImport(FoundationNetworking)
|
|
import FoundationNetworking
|
|
#endif
|
|
|
|
public enum MiMoSettingsError: LocalizedError, Sendable, Equatable {
|
|
case missingCookie(details: String? = nil)
|
|
case invalidCookie
|
|
case invalidEndpointOverride(String)
|
|
|
|
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."
|
|
case let .invalidEndpointOverride(key):
|
|
"Xiaomi MiMo endpoint override \(key) must use HTTPS or a bare host."
|
|
}
|
|
}
|
|
}
|
|
|
|
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 = self.cleaned(environment[self.apiURLKey]),
|
|
let url = ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: override)
|
|
{
|
|
return url
|
|
}
|
|
return URL(string: "https://platform.xiaomimimo.com/api/v1")!
|
|
}
|
|
|
|
public static func validateEndpointOverrides(
|
|
environment: [String: String] = ProcessInfo.processInfo.environment) throws
|
|
{
|
|
guard let override = self.cleaned(environment[self.apiURLKey]) else { return }
|
|
guard ProviderEndpointOverrideValidator.normalizedHTTPSURL(from: override) == nil else { return }
|
|
throw MiMoSettingsError.invalidEndpointOverride(self.apiURLKey)
|
|
}
|
|
|
|
private static func cleaned(_ raw: String?) -> String? {
|
|
guard let value = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { return nil }
|
|
return value
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
try MiMoSettingsReader.validateEndpointOverrides(environment: environment)
|
|
|
|
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
|
|
}
|
|
}
|