// // DeepSeekBalancePlugin.swift // MioIsland 插件:DeepSeek 余额(刘海岛头部常驻 + 点开明细) // // 数据:~/.config/token-bar/status.json(token_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/usages,OAuth 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.. 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) } }