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

361 lines
14 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// 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)
}
}