feat: MioIsland DeepSeek balance plugin with Kimi Code usage

This commit is contained in:
kitsiu1211-stack 2026-07-29 14:17:06 +08:00
commit 70a1c6ea64
7 changed files with 584 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
build/
.DS_Store

52
AGENTS.md Normal file
View File

@ -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/
# 然后重启 MioIslandpkill -f "Mio Island" && open -a "Mio Island"
```
- 只编译 arm64Apple Silicon
- `id`kebab-case必须与 `Info.plist``CFBundleIdentifier` 后缀(`com.mioisland.plugin.<id>`)、`build.sh` 的 `PLUGIN_NAME`、输出 bundle 文件名完全一致。
- `NSPrincipalClass` 格式:`<ModuleName>.<ClassName>`。
## 插件协议(不要改 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。

24
Info.plist Normal file
View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>DeepSeekBalance</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.deepseek-balance</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>DeepSeek Balance</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>1.0.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSPrincipalClass</key>
<string>DeepSeekBalance.DeepSeekBalancePlugin</string>
</dict>
</plist>

30
README.md Normal file
View File

@ -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

View File

@ -0,0 +1,360 @@
//
// 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)
}
}

44
Sources/MioPlugin.swift Normal file
View File

@ -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?
}

72
build.sh Executable file
View File

@ -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