73dc31ae-848d-485b-8333-383.../Sources/DeepSeekBalancePlugin.swift

361 lines
14 KiB
Swift
Raw Normal View History

//
// DeepSeekBalancePlugin.swift
// MioIsland DeepSeek +
//
// ~/.config/token-bar/status.jsontoken_bar.py 30
// + /DeepSeek API 404
//
import AppKit
import SwiftUI
import Combine
//
// MARK: -
//
private let statusFile = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/token-bar/status.json")
private let historyFile = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".config/token-bar/balance_history.json")
struct ProviderStatus: Decodable {
let balance: Double?
let budget: Double?
let error: String?
}
struct StatusData: Decodable {
let providers: [ProviderStatus]?
let updated: String?
}
struct BalancePoint: Codable {
let t: TimeInterval
let balance: Double
}
// Kimi Code GET /coding/v1/usagesOAuth token CLI
struct KimiUsage {
var limit: Int = 0
var used: Int = 0
var remaining: Int = 0
var resetTime: String = ""
var membership: String = ""
var available = false
var remainingRatio: Double { limit > 0 ? Double(remaining) / Double(limit) : 0 }
}
private let kimiCredsFile = FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".kimi-code/credentials/kimi-code.json")
func fetchKimiUsage() -> KimiUsage {
var usage = KimiUsage()
guard let data = try? Data(contentsOf: kimiCredsFile),
let creds = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let token = creds["access_token"] as? String,
let url = URL(string: "https://api.kimi.com/coding/v1/usages") else { return usage }
var req = URLRequest(url: url)
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 8
guard let respData = try? NSURLConnection.sendSynchronousRequest(req, returning: nil),
let json = try? JSONSerialization.jsonObject(with: respData) as? [String: Any],
let u = json["usage"] as? [String: Any] else { return usage }
usage.limit = Int(u["limit"] as? String ?? "0") ?? 0
usage.used = Int(u["used"] as? String ?? "0") ?? 0
usage.remaining = Int(u["remaining"] as? String ?? "0") ?? 0
usage.resetTime = u["resetTime"] as? String ?? ""
if let user = json["user"] as? [String: Any],
let m = user["membership"] as? [String: Any] {
usage.membership = m["level"] as? String ?? ""
}
usage.available = true
return usage
}
/// SwiftUI
final class BalanceStore: ObservableObject {
@Published var balance: Double = 0
@Published var budget: Double = 100
@Published var updated: String = "--"
@Published var history: [BalancePoint] = []
@Published var kimi = KimiUsage()
var ratio: Double { budget > 0 ? balance / budget : 1 }
/// >=35% <=20%
var accent: Color {
let t = min(max((0.35 - ratio) / 0.15, 0), 1)
return Color(red: 0.30 + (0.95 - 0.30) * t,
green: 0.42 + (0.25 - 0.42) * t,
blue: 1.0 + (0.20 - 1.0) * t)
}
var isWarning: Bool { ratio <= 0.20 }
func refresh() {
guard let data = try? Data(contentsOf: statusFile),
let status = try? JSONDecoder().decode(StatusData.self, from: data),
let prov = status.providers?.first, prov.error == nil, let bal = prov.balance else { return }
balance = bal
budget = prov.budget ?? 100
updated = status.updated ?? "--"
appendHistory(balance: bal)
history = loadHistory()
refreshKimi()
}
func refreshKimi() {
DispatchQueue.global(qos: .utility).async { [weak self] in
let u = fetchKimiUsage()
DispatchQueue.main.async { self?.kimi = u }
}
}
private func appendHistory(balance: Double) {
var h = loadHistory()
if let last = h.last, abs(last.balance - balance) < 0.01 { return }
h.append(BalancePoint(t: Date().timeIntervalSince1970, balance: balance))
if h.count > 1000 { h = Array(h.suffix(1000)) }
if let data = try? JSONEncoder().encode(h) {
try? data.write(to: historyFile, options: .atomic)
}
}
private func loadHistory() -> [BalancePoint] {
guard let data = try? Data(contentsOf: historyFile),
let h = try? JSONDecoder().decode([BalancePoint].self, from: data) else { return [] }
return h
}
/// >¥1
struct Recharge {
let date: String
let amount: Double
let prevDays: Double?
}
var recharges: [Recharge] {
var events: [(t: TimeInterval, amount: Double, prevT: TimeInterval?)] = []
for i in 1..<history.count {
let delta = history[i].balance - history[i - 1].balance
if delta > 1.0 {
events.append((history[i].t, delta, events.last?.t))
}
}
let fmt = DateFormatter()
fmt.dateFormat = "MM-dd"
return events.reversed().prefix(5).map { e in
Recharge(date: fmt.string(from: Date(timeIntervalSince1970: e.t)),
amount: e.amount,
prevDays: e.prevT.map { (e.t - $0) / 86400 })
}
}
var dailyBurn: Double? {
guard let newest = history.last else { return nil }
let cutoff = newest.t - 86400
let oldest = history.last(where: { $0.t <= cutoff }) ?? history.first!
let burn = oldest.balance - newest.balance
return burn > 0.01 ? burn : nil
}
}
//
// MARK: -
//
final class DeepSeekBalancePlugin: NSObject, MioPlugin {
var id: String { "deepseek-balance" }
var name: String { "DeepSeek 余额" }
var icon: String { "yensign.circle.fill" }
var version: String { "1.0.0" }
private let store = BalanceStore()
private var timer: Timer?
func activate() {
store.refresh()
timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.store.refresh()
}
RunLoop.main.add(timer!, forMode: .common)
}
func deactivate() {
timer?.invalidate()
timer = nil
}
///
func makeView() -> NSView {
NSHostingView(rootView: BalanceDetailView(store: store))
}
/// ¥ +
@objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
guard slot == "header" else { return nil }
return NSHostingView(rootView: BalanceHeaderView(store: store))
}
}
//
// MARK: - SwiftUI
//
/// ¥ +
private struct BalanceHeaderView: View {
@ObservedObject var store: BalanceStore
@State private var pulsing = false
var body: some View {
HStack(spacing: 4) {
Text("¥")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.white)
.frame(width: 14, height: 14)
.background(Circle().fill(store.accent))
.opacity(store.isWarning ? (pulsing ? 1.0 : 0.4) : 1.0)
Text(String(format: "%.0f", store.balance))
.font(.system(size: 12, weight: .semibold).monospacedDigit())
.foregroundColor(store.isWarning ? Color(red: 0.95, green: 0.25, blue: 0.2) : .white)
if store.kimi.available {
Text("K")
.font(.system(size: 9, weight: .bold))
.foregroundColor(.white)
.frame(width: 14, height: 14)
.background(Circle().fill(Color(red: 0.48, green: 0.36, blue: 1.0)))
.padding(.leading, 4)
Text(String(format: "%.0f%%", store.kimi.remainingRatio * 100))
.font(.system(size: 12, weight: .semibold).monospacedDigit())
.foregroundColor(.white)
}
}
.padding(.horizontal, 6)
.padding(.vertical, 2)
.onAppear {
if store.isWarning {
withAnimation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true)) {
pulsing = true
}
}
}
}
}
///
private struct BalanceDetailView: View {
@ObservedObject var store: BalanceStore
var body: some View {
VStack(alignment: .leading, spacing: 18) {
// +
VStack(alignment: .leading, spacing: 8) {
HStack(alignment: .firstTextBaseline, spacing: 6) {
Text("¥")
.font(.system(size: 22, weight: .bold))
.foregroundColor(store.accent)
Text(String(format: "%.2f", store.balance))
.font(.system(size: 40, weight: .bold).monospacedDigit())
.foregroundColor(.white)
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.white.opacity(0.12))
.frame(height: 6)
RoundedRectangle(cornerRadius: 3)
.fill(store.accent)
.frame(width: geo.size.width * min(max(store.ratio, 0), 1), height: 6)
}
}
.frame(height: 6)
HStack {
Text(String(format: "剩余 %.0f%%", store.ratio * 100))
Spacer()
Text("更新于 \(store.updated)")
}
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.5))
if store.isWarning {
Text("⚠️ 余额告急,记得充值")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(Color(red: 0.95, green: 0.25, blue: 0.2))
}
}
// Kimi Code
if store.kimi.available {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Kimi Code 用量")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.9))
Spacer()
Text(store.kimi.membership.replacingOccurrences(of: "LEVEL_", with: ""))
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.4))
}
GeometryReader { geo in
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 3)
.fill(Color.white.opacity(0.12))
.frame(height: 5)
RoundedRectangle(cornerRadius: 3)
.fill(Color(red: 0.48, green: 0.36, blue: 1.0))
.frame(width: geo.size.width * min(max(store.kimi.remainingRatio, 0), 1), height: 5)
}
}
.frame(height: 5)
Text("本周配额 \(store.kimi.used)/\(store.kimi.limit),剩余 \(store.kimi.remaining)")
.font(.system(size: 11).monospacedDigit())
.foregroundColor(.white.opacity(0.55))
}
}
//
if let burn = store.dailyBurn {
Label(String(format: "近 24h 消耗 ¥%.2f", burn), systemImage: "chart.line.downtrend.xyaxis")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.75))
}
//
VStack(alignment: .leading, spacing: 8) {
Text("充值明细(快照推算)")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.white.opacity(0.9))
if store.recharges.isEmpty {
Text("暂无充值记录,历史数据积累中……")
.font(.system(size: 12))
.foregroundColor(.white.opacity(0.45))
} else {
ForEach(Array(store.recharges.enumerated()), id: \.offset) { _, r in
HStack {
Text(r.date)
.foregroundColor(.white.opacity(0.5))
Text(String(format: "+¥%.2f", r.amount))
.foregroundColor(.white)
Spacer()
if let d = r.prevDays {
Text(String(format: "上一笔用了 %.0f 天", d))
.foregroundColor(.white.opacity(0.45))
}
}
.font(.system(size: 12).monospacedDigit())
}
}
}
Spacer()
}
.padding(24)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
}
}