356 lines
13 KiB
Swift
356 lines
13 KiB
Swift
import Foundation
|
|
#if os(macOS) && canImport(FoundationXML)
|
|
import FoundationXML
|
|
#endif
|
|
|
|
public struct JetBrainsQuotaInfo: Sendable, Equatable {
|
|
public let type: String?
|
|
public let used: Double
|
|
public let maximum: Double
|
|
public let available: Double
|
|
public let until: Date?
|
|
|
|
public init(type: String?, used: Double, maximum: Double, available: Double?, until: Date?) {
|
|
self.type = type
|
|
self.used = used
|
|
self.maximum = maximum
|
|
// Use available if provided, otherwise calculate from maximum - used
|
|
self.available = available ?? max(0, maximum - used)
|
|
self.until = until
|
|
}
|
|
|
|
/// Percentage of quota that has been used (0-100)
|
|
public var usedPercent: Double {
|
|
guard self.maximum > 0 else { return 0 }
|
|
return min(100, max(0, (self.used / self.maximum) * 100))
|
|
}
|
|
|
|
/// Percentage of quota remaining (0-100), based on available value
|
|
public var remainingPercent: Double {
|
|
guard self.maximum > 0 else { return 100 }
|
|
return min(100, max(0, (self.available / self.maximum) * 100))
|
|
}
|
|
}
|
|
|
|
public struct JetBrainsRefillInfo: Sendable, Equatable {
|
|
public let type: String?
|
|
public let next: Date?
|
|
public let amount: Double?
|
|
public let duration: String?
|
|
|
|
public init(type: String?, next: Date?, amount: Double?, duration: String?) {
|
|
self.type = type
|
|
self.next = next
|
|
self.amount = amount
|
|
self.duration = duration
|
|
}
|
|
}
|
|
|
|
public struct JetBrainsStatusSnapshot: Sendable {
|
|
public let quotaInfo: JetBrainsQuotaInfo
|
|
public let refillInfo: JetBrainsRefillInfo?
|
|
public let detectedIDE: JetBrainsIDEInfo?
|
|
|
|
public init(quotaInfo: JetBrainsQuotaInfo, refillInfo: JetBrainsRefillInfo?, detectedIDE: JetBrainsIDEInfo?) {
|
|
self.quotaInfo = quotaInfo
|
|
self.refillInfo = refillInfo
|
|
self.detectedIDE = detectedIDE
|
|
}
|
|
|
|
public func toUsageSnapshot() throws -> UsageSnapshot {
|
|
// Primary shows monthly credits usage with next refill date
|
|
// IDE displays: "今月のクレジット残り X / Y" with "Z月D日に更新されます"
|
|
let refillDate = self.refillInfo?.next
|
|
let primary = RateWindow(
|
|
usedPercent: self.quotaInfo.usedPercent,
|
|
windowMinutes: nil,
|
|
resetsAt: refillDate,
|
|
resetDescription: Self.formatResetDescription(refillDate))
|
|
|
|
let identity = ProviderIdentitySnapshot(
|
|
providerID: .jetbrains,
|
|
accountEmail: nil,
|
|
accountOrganization: self.detectedIDE?.displayName,
|
|
loginMethod: self.quotaInfo.type)
|
|
|
|
return UsageSnapshot(
|
|
primary: primary,
|
|
secondary: nil,
|
|
tertiary: nil,
|
|
updatedAt: Date(),
|
|
identity: identity)
|
|
}
|
|
|
|
private static func formatResetDescription(_ date: Date?) -> String? {
|
|
guard let date else { return nil }
|
|
let now = Date()
|
|
let interval = date.timeIntervalSince(now)
|
|
guard interval > 0 else { return "Expired" }
|
|
|
|
let hours = Int(interval / 3600)
|
|
let minutes = Int((interval.truncatingRemainder(dividingBy: 3600)) / 60)
|
|
|
|
if hours > 24 {
|
|
let days = hours / 24
|
|
let remainingHours = hours % 24
|
|
return "Resets in \(days)d \(remainingHours)h"
|
|
} else if hours > 0 {
|
|
return "Resets in \(hours)h \(minutes)m"
|
|
} else {
|
|
return "Resets in \(minutes)m"
|
|
}
|
|
}
|
|
}
|
|
|
|
public enum JetBrainsStatusProbeError: LocalizedError, Sendable, Equatable {
|
|
case noIDEDetected
|
|
case quotaFileNotFound(String)
|
|
case parseError(String)
|
|
case noQuotaInfo
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .noIDEDetected:
|
|
"No JetBrains IDE with AI Assistant detected. Install a JetBrains IDE and enable AI Assistant."
|
|
case let .quotaFileNotFound(path):
|
|
"JetBrains AI quota file not found at \(path). Enable AI Assistant in your IDE."
|
|
case let .parseError(message):
|
|
"Could not parse JetBrains AI quota: \(message)"
|
|
case .noQuotaInfo:
|
|
"No quota information found in the JetBrains AI configuration."
|
|
}
|
|
}
|
|
}
|
|
|
|
public struct JetBrainsStatusProbe: Sendable {
|
|
private let settings: ProviderSettingsSnapshot?
|
|
|
|
public init(settings: ProviderSettingsSnapshot? = nil) {
|
|
self.settings = settings
|
|
}
|
|
|
|
public func fetch() async throws -> JetBrainsStatusSnapshot {
|
|
let (quotaFilePath, detectedIDE) = try self.resolveQuotaFilePath()
|
|
return try Self.parseQuotaFile(at: quotaFilePath, detectedIDE: detectedIDE)
|
|
}
|
|
|
|
private func resolveQuotaFilePath() throws -> (String, JetBrainsIDEInfo?) {
|
|
if let customPath = self.settings?.jetbrainsIDEBasePath?.trimmingCharacters(in: .whitespacesAndNewlines),
|
|
!customPath.isEmpty
|
|
{
|
|
let expandedBasePath = (customPath as NSString).expandingTildeInPath
|
|
let quotaPath = JetBrainsIDEDetector.quotaFilePath(for: expandedBasePath)
|
|
return (quotaPath, nil)
|
|
}
|
|
|
|
guard let detectedIDE = JetBrainsIDEDetector.detectLatestIDE() else {
|
|
throw JetBrainsStatusProbeError.noIDEDetected
|
|
}
|
|
return (detectedIDE.quotaFilePath, detectedIDE)
|
|
}
|
|
|
|
public static func parseQuotaFile(
|
|
at path: String,
|
|
detectedIDE: JetBrainsIDEInfo?) throws -> JetBrainsStatusSnapshot
|
|
{
|
|
guard FileManager.default.fileExists(atPath: path) else {
|
|
throw JetBrainsStatusProbeError.quotaFileNotFound(path)
|
|
}
|
|
|
|
let xmlData: Data
|
|
do {
|
|
xmlData = try Data(contentsOf: URL(fileURLWithPath: path))
|
|
} catch {
|
|
throw JetBrainsStatusProbeError.parseError("Failed to read file: \(error.localizedDescription)")
|
|
}
|
|
|
|
return try Self.parseXMLData(xmlData, detectedIDE: detectedIDE)
|
|
}
|
|
|
|
public static func parseXMLData(_ data: Data, detectedIDE: JetBrainsIDEInfo?) throws -> JetBrainsStatusSnapshot {
|
|
#if os(macOS)
|
|
let document: XMLDocument
|
|
do {
|
|
document = try XMLDocument(data: data)
|
|
} catch {
|
|
throw JetBrainsStatusProbeError.parseError("Invalid XML: \(error.localizedDescription)")
|
|
}
|
|
|
|
let quotaInfoRaw = try? document
|
|
.nodes(forXPath: "//component[@name='AIAssistantQuotaManager2']/option[@name='quotaInfo']/@value")
|
|
.first?
|
|
.stringValue
|
|
let nextRefillRaw = try? document
|
|
.nodes(forXPath: "//component[@name='AIAssistantQuotaManager2']/option[@name='nextRefill']/@value")
|
|
.first?
|
|
.stringValue
|
|
#else
|
|
let parseResult = JetBrainsXMLParser.parse(data: data)
|
|
let quotaInfoRaw = parseResult.quotaInfo
|
|
let nextRefillRaw = parseResult.nextRefill
|
|
#endif
|
|
|
|
guard let quotaInfoRaw, !quotaInfoRaw.isEmpty else {
|
|
throw JetBrainsStatusProbeError.noQuotaInfo
|
|
}
|
|
|
|
let quotaInfoDecoded = Self.decodeHTMLEntities(quotaInfoRaw)
|
|
let quotaInfo = try Self.parseQuotaInfoJSON(quotaInfoDecoded)
|
|
|
|
var refillInfo: JetBrainsRefillInfo?
|
|
if let nextRefillRaw, !nextRefillRaw.isEmpty {
|
|
let nextRefillDecoded = Self.decodeHTMLEntities(nextRefillRaw)
|
|
refillInfo = try? Self.parseRefillInfoJSON(nextRefillDecoded)
|
|
}
|
|
|
|
return JetBrainsStatusSnapshot(
|
|
quotaInfo: quotaInfo,
|
|
refillInfo: refillInfo,
|
|
detectedIDE: detectedIDE)
|
|
}
|
|
|
|
private static func decodeHTMLEntities(_ string: String) -> String {
|
|
string
|
|
.replacingOccurrences(of: " ", with: "\n")
|
|
.replacingOccurrences(of: """, with: "\"")
|
|
.replacingOccurrences(of: "&", with: "&")
|
|
.replacingOccurrences(of: "<", with: "<")
|
|
.replacingOccurrences(of: ">", with: ">")
|
|
.replacingOccurrences(of: "'", with: "'")
|
|
}
|
|
|
|
private static func parseQuotaInfoJSON(_ jsonString: String) throws -> JetBrainsQuotaInfo {
|
|
guard let data = jsonString.data(using: .utf8) else {
|
|
throw JetBrainsStatusProbeError.parseError("Invalid JSON encoding")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw JetBrainsStatusProbeError.parseError("Invalid JSON format")
|
|
}
|
|
|
|
let type = json["type"] as? String
|
|
let currentStr = json["current"] as? String
|
|
let maximumStr = json["maximum"] as? String
|
|
let untilStr = json["until"] as? String
|
|
|
|
// tariffQuota contains the actual available credits
|
|
let tariffQuota = json["tariffQuota"] as? [String: Any]
|
|
let availableStr = tariffQuota?["available"] as? String
|
|
|
|
let used = currentStr.flatMap { Double($0) } ?? 0
|
|
let maximum = maximumStr.flatMap { Double($0) } ?? 0
|
|
let available = availableStr.flatMap { Double($0) }
|
|
let until = untilStr.flatMap { Self.parseDate($0) }
|
|
|
|
return JetBrainsQuotaInfo(type: type, used: used, maximum: maximum, available: available, until: until)
|
|
}
|
|
|
|
private static func parseRefillInfoJSON(_ jsonString: String) throws -> JetBrainsRefillInfo {
|
|
guard let data = jsonString.data(using: .utf8) else {
|
|
throw JetBrainsStatusProbeError.parseError("Invalid JSON encoding")
|
|
}
|
|
|
|
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
|
throw JetBrainsStatusProbeError.parseError("Invalid JSON format")
|
|
}
|
|
|
|
let type = json["type"] as? String
|
|
let nextStr = json["next"] as? String
|
|
let amountStr = json["amount"] as? String
|
|
let duration = json["duration"] as? String
|
|
|
|
let next = nextStr.flatMap { Self.parseDate($0) }
|
|
let amount = amountStr.flatMap { Double($0) }
|
|
|
|
let tariff = json["tariff"] as? [String: Any]
|
|
let tariffAmountStr = tariff?["amount"] as? String
|
|
let tariffDuration = tariff?["duration"] as? String
|
|
let finalAmount = amount ?? tariffAmountStr.flatMap { Double($0) }
|
|
let finalDuration = duration ?? tariffDuration
|
|
|
|
return JetBrainsRefillInfo(type: type, next: next, amount: finalAmount, duration: finalDuration)
|
|
}
|
|
|
|
private static func parseDate(_ string: String) -> Date? {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
if let date = formatter.date(from: string) {
|
|
return date
|
|
}
|
|
|
|
formatter.formatOptions = [.withInternetDateTime]
|
|
return formatter.date(from: string)
|
|
}
|
|
}
|
|
|
|
#if !os(macOS)
|
|
/// Simple regex-based XML parser to avoid libxml2 dependency on Linux.
|
|
/// Only extracts quotaInfo and nextRefill values from AIAssistantQuotaManager2 component.
|
|
private enum JetBrainsXMLParser {
|
|
struct ParseResult {
|
|
let quotaInfo: String?
|
|
let nextRefill: String?
|
|
}
|
|
|
|
static func parse(data: Data) -> ParseResult {
|
|
guard let content = String(data: data, encoding: .utf8) else {
|
|
return ParseResult(quotaInfo: nil, nextRefill: nil)
|
|
}
|
|
|
|
// Find the AIAssistantQuotaManager2 component block
|
|
guard let componentRange = self.findComponentRange(in: content) else {
|
|
return ParseResult(quotaInfo: nil, nextRefill: nil)
|
|
}
|
|
|
|
let componentContent = String(content[componentRange])
|
|
|
|
let quotaInfo = self.extractOptionValue(named: "quotaInfo", from: componentContent)
|
|
let nextRefill = self.extractOptionValue(named: "nextRefill", from: componentContent)
|
|
|
|
return ParseResult(quotaInfo: quotaInfo, nextRefill: nextRefill)
|
|
}
|
|
|
|
private static func findComponentRange(in content: String) -> Range<String.Index>? {
|
|
// Match <component name="AIAssistantQuotaManager2"> ... </component>
|
|
let pattern = #"<component[^>]*name\s*=\s*["']AIAssistantQuotaManager2["'][^>]*>[\s\S]*?</component>"#
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
|
|
let match = regex.firstMatch(
|
|
in: content,
|
|
options: [],
|
|
range: NSRange(content.startIndex..., in: content)),
|
|
let range = Range(match.range, in: content)
|
|
else {
|
|
return nil
|
|
}
|
|
return range
|
|
}
|
|
|
|
private static func extractOptionValue(named name: String, from content: String) -> String? {
|
|
// Match <option name="NAME" value="VALUE"/> or <option value="VALUE" name="NAME"/>
|
|
let patterns = [
|
|
#"<option[^>]*name\s*=\s*["']\#(name)["'][^>]*value\s*=\s*["']([^"']*)["']"#,
|
|
#"<option[^>]*value\s*=\s*["']([^"']*)["'][^>]*name\s*=\s*["']\#(name)["']"#,
|
|
]
|
|
|
|
for pattern in patterns {
|
|
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
|
|
let match = regex.firstMatch(
|
|
in: content,
|
|
options: [],
|
|
range: NSRange(content.startIndex..., in: content))
|
|
else {
|
|
continue
|
|
}
|
|
|
|
// The value is in capture group 1 for first pattern, group 1 for second pattern
|
|
let valueRange = match.range(at: 1)
|
|
if let range = Range(valueRange, in: content) {
|
|
return String(content[range])
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
}
|
|
#endif
|