mirror of
https://github.com/kitsiu1211-stack/mio-deepseek-balance.git
synced 2026-08-10 02:54:32 +00:00
feat: MioIsland DeepSeek balance plugin with Kimi Code usage
This commit is contained in:
commit
70a1c6ea64
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
52
AGENTS.md
Normal file
52
AGENTS.md
Normal 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/
|
||||||
|
# 然后重启 MioIsland(pkill -f "Mio Island" && open -a "Mio Island")
|
||||||
|
```
|
||||||
|
|
||||||
|
- 只编译 arm64(Apple 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
24
Info.plist
Normal 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
30
README.md
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
# MioIsland DeepSeek Balance Plugin
|
||||||
|
|
||||||
|
MioIsland(刘海岛)插件:在 MacBook 刘海头部常驻显示 **DeepSeek 余额**和 **Kimi Code 用量**,点开查看明细。
|
||||||
|
|
||||||
|
  
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
- **刘海头部常驻**:`¥` 徽章 + 余额数字(随余额比例蓝→红渐变,≤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
|
||||||
360
Sources/DeepSeekBalancePlugin.swift
Normal file
360
Sources/DeepSeekBalancePlugin.swift
Normal file
@ -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..<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
44
Sources/MioPlugin.swift
Normal 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
72
build.sh
Executable 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
|
||||||
Loading…
Reference in New Issue
Block a user