commit 70a1c6ea64fb44733c0e45a6c3c43ca72515e6ce Author: kitsiu1211-stack Date: Wed Jul 29 14:17:06 2026 +0800 feat: MioIsland DeepSeek balance plugin with Kimi Code usage diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..880ac59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +build/ +.DS_Store diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d2e4ff8 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md — MioIsland 插件开发指南 + +> 本目录是一个 MioIsland(刘海岛)插件项目。动手前请先读完本文件。 + +## 背景 + +- **MioIsland**(`/Applications/Mio Island.app`,开源:github.com/MioMioOS/MioIsland)把 MacBook 刘海变成"灵动岛"。插件以 `.bundle` 形式在运行时被宿主加载。 +- 本机有三个自研插件项目(结构相同,本文件会同步到各自目录): + - `~/mio-deepseek-balance` — DeepSeek 余额(id: `deepseek-balance`) + - `~/mio-runcat` — CPU 小猫跑速(id: `runcat`) + - `~/mio-agent-status` — Kimi Code / TRAE 状态点(id: `agent-status`) + +## 构建与安装 + +```bash +./build.sh install # 编译 + ad-hoc 签名 + 装入 ~/.config/codeisland/plugins/ +# 然后重启 MioIsland(pkill -f "Mio Island" && open -a "Mio Island") +``` + +- 只编译 arm64(Apple Silicon)。 +- `id`(kebab-case)必须与 `Info.plist` 的 `CFBundleIdentifier` 后缀(`com.mioisland.plugin.`)、`build.sh` 的 `PLUGIN_NAME`、输出 bundle 文件名完全一致。 +- `NSPrincipalClass` 格式:`.`。 + +## 插件协议(不要改 selector) + +`Sources/MioPlugin.swift` 是宿主协议的逐字拷贝,宿主通过 `responds(to:)` + `perform(_:)` 匹配,**改任何 selector 都会导致静默加载失败**: + +- 必备:`id / name / icon(SF Symbol) / version / activate() / deactivate() / makeView() -> NSView` +- 可选:`viewForSlot("header", context:) -> NSView?` —— 返回刘海头部常驻小视图(约 20×20 起,宽度可自适应),这是"常驻显示"的关键入口 +- 视图一律用 `NSHostingView(rootView:)` 包 SwiftUI + +## 数据源与约定 + +- **DeepSeek 余额**:读 `~/.config/token-bar/status.json`(由 `~/token-bar/token_bar.py` 每 30 分钟抓取写入)。**DeepSeek 没有公开账单 API**(billing/transactions/usage 均 404),充值/消耗明细用 `~/.config/token-bar/balance_history.json` 快照差分推算(余额上涨 >¥1 记为充值)。 +- **RunCat**:猫帧渲染器与速度曲线来自 `~/CodeM/playgrounds/default/RunCat/`(`CatArt.swift` + `Theme.swift` + `Metrics.swift` 拷贝进插件 Sources)。速度 = 0.4 + 3.6×(CPU/100)^0.6 循环/秒,EMA α=0.3,相位驱动。 +- **Agent 状态**:Kimi Code 用 `~/.kimi-code/sessions/**/wire.jsonl` 最新 mtime 判定;TRAE 用 `ps` 统计 TRAE SOLO CN 进程 CPU。 + +## Kimi Code → MioIsland 事件桥(会话状态上岛) + +- 桥接脚本:`~/.kimi-code/hooks/mio-island.py`,把 Kimi Code hook 事件翻译成 MioIsland 原生协议(与 Claude Code 的 `codeisland-state.py` 同格式),发 Unix socket `/tmp/codeisland.sock`。 +- 已在 `~/.kimi-code/config.toml` 注册 15 个 `[[hooks]]`(UserPromptSubmit/PreToolUse/PostToolUse/Stop/SessionEnd 等)。 +- **限制**:Kimi 的 `PermissionRequest` 是只读事件,岛上只能显示"等待审批",不能代替终端批准。改 hook 配置后只对新会话生效。 + +## MioIsland 设置 + +- 偏好:`~/Library/Preferences/com.codeisland.app.plist` 的 `notchCustomization.v1`(JSON 字符串),含 `defaultGeometry.notchHeight`(岛的下探高度,当前 48)、`horizontalOffset`、`maxWidth`。改完需重启 MioIsland。 +- 已 pin 的插件列表在同 plist 的 `PinnedPluginIds`。 + +## 安全注意 + +- `~/.config/token-bar/config.json` 含 API Key,不要打印/外发。 +- 插件在宿主进程内运行,无沙箱——谨慎引入外部依赖,保持零依赖纯 Swift/SwiftUI。 diff --git a/Info.plist b/Info.plist new file mode 100644 index 0000000..7c58713 --- /dev/null +++ b/Info.plist @@ -0,0 +1,24 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + DeepSeekBalance + CFBundleIdentifier + com.mioisland.plugin.deepseek-balance + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + DeepSeek Balance + CFBundlePackageType + BNDL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + NSPrincipalClass + DeepSeekBalance.DeepSeekBalancePlugin + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..ebe6512 --- /dev/null +++ b/README.md @@ -0,0 +1,30 @@ +# MioIsland DeepSeek Balance Plugin + +MioIsland(刘海岛)插件:在 MacBook 刘海头部常驻显示 **DeepSeek 余额**和 **Kimi Code 用量**,点开查看明细。 + +![MioIsland](https://img.shields.io/badge/MioIsland-plugin-blue) ![macOS](https://img.shields.io/badge/macOS-15%2B-black) ![Swift](https://img.shields.io/badge/Swift-6.0-orange) + +## 功能 + +- **刘海头部常驻**:`¥` 徽章 + 余额数字(随余额比例蓝→红渐变,≤20% 红色脉冲预警);`K` 徽章 + Kimi Code 配额剩余百分比 +- **点开明细面板**: + - DeepSeek:余额大数字 + 进度条 + 充值明细(本地快照推算)+ 近 24h 消耗 + - Kimi Code:会员等级 + 本周配额用量条(used/limit/remaining) + +## 数据源 + +- **DeepSeek 余额**:`~/.config/token-bar/status.json`(由 [token_bar.py](https://github.com/kitsiu1211-stack) 每 30 分钟抓取 DeepSeek API 写入)。DeepSeek 无公开账单 API,充值/消耗明细用 `~/.config/token-bar/balance_history.json` 快照差分推算(余额上涨 >¥1 记为充值) +- **Kimi Code 用量**:`GET https://api.kimi.com/coding/v1/usages`(OAuth token 读自 Kimi Code CLI 凭证 `~/.kimi-code/credentials/kimi-code.json`) + +## 构建与安装 + +```bash +./build.sh install # 编译 + ad-hoc 签名 + 装入 ~/.config/codeisland/plugins/ +# 重启 MioIsland 生效 +``` + +arm64 only。插件协议见 [AGENTS.md](AGENTS.md)。 + +## License + +MIT diff --git a/Sources/DeepSeekBalancePlugin.swift b/Sources/DeepSeekBalancePlugin.swift new file mode 100644 index 0000000..a65ff3d --- /dev/null +++ b/Sources/DeepSeekBalancePlugin.swift @@ -0,0 +1,360 @@ +// +// 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) + } +} diff --git a/Sources/MioPlugin.swift b/Sources/MioPlugin.swift new file mode 100644 index 0000000..053e65f --- /dev/null +++ b/Sources/MioPlugin.swift @@ -0,0 +1,44 @@ +// +// MioPlugin.swift +// MioIsland Plugin SDK +// +// Standalone copy of the MioPlugin protocol from the host CodeIsland app. +// At runtime, @objc protocol conformance is matched by selector signatures +// (not module identity), so this duplicate works for .bundle plugins +// loaded by NativePluginManager. +// +// DO NOT change the selectors or the @objc-ness — the host walks them +// via responds(to:) / perform(_:) and any divergence will silently break +// loading. +// + +import AppKit + +@objc protocol MioPlugin: AnyObject { + /// Stable identifier — used as the dedupe key. Lowercase, kebab-case. + var id: String { get } + + /// Human-readable name shown in the Plugins settings page. + var name: String { get } + + /// SF Symbol name for the icon shown in the notch header bar. + var icon: String { get } + + /// Semver version. Must increase across releases (the marketplace enforces this). + var version: String { get } + + /// Called once after the plugin is loaded. Use this to start any background + /// work (timers, observers, etc.). Runs on the main actor. + func activate() + + /// Called when the plugin is unloaded (or the app is quitting). Stop everything. + func deactivate() + + /// The main view shown when the user taps the plugin icon in the notch header. + /// Wrap your SwiftUI view in NSHostingView. + func makeView() -> NSView + + /// Optional: contribute small views into named UI slots in the host app. + /// Slots: "header" (20x20 button next to other plugin icons), "footer", etc. + @objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? +} diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..949b511 --- /dev/null +++ b/build.sh @@ -0,0 +1,72 @@ +#!/bin/bash +# Build the example plugin as a .bundle for MioIsland. +# +# Usage: +# ./build.sh # build to ./build/my-plugin.bundle +# ./build.sh install # also install to ~/.config/codeisland/plugins/ +# +# After publishing your plugin, the user-facing flow is: +# 1. Marketplace generates a download URL +# 2. User pastes URL into MioIsland's Plugins settings +# 3. MioIsland downloads + extracts + loads the .bundle automatically +set -e + +# ─── Edit these two lines to match your plugin ─────────────────────── +PLUGIN_NAME="deepseek-balance" # kebab-case, must match CFBundleIdentifier suffix +MODULE_NAME="DeepSeekBalance" # PascalCase, must match Info.plist NSPrincipalClass prefix +# ───────────────────────────────────────────────────────────────────── + +BUNDLE_NAME="${PLUGIN_NAME}.bundle" +BUILD_DIR="build" +SOURCES="Sources/*.swift" + +echo "Building ${PLUGIN_NAME} plugin..." + +# Clean +rm -rf "${BUILD_DIR}" +mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS" + +# Compile to dynamic library. arm64-only — most users are on Apple Silicon. +# Add x86_64 with `-target x86_64-apple-macos15.0` if you need Intel support +# and use `lipo` to fuse the two binaries. +swiftc \ + -emit-library \ + -module-name "${MODULE_NAME}" \ + -target arm64-apple-macos15.0 \ + -sdk "$(xcrun --show-sdk-path)" \ + -o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \ + ${SOURCES} + +# Copy Info.plist +cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/" + +# Ad-hoc sign — required so macOS will load the bundle. No Apple Developer +# account needed; this works for everyone. +codesign --force --sign - "${BUILD_DIR}/${BUNDLE_NAME}" + +echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}" + +# Package as .zip for marketplace upload. The marketplace expects a zip +# containing a single .bundle directory at the root. +cd "${BUILD_DIR}" +rm -f "${PLUGIN_NAME}.zip" +zip -qr "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}" +cd .. + +echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip (for marketplace upload)" +echo "" + +if [ "${1:-}" = "install" ]; then + DEST="${HOME}/.config/codeisland/plugins" + mkdir -p "${DEST}" + rm -rf "${DEST}/${BUNDLE_NAME}" + cp -r "${BUILD_DIR}/${BUNDLE_NAME}" "${DEST}/" + echo "✓ Installed to ${DEST}/${BUNDLE_NAME}" + echo " Restart MioIsland to load it." +else + echo "Install locally:" + echo " ./build.sh install" + echo "" + echo "Or upload ${BUILD_DIR}/${PLUGIN_NAME}.zip via the marketplace at" + echo " https://miomio.chat/developer" +fi