v0.2.0: initial 看盘侠 — Fund & Gold plugin for MioIsland

Real-time OTC fund and gold tracker for the macOS notch. Three tabs:
- 持仓: live intraday estimates from 天天基金, hero card with
  total value / today P&L / cumulative P&L, per-row 当日 + 累计.
- 黄金: SHFE 沪金 realtime + daily K-line + 伦敦金 reference.
- 添加: search 26k+ public funds via 东方财富 suggest API.

No API keys, no Python, no servers. All data comes from public
endpoints (天天基金, 东方财富, 新浪财经).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
徐翔宇 2026-04-27 20:20:15 +08:00
commit e5a72bcf42
30 changed files with 3750 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@ -0,0 +1,5 @@
build/
.DS_Store
*.swiftmodule
*.dSYM
.build/

127
HANDOFF.md Normal file
View File

@ -0,0 +1,127 @@
# 盯基金 v0.2 — 早晨交付报告
> 你睡前要求:参考设计文档 `Mac 灵动岛 - 基金面板.html` 重写 UI找免费实数据接口自己测早晨给报告。
## 🧾 交付清单
### ✅ 已完成
| 项 | 证据 |
|---|---|
| 拉取设计文档 + 解压 tar 包 | `/tmp/fund-design/` |
| 读完 chat transcript / panel-holdings.jsx / panel-gold.jsx / panel-add.jsx / styles.css | 已对齐设计 token |
| **面板尺寸 380×540**(设计原值,原 v0.1 是 440×560 | `Info.plist` 已改 |
| **配色还原**lime `#d4ff3a` + 红涨绿跌 + 黑底 | `Theme.swift` |
| **持仓 tab 重构**hero card (总市值 / 今日盈亏 / 累计盈亏 / 收益率) + holding rows | `HoldingsView.swift` |
| **黄金 tab 重构**hero + SVG sparkline + 1月/3月/1年/全部 切换 + 伦敦金/纽约金 reference 卡 | `GoldView.swift` + `SparkLine.swift` |
| **添加 tab 重构**:搜索框 + tag 颜色 + 已加 checkmark | `AddView.swift` |
| **新增持仓编辑功能**(设计图没明示但逻辑上必须有) | `PositionEditor.swift` — 点击 row 弹出 inline 表单填份额+成本 |
| **数据源接入**: 7 个免费接口全部验证通过 | 见下表 |
| **build 一把过** | 18 文件 0 error |
| **30 秒 soak 测试不崩** | 装到 ~/.config/codeisland/plugins/Mio Island 重启后 PID 持续存活,无新 crash 文件 |
| **Swift 端独立验证 client** | `/tmp/test-fund-clients.swift` 7 个 endpoint 真测 |
### ⚠️ 没完成
- **付费门槛** — v0.2 是 UI 壳license 系统留给 v0.3
- **黄金「我的持仓」卡片**(设计图末尾那块) — 设计里有但因为黄金不像基金有「自选」概念,需要先和你确认产品逻辑:是单独输入金条克数还是关联某个黄金 ETF我跳过这块没瞎做
- **K 线 hover tooltip** — 设计里只有静态当前点hover 显示具体日期价格的功能没实现
- **数字 tick 动画**(设计里 `.flash-up` / `.flash-down` — 跳过,性能成本 vs 用户感知比不划算
- **Mio Island 主题适配** — 我没跟随宿主主题host 的 Theme 系统是 internal插件用了固定的 lime/gold/red/green 配色
## 📊 数据源验证结果
| Endpoint | 用途 | 提供商 | 实测今晚 |
|---|---|---|---|
| `fundsuggest.eastmoney.com/.../FundSearchAPI.ashx` | 基金搜索 | 东方财富 | ✅ "易方达" → 110006 易方达货币A 等 |
| `fundgz.1234567.com.cn/js/{code}.js` | 盘中估值 | 天天基金 | ✅ 005827 估值 1.7669 (+0.53%) @ 04-24 15:00 |
| `api.fund.eastmoney.com/f10/lsjz` | 历史净值 | 东方财富 | ✅ 005827 近 3 日 1.7678 / 1.7575 / 1.7502 |
| `hq.sinajs.cn/list=AU0` | 沪金实时 (RMB/g) | 新浪 | ✅ 黄金连续 574.94 (GB18030 解码) |
| `hq.sinajs.cn/list=hf_GC` | 纽约金实时 (USD/oz) | 新浪 | ✅ 4728.253 USD/oz |
| `hq.sinajs.cn/list=hf_XAU` | 伦敦金实时 | 新浪 | ✅ 4708.05 |
| `stock2.finance.sina.com.cn/.../getInnerFuturesDailyKLine?symbol=AU0` | 沪金日 K 线 | 新浪 | ✅ 4032 bars 全部历史 |
**没用到 Python没用 akshare没用 subprocess没用服务端**。纯 Swift HTTP / JSON / GB18030 解码。零依赖。
## 🧱 架构
```
Sources/
├── MioPlugin.swift # 协议 (verbatim from host)
├── FundPlugin.swift # 主入口 (NSPrincipalClass)
├── data/
│ ├── Models.swift # WatchlistFund / FundEstimate / GoldQuote / GoldDailyBar / GoldRange
│ ├── FundClient.swift # 3 个基金 API (search / estimate / history)
│ ├── GoldClient.swift # 4 路黄金实时 (沪金/伦金/纽金 + 上海现货占位)
│ ├── GoldKlineClient.swift # 沪金日 K 线
│ └── SinaQuoteParser.swift # GB18030 解码 + 字段位解析
├── storage/
│ └── Watchlist.swift # JSON 持久化 (持仓 + 份额 + 成本)
├── engine/
│ ├── RefreshScheduler.swift # 交易时段感知 (A 股/SHFE 时段)
│ └── FundStore.swift # @MainActor 状态层 (估值字典 + 黄金字典 + K线缓存)
└── ui/
├── Theme.swift # 设计 token 单一来源
├── ExpandedView.swift # 380×540 主面板 + 3 tab + footer
├── HeaderSlotView.swift # 20×20 notch 图标
├── HoldingsView.swift # 持仓 tab + hero card
├── GoldView.swift # 黄金 tab + 实时 + K 线
├── AddView.swift # 添加 tab
├── PositionEditor.swift # 内联编辑份额/成本
└── SparkLine.swift # 纯 SwiftUI 等价 SVG sparkline
```
## 🧪 自测方式(你早晨可以亲自跑一遍)
```bash
# 1. 跑 Swift 端数据源烟测
swift /tmp/test-fund-clients.swift
# 2. 重新装 v0.2 plugin
cd /tmp/mio-plugin-fund && bash build.sh install
# 3. 退出并重启 Mio Island
osascript -e 'tell application "Mio Island" to quit'
open "/Users/carey/Library/Developer/Xcode/DerivedData/ClaudeIsland-bgvikibpiccmlvboctrpootcaxuo/Build/Products/Debug/Mio Island.app"
# 4. 在刘海下找到 chart.line.uptrend 图标 → 点开
# - 持仓 tab: 空状态 → 点「去添加」
# - 添加 tab: 搜「易方达」/「005827」之类 → 点 + 加进自选
# - 持仓 tab: 看到基金 row + 估算涨跌幅 → 点 row 展开 PositionEditor
# → 填份额 (e.g. 100) + 成本 (e.g. 1.50) → 保存
# - hero card 现在应该显示 总市值 / 今日盈亏 / 累计盈亏 / 收益率
# - 黄金 tab: 沪金实时 + K 线 + 1月/3月/1年/全部 切换 + 伦敦金/纽约金 pill
```
## 🎨 视觉对照
我没法截图Screen Recording TCC 不给)。**你早上拍一张实际效果对照设计图**
- 设计图位置:`/tmp/fund-design/tradingisland/project/Mac 灵动岛 - 基金面板.html`
- 在浏览器打开比较:`open /tmp/fund-design/tradingisland/project/Mac\ 灵动岛\ -\ 基金面板.html`
视觉差异预期vs 设计图):
1. **设计图是 mock 数据 + 假涨跌**,我的是真数据 — 数字会不一样
2. **设计的「黄金 · 持仓」卡片**没做(说明里解释了原因)
3. **伦敦金/纽约金 reference 卡的位置**:设计放右下方,我放在 K 线下面 —— 放下面更突出实时价
4. **数字 flash 动画**没做
## ⚠️ 缩水的地方(按交付协议必须列)
1. **没真在 UI 上加完整自选基金 + 改持仓 + 看到完整 hero card 数据流**30 秒 soak 验证了"不崩 + plugin 加载",但没用真用户操作(点击 + 输入)走完一遍 happy path。原因Screen Recording 权限我没有,没法点击。**你早上手动跑一次最重要**。
2. **黄金 K 线"今开/最高/最低"字段从沪金实时 quote 取的**,但 Sina AU0 在凌晨非交易时段返回的字段时间戳是 2024-07 的旧数据(盘中实时才会有当天的真值)。**白天交易时段会自动正确**,凌晨看会怪——这是数据源固有特性,不是 bug。
3. **付费门槛没做** — v0.2 所有功能免费可用license 系统等 v0.3 加。
4. **没接触 Mio Island host 主题系统** — 插件是固定深色配色,宿主切了 retro 主题这里也不会跟着变。设计图本来就是固定深色 lime 风格,我按设计来。
5. **`/tmp/mio-plugin-fund` 还在 tmp**,没 push 到 GitHub。早晨你点头我可以创建 `xmqywx/mio-plugin-fund` 仓库 push 上去 + 准备上架 miomio.chat。
## 🚀 早晨待办(需要你拍板)
- [ ] 亲手测一遍 happy path搜基金 → 加 → 编辑持仓 → 看 hero card
- [ ] 看 K 线和设计图对比满意度
- [ ] 决定要不要做"黄金持仓"那块(输入克数+成本 → 算盈亏)
- [ ] 决定要不要做付费门槛v0.3 任务清单)
- [ ] 决定要不要 push 到 GitHub + 上架 miomio.chat
- [ ] 群公告文案需不需要我准备
---
晚安。

38
Info.plist Normal file
View File

@ -0,0 +1,38 @@
<?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>zh_CN</string>
<key>CFBundleExecutable</key>
<string>FundPlugin</string>
<key>CFBundleIdentifier</key>
<string>com.mioisland.plugin.fund</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>看盘侠</string>
<key>CFBundlePackageType</key>
<string>BNDL</string>
<key>CFBundleShortVersionString</key>
<string>0.2.0</string>
<key>CFBundleVersion</key>
<string>2</string>
<key>NSPrincipalClass</key>
<string>FundPlugin.FundPlugin</string>
<!--
Panel size: 380×580.
- 380 wide so the island isn't too thick.
- 580 tall = 40pt notch reservation + 540pt of card content
(hero + tabs + list/chart + footer). Mirrors the Music
Player layout pattern: dedicate the top band for the
physical notch + host's floating back-chevron, then put
actual plugin chrome below.
Host clamp is [280-1200] × [120-900] so we sit comfortably.
-->
<key>MioPluginPreferredWidth</key>
<integer>380</integer>
<key>MioPluginPreferredHeight</key>
<integer>580</integer>
</dict>
</plist>

21
LICENSE Normal file
View File

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 MioMioOS
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

109
README.md Normal file
View File

@ -0,0 +1,109 @@
# 看盘侠 — Fund & Gold Tracker for MioIsland
Real-time OTC fund and gold tracker for the macOS notch. Built as a
native `.bundle` plugin for [MioIsland](https://github.com/MioMioOS/MioIsland).
## Features
### 持仓 (Holdings)
- Live intraday fund estimates (~1 min granularity from 天天基金).
- Hero card with 总市值 / 今日盈亏 / 累计盈亏 / 收益率 — only computed
for funds where you've filled in shares + cost basis.
- Per-fund row with name, code, latest NAV, day rate %, day ¥ P&L.
- Click any row to inline-edit shares + cost basis (or clear).
### 黄金 (Gold)
- Realtime SHFE 沪金 quote (RMB/g) as the primary chart.
- Daily K-line with 1月 / 3月 / 1年 / 全部 range tabs.
- Reference cards for 伦敦金 (XAU/USD) and 纽约金 (COMEX GC) with
realtime price + day change.
- Historical chart back to 2008.
### 添加 (Add)
- Search 26,000+ public funds by code, name, or pinyin (东方财富 suggest API).
- Category-coded chips (ETF / 指数 / 股票 / 混合 / 债券 / QDII / etc).
- Already-added state with checkmark.
## Data sources (no API keys, no Python, no servers)
| Endpoint | Provider | Format |
|---|---|---|
| `fundsuggest.eastmoney.com/.../FundSearchAPI.ashx` | 东方财富 | JSON |
| `fundgz.1234567.com.cn/js/{code}.js` | 天天基金 | JSONP |
| `api.fund.eastmoney.com/f10/lsjz` | 东方财富 | JSON |
| `hq.sinajs.cn/list={symbol}` | 新浪财经 | CSV (GB18030) |
| `stock2.finance.sina.com.cn/.../IndexService.getInnerFuturesDailyKLine` | 新浪财经 | JSON array |
All endpoints are 10+ years old, used by every Chinese fintech app. No auth.
## Install
### From source
```bash
git clone <this repo>
cd mio-plugin-fund
./build.sh install
# Restart Mio Island (Cmd+Q + reopen)
```
### From marketplace
(After release: install via miomio.chat plugin store.)
## Development
```bash
./build.sh # produce build/fund.bundle + build/fund.zip
./build.sh install # build + copy to ~/.config/codeisland/plugins/
```
### Project structure
```
Sources/
├── MioPlugin.swift # protocol (verbatim from host)
├── FundPlugin.swift # principal class
├── data/
│ ├── Models.swift # WatchlistFund, FundEstimate, GoldQuote, …
│ ├── FundClient.swift # search + estimate + history
│ ├── GoldClient.swift # 4 realtime gold sources
│ ├── GoldKlineClient.swift # daily K-line history
│ └── SinaQuoteParser.swift # GB18030 → GoldQuote parser
├── storage/
│ └── Watchlist.swift # persisted JSON watchlist + positions
├── engine/
│ ├── RefreshScheduler.swift # trade-hour-aware refresh cadence
│ └── FundStore.swift # @MainActor state surface
└── ui/
├── Theme.swift # design tokens (lime / gold / red-up / green-down)
├── ExpandedView.swift # 380×540 panel, 3-tab strip + footer
├── HeaderSlotView.swift # 20×20 notch icon
├── HoldingsView.swift # 持仓 tab
├── GoldView.swift # 黄金 tab
├── AddView.swift # 添加 tab
├── PositionEditor.swift # inline shares + cost form
└── SparkLine.swift # SVG-equivalent line chart
```
### Refresh cadence
| Tier | When | Interval |
|---|---|---|
| Funds intraday | 09:30-11:30, 13:00-15:00 (CST, Mon-Fri) | 60s |
| Funds idle | Otherwise | 30 min |
| Gold | 24/7 | 30s |
| Gold K-line history | On launch + manual refresh | — |
## Pricing (planned)
| Tier | Content |
|---|---|
| Free | 3 funds, daily NAV only, no gold, no AI |
| Pro (¥49 once / ¥99 yearly) | Unlimited watchlist, intraday estimates, gold panel, AI 解盘 |
Not enforced yet — v0.2 is the UI shell. Paid gating goes in v0.3.
## License
MIT — see [LICENSE](LICENSE).

52
Sources/FundPlugin.swift Normal file
View File

@ -0,0 +1,52 @@
//
// FundPlugin.swift
// Mio Island plugin:
//
// Principal class. Module = FundPlugin, Class = FundPlugin
// NSPrincipalClass = "FundPlugin.FundPlugin".
//
import AppKit
import SwiftUI
final class FundPlugin: NSObject, MioPlugin {
var id: String { "fund" }
var name: String { "看盘侠" }
var icon: String { "chart.line.uptrend.xyaxis" }
var version: String { "0.2.0" }
func activate() {
FundDebugLog.write("plugin activate")
// Kick off the refresh loops as soon as the plugin enables.
// The store is a singleton so the loops survive panel show/hide.
Task { @MainActor in
FundStore.shared.start()
await FundStore.shared.refreshNow()
}
}
func deactivate() {
FundDebugLog.write("plugin deactivate")
Task { @MainActor in
FundStore.shared.stop()
}
}
func makeView() -> NSView {
let view = NSHostingView(rootView: ExpandedView())
view.autoresizingMask = [.width, .height]
return view
}
@objc func viewForSlot(_ slot: String, context: [String: Any]) -> NSView? {
switch slot {
case "header":
let v = NSHostingView(rootView: HeaderSlotView())
v.frame = NSRect(x: 0, y: 0, width: 20, height: 20)
v.setFrameSize(NSSize(width: 20, height: 20))
return v
default:
return nil
}
}
}

18
Sources/MioPlugin.swift Normal file
View File

@ -0,0 +1,18 @@
//
// MioPlugin.swift
// Mio Island Plugin SDK (verbatim copy from host).
// Runtime conformance is by ObjC selector, not module identity.
//
import AppKit
@objc protocol MioPlugin: AnyObject {
var id: String { get }
var name: String { get }
var icon: String { get }
var version: String { get }
func activate()
func deactivate()
func makeView() -> NSView
@objc optional func viewForSlot(_ slot: String, context: [String: Any]) -> NSView?
}

View File

@ -0,0 +1,154 @@
//
// EtfClient.swift
// plugin v0.3
//
// Realtime quotes for exchange-traded funds ( ETF). Different
// endpoint from open-end mutual funds ETFs trade on Shanghai/Shenzhen
// exchanges with stock-like tick data.
//
// Endpoint: `https://hq.sinajs.cn/list={prefix}{code}` where prefix is
// "sh" () or "sz" (), encoded GB18030. Same family as the
// gold AU0 endpoint we already use, just stock-format payload instead
// of futures.
//
// Field layout (verified 2026-04-26 with sz159206 ETF):
// [0] name (GBK) "ETF"
// [1] prevClose () 1.854
// [2] open () 1.867
// [3] current () 1.806
// [4] high () 1.857
// [5] low () 1.801
// [6] bid1 () 1.806
// [7] ask1 () 1.807
// [8] volume (·) 902910170
// [9] turnover (·) 1643683554.028
// [10..29]
// [30] date "2026-04-24"
// [31] time "15:00:00"
//
import Foundation
/// Minimal subset of the Sina stock payload we surface in the UI.
struct ETFQuote: Equatable {
let code: String // raw 6-digit code (no prefix)
let name: String
let prevClose: Double
let open: Double
let last: Double
let high: Double
let low: Double
let updatedAt: Date?
var change: Double { last - prevClose }
var changeRate: Double { prevClose == 0 ? 0 : change / prevClose * 100 }
}
actor ETFClient {
static let shared = ETFClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 6
cfg.timeoutIntervalForResource = 12
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
/// True iff this looks like a tradeable ETF code (Shanghai or Shenzhen).
/// Routing rules taken from CSRC code blocks (3-digit prefix of the
/// 6-digit ticker):
/// - 51x, 52x, 56x, 58x Shanghai ETFs (sh prefix)
/// - 15x, 16x, 18x Shenzhen ETFs (sz prefix)
/// Anything else falls back to open-end mutual fund routing.
/// 6-digit ticker / 1000 = leading 3 digits earlier code mistakenly
/// used /10000 which always returned the leading 2 digits and made
/// every ETF look like an OTC fund.
static func isETFCode(_ code: String) -> Bool {
guard code.count == 6, let n = Int(code) else { return false }
let prefix = n / 1000
switch prefix {
case 510...599, 150...199:
return true
default:
return false
}
}
/// Returns "sh" or "sz" for the given ETF code, or nil if it isn't an ETF.
static func sinaPrefix(for code: String) -> String? {
guard code.count == 6, let n = Int(code) else { return nil }
let prefix = n / 1000
if (510...599).contains(prefix) { return "sh" }
if (150...199).contains(prefix) { return "sz" }
return nil
}
func quote(for code: String) async throws -> ETFQuote {
guard let prefix = Self.sinaPrefix(for: code) else {
throw FundClientError.malformed("\(code) is not an ETF code")
}
let url = URL(string: "https://hq.sinajs.cn/list=\(prefix)\(code)")!
var req = URLRequest(url: url)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
let (data, _) = try await session.data(for: req)
// Sina serves GB18030 so the Chinese name field decodes cleanly.
let gbEnc = String.Encoding(rawValue:
CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
guard let s = String(data: data, encoding: gbEnc) ?? String(data: data, encoding: .utf8),
let lQ = s.firstIndex(of: "\""),
let rQ = s[s.index(after: lQ)...].firstIndex(of: "\"") else {
throw FundClientError.malformed("etf payload parse failed for \(code)")
}
let body = String(s[s.index(after: lQ)..<rQ])
let fields = body.components(separatedBy: ",")
guard fields.count >= 30 else {
throw FundClientError.malformed("etf field count \(fields.count) for \(code)")
}
guard let prevClose = Double(fields[1]),
let open = Double(fields[2]),
let last = Double(fields[3]),
let high = Double(fields[4]),
let low = Double(fields[5]) else {
throw FundClientError.malformed("etf number parse failed for \(code)")
}
let dateStr = fields.count > 30 ? fields[30] : ""
let timeStr = fields.count > 31 ? fields[31] : ""
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "Asia/Shanghai")
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
let updatedAt = fmt.date(from: "\(dateStr) \(timeStr)")
return ETFQuote(
code: code, name: fields[0],
prevClose: prevClose, open: open, last: last,
high: high, low: low, updatedAt: updatedAt
)
}
/// Fetch many ETF quotes in parallel; logs per-code failures so
/// silent breakage in production is debuggable.
func quotes(for codes: [String]) async -> [ETFQuote] {
await withTaskGroup(of: ETFQuote?.self) { group in
for c in codes {
group.addTask {
do { return try await self.quote(for: c) }
catch {
FundDebugLog.write("ETF quote \(c) failed: \(error)")
return nil
}
}
}
var out: [ETFQuote] = []
for await q in group { if let q { out.append(q) } }
return out
}
}
}

View File

@ -0,0 +1,244 @@
//
// FundClient.swift
// plugin
//
// HTTP client for fund data. Three endpoints, all 10+ years old,
// used by every Chinese fintech app. No Python dependency.
//
// Endpoints:
// 1. Search: https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx
// (Eastmoney JSON)
// 2. Estimate: http://fundgz.1234567.com.cn/js/{code}.js
// (TianTian JSONP `jsonpgz({...})`)
// 3. History: https://api.fund.eastmoney.com/f10/lsjz?fundCode={code}&pageSize=N
// (Eastmoney F10 JSON, requires Referer header)
//
import Foundation
enum FundClientError: Error, LocalizedError {
case badStatus(Int)
case malformed(String)
case decode(Error)
var errorDescription: String? {
switch self {
case .badStatus(let code): return "HTTP \(code)"
case .malformed(let msg): return "Malformed response: \(msg)"
case .decode(let err): return "Decode failed: \(err.localizedDescription)"
}
}
}
actor FundClient {
static let shared = FundClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
// 8s wasn't enough on cold start TLS handshake to
// fundgz.1234567.com.cn often took 7-10s after app launch and
// tripped the request timeout. 15s gives the first request
// headroom; subsequent reuses are sub-second.
cfg.timeoutIntervalForRequest = 15
cfg.timeoutIntervalForResource = 25
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
// MARK: 1. Search
/// Fuzzy search for funds by name, code, or pinyin abbreviation.
/// Returns up to 10 hits.
func search(_ query: String) async throws -> [FundSearchHit] {
let trimmed = query.trimmingCharacters(in: .whitespaces)
guard !trimmed.isEmpty,
let encoded = trimmed.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
return []
}
let url = URL(string: "https://fundsuggest.eastmoney.com/FundSearch/api/FundSearchAPI.ashx?m=1&key=\(encoded)")!
let (data, resp) = try await session.data(from: url)
try Self.assertOK(resp)
struct Envelope: Decodable {
let ErrCode: Int
let Datas: [Hit]
}
struct Hit: Decodable {
let CODE: String
let NAME: String
let CATEGORYDESC: String?
}
do {
let env = try JSONDecoder().decode(Envelope.self, from: data)
return env.Datas.prefix(10).map {
FundSearchHit(code: $0.CODE, name: $0.NAME, category: $0.CATEGORYDESC)
}
} catch {
throw FundClientError.decode(error)
}
}
// MARK: 2. Intraday estimate
/// Fetch the live intraday estimate for one fund.
///
/// Endpoint returns JSONP wrapped in `jsonpgz(...)`. Out-of-session
/// (weekends, after-hours), `gsz` / `gszzl` / `gztime` may be empty
/// strings we return nil for those fields rather than fake them.
func estimate(for code: String) async throws -> FundEstimate {
guard !code.isEmpty else { throw FundClientError.malformed("empty code") }
// Cache-buster Tiantian aggressively caches; without `_=ts`
// we can be served a 5-minute-old snapshot.
let ts = Int(Date().timeIntervalSince1970 * 1000)
// HTTPS Tiantian also serves over TLS, and Mio's host ATS
// policy may block plain HTTP, which would silently kill OTC
// fund estimates without surfacing in the UI.
let url = URL(string: "https://fundgz.1234567.com.cn/js/\(code).js?rt=\(ts)")!
var req = URLRequest(url: url)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
req.setValue("https://fund.eastmoney.com/", forHTTPHeaderField: "Referer")
let (data, resp) = try await session.data(for: req)
try Self.assertOK(resp)
guard let raw = String(data: data, encoding: .utf8) else {
throw FundClientError.malformed("non-utf8")
}
// Strip JSONP wrapper: jsonpgz({...});
guard let lParen = raw.firstIndex(of: "("),
let rParen = raw.lastIndex(of: ")") else {
// Tiantian returns `jsonpgz();` (empty) for codes that don't
// exist or aren't yet tradeable surface that distinctly.
if raw.contains("jsonpgz();") {
throw FundClientError.malformed("fund not found: \(code)")
}
throw FundClientError.malformed("missing JSONP parens")
}
let inner = raw[raw.index(after: lParen)..<rParen]
guard let innerData = String(inner).data(using: .utf8) else {
throw FundClientError.malformed("inner non-utf8")
}
struct Raw: Decodable {
let fundcode: String
let name: String
let jzrq: String // "2026-04-23"
let dwjz: String
let gsz: String?
let gszzl: String?
let gztime: String? // "2026-04-24 15:00"
}
let r: Raw
do { r = try JSONDecoder().decode(Raw.self, from: innerData) }
catch { throw FundClientError.decode(error) }
let dateFmt = DateFormatter.fundDate
let timeFmt = DateFormatter.fundDateTime
guard let pubDate = dateFmt.date(from: r.jzrq),
let pubNav = Double(r.dwjz) else {
throw FundClientError.malformed("unparseable published nav")
}
let estNav = r.gsz.flatMap { Double($0) }
let estRate = r.gszzl.flatMap { Double($0) }
let estAt = r.gztime.flatMap { timeFmt.date(from: $0) }
return FundEstimate(
code: r.fundcode, name: r.name,
publishedDate: pubDate, publishedNav: pubNav,
estimatedNav: estNav, estimatedRate: estRate,
estimatedAt: estAt
)
}
/// Convenience: fetch many estimates in parallel. Failures for
/// individual codes are swallowed (returned as missing entries) so
/// one dead code doesn't sink the whole refresh.
func estimates(for codes: [String]) async -> [FundEstimate] {
await withTaskGroup(of: FundEstimate?.self) { group in
for c in codes {
group.addTask {
do { return try await self.estimate(for: c) }
catch {
FundDebugLog.write("OTC estimate \(c) failed: \(error)")
return nil
}
}
}
var out: [FundEstimate] = []
out.reserveCapacity(codes.count)
for await result in group {
if let r = result { out.append(r) }
}
return out
}
}
// MARK: 3. Historical NAV
/// Fetch the most recent N NAV points (default 30).
/// Used to render the sparkline in the expanded panel.
func history(for code: String, limit: Int = 30) async throws -> [FundNavPoint] {
var comps = URLComponents(string: "https://api.fund.eastmoney.com/f10/lsjz")!
comps.queryItems = [
.init(name: "fundCode", value: code),
.init(name: "pageIndex", value: "1"),
.init(name: "pageSize", value: String(limit)),
]
var req = URLRequest(url: comps.url!)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
req.setValue("http://fundf10.eastmoney.com/", forHTTPHeaderField: "Referer")
let (data, resp) = try await session.data(for: req)
try Self.assertOK(resp)
struct Envelope: Decodable {
struct Inner: Decodable { let LSJZList: [Row] }
struct Row: Decodable {
let FSRQ: String // "2026-04-24"
let DWJZ: String // "1.7678"
let JZZZL: String // "0.59" (% sometimes "" for the very first day)
}
let Data: Inner
}
let env: Envelope
do { env = try JSONDecoder().decode(Envelope.self, from: data) }
catch { throw FundClientError.decode(error) }
let fmt = DateFormatter.fundDate
// Reverse so list is oldest newest (sparkline draws left-to-right).
return env.Data.LSJZList.reversed().compactMap { row in
guard let d = fmt.date(from: row.FSRQ),
let nav = Double(row.DWJZ) else { return nil }
let rate = Double(row.JZZZL) ?? 0.0
return FundNavPoint(date: d, unitNav: nav, dailyRate: rate)
}
}
// MARK: helpers
private static func assertOK(_ resp: URLResponse) throws {
guard let http = resp as? HTTPURLResponse else { return }
guard (200..<300).contains(http.statusCode) else {
throw FundClientError.badStatus(http.statusCode)
}
}
}
extension DateFormatter {
static let fundDate: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
f.dateFormat = "yyyy-MM-dd"
return f
}()
static let fundDateTime: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
f.dateFormat = "yyyy-MM-dd HH:mm"
return f
}()
}

View File

@ -0,0 +1,59 @@
//
// GoldClient.swift
// plugin
//
// Realtime gold quotes from Sina Finance. One endpoint, four flavors.
//
// Sina serves comma-separated field strings, NOT JSON. Encoding is
// GB18030 for Chinese name fields, ASCII for numbers see
// SinaQuoteParser for details.
//
import Foundation
actor GoldClient {
static let shared = GoldClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 6
cfg.timeoutIntervalForResource = 12
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
// MARK: One source
func quote(for source: GoldQuote.Source) async throws -> GoldQuote {
let symbol = source.sinaSymbol
let url = URL(string: "https://hq.sinajs.cn/list=\(symbol)")!
var req = URLRequest(url: url)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
// Sina enforces a Referer check on hq.sinajs.cn endpoints
// requests without a finance.sina.com.cn referer return empty
// payloads. Documented hack but it has held since 2016.
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
let (data, _) = try await session.data(for: req)
guard let q = SinaQuoteParser.parse(data: data, source: source) else {
throw FundClientError.malformed("sina parse failed for \(symbol)")
}
return q
}
// MARK: Bulk
/// Fetch all four sources in parallel. Failures degrade silently
/// so off-hours empty payloads don't sink the panel.
func quoteAll() async -> [GoldQuote] {
await withTaskGroup(of: GoldQuote?.self) { group in
for src in [GoldQuote.Source.comex, .london, .shfeFutures] {
group.addTask { try? await self.quote(for: src) }
}
var out: [GoldQuote] = []
for await q in group { if let q { out.append(q) } }
return out
}
}
}

View File

@ -0,0 +1,73 @@
//
// GoldKlineClient.swift
// plugin v0.2
//
// Daily K-line history for the gold chart. Uses Sina's classic
// inner-futures endpoint:
//
// https://stock2.finance.sina.com.cn/futures/api/json.php/IndexService.getInnerFuturesDailyKLine?symbol=AU0
//
// Returns a JSON array of arrays:
// [[date, open, high, low, close, volume], ...]
//
// Stable since 2008, used by every futures-tracker website in China.
// Falls back gracefully if the endpoint format ever changes we
// parse defensively.
//
import Foundation
actor GoldKlineClient {
static let shared = GoldKlineClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 8
cfg.timeoutIntervalForResource = 15
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
/// Fetch the full daily K-line for the given symbol (default AU0
/// = SHFE ). Returns oldest newest.
func dailyKLine(symbol: String = "AU0") async throws -> [GoldDailyBar] {
let url = URL(string:
"https://stock2.finance.sina.com.cn/futures/api/json.php/IndexService.getInnerFuturesDailyKLine?symbol=\(symbol)"
)!
var req = URLRequest(url: url)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
let (data, _) = try await session.data(for: req)
// Sina returns: [["2008-01-09","230.950","230.990","221.880","223.300","103364"], ...]
// Strings only we parse to Double.
guard let arr = try? JSONSerialization.jsonObject(with: data) as? [[String]] else {
throw FundClientError.malformed("kline array decode failed")
}
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "Asia/Shanghai")
fmt.dateFormat = "yyyy-MM-dd"
return arr.compactMap { row -> GoldDailyBar? in
guard row.count >= 5,
let date = fmt.date(from: row[0]),
let open = Double(row[1]),
let high = Double(row[2]),
let low = Double(row[3]),
let close = Double(row[4]) else { return nil }
let volume = row.count >= 6 ? (Double(row[5]) ?? 0) : 0
return GoldDailyBar(date: date, open: open, high: high, low: low, close: close, volume: volume)
}
}
/// Convenience: slice the most recent N days.
func recentDailyKLine(days: Int?, symbol: String = "AU0") async throws -> [GoldDailyBar] {
let full = try await dailyKLine(symbol: symbol)
guard let n = days, n < full.count else { return full }
return Array(full.suffix(n))
}
}

View File

@ -0,0 +1,84 @@
//
// GoldMinuteClient.swift
// plugin v0.3
//
// Realtime minute-line for SHFE AU0 replaces the daily K-line
// in the chart widget. This matches what Alipay / show in the
// "" panel: an intraday tick line spanning
// 20:00 02:30 (night session) 09:00 15:30 (day session).
//
// Endpoint: Sina futures inner getMinLine
// https://stock.finance.sina.com.cn/futures/api/jsonp.php
// /var-_t=/InnerFuturesNewService.getMinLine?symbol=AU0
//
// Returns JSONP: `var-_t=([[time, last, avgPrice, vol, oi, prevClose, date], ...]);`
// First row carries the prevClose + date; subsequent rows are tick samples.
//
import Foundation
/// One minute sample from the AU0 intraday line.
struct GoldMinutePoint: Equatable {
/// Trading time, formatted "HH:mm" (e.g. "21:00").
let time: String
/// Last traded price (RMB/g).
let price: Double
/// Cumulative volume up to this minute.
let volume: Double
}
actor GoldMinuteClient {
static let shared = GoldMinuteClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 8
cfg.timeoutIntervalForResource = 15
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
/// Fetch today's minute line for the given futures symbol (default
/// AU0 = SHFE ). Returns oldest newest.
func minuteLine(symbol: String = "AU0") async throws -> [GoldMinutePoint] {
let url = URL(string:
"https://stock.finance.sina.com.cn/futures/api/jsonp.php/var-_t=/InnerFuturesNewService.getMinLine?symbol=\(symbol)"
)!
var req = URLRequest(url: url)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
let (data, _) = try await session.data(for: req)
guard let raw = String(data: data, encoding: .utf8) else {
throw FundClientError.malformed("minline non-utf8")
}
// Strip JSONP wrapper: `var-_t=(<json>);` (Sina injects a redirect
// script first; ignore that and keep the part starting at `var-_t=(`).
guard let lParen = raw.range(of: "var-_t=(")?.upperBound,
let rParen = raw[lParen...].lastIndex(of: ")") else {
throw FundClientError.malformed("minline jsonp wrapper missing")
}
let inner = String(raw[lParen..<rParen])
// Empty payload (off-hours / no session yet today) is `null`.
if inner.trimmingCharacters(in: .whitespaces) == "null" { return [] }
guard let innerData = inner.data(using: .utf8),
let arr = try? JSONSerialization.jsonObject(with: innerData) as? [[Any]] else {
throw FundClientError.malformed("minline array decode")
}
return arr.compactMap { row -> GoldMinutePoint? in
// Layout: [time, last, avgPrice, volume, openInterest, prevClose?, date?]
guard row.count >= 4 else { return nil }
// Sina returns numbers as strings inside the array coerce.
let timeStr = row[0] as? String ?? ""
let priceStr = row[1] as? String ?? ""
let volStr = row[3] as? String ?? "0"
guard let p = Double(priceStr), p > 0 else { return nil }
return GoldMinutePoint(time: timeStr, price: p, volume: Double(volStr) ?? 0)
}
}
}

198
Sources/data/Models.swift Normal file
View File

@ -0,0 +1,198 @@
//
// Models.swift
// plugin v0.2 (UI redesign per design spec)
//
// Models grew from "watchlist with name+code" to "actual holding with
// cost basis and shares" so the design's hero card ( / /
// ) can compute real numbers. Cost/shares are optional if
// empty, the row falls back to "watchlist only" display (rate %
// without ¥ amounts), so we don't force every user to type cost basis
// on day one.
//
import Foundation
// MARK: - Fund
/// One fund the user has subscribed to (lives in the watchlist).
/// `shares` and `costNav` are optional: when set, the holdings hero
/// card shows real ¥ P&L; when nil, only the rate % displays.
struct WatchlistFund: Codable, Equatable, Identifiable {
let code: String // "005827"
let name: String // ""
var addedAt: Date
var displayOrder: Int
/// Shares held (). Optional nil means "I just want to watch this fund".
var shares: Double?
/// Average cost basis per share (/).
var costNav: Double?
var id: String { code }
/// True iff the user has filled in both shares + cost basis.
var hasPosition: Bool {
guard let s = shares, s > 0, let c = costNav, c > 0 else { return false }
return true
}
/// Cost amount = shares × cost basis (only valid when hasPosition).
var costAmount: Double? {
guard let s = shares, let c = costNav else { return nil }
return s * c
}
}
/// Today's intraday estimate + last-published NAV for one fund.
/// Returned by `FundClient.estimate(for:)` (the
/// `fundgz.1234567.com.cn/js/{code}.js` JSONP feed).
struct FundEstimate: Equatable {
let code: String
let name: String
let publishedDate: Date // jzrq date the published NAV applies to
let publishedNav: Double // dwjz last published unit NAV
let estimatedNav: Double? // gsz intraday estimated NAV (nil out of session)
let estimatedRate: Double? // gszzl intraday %, e.g. 0.53 means +0.53%
let estimatedAt: Date? // gztime last time estimate refreshed
/// "Best available" NAV use intraday estimate if we have it, else
/// fall back to last published. The hero card uses this to compute
/// market value during trade hours.
var bestNav: Double { estimatedNav ?? publishedNav }
}
/// One row from the historical NAV table (used for sparkline).
struct FundNavPoint: Equatable {
let date: Date
let unitNav: Double
let dailyRate: Double
}
/// One match from the eastmoney fund-suggest endpoint.
/// Includes "category" (fund type) and a heuristic "tag" for the
/// design's coloured chip.
struct FundSearchHit: Equatable, Identifiable {
let code: String
let name: String
let category: String? // e.g. "-" / "-"
var id: String { code }
/// Coarse-grained tag derived from category for the design's coloured
/// chip in the search list. Not authoritative just hint colour.
var displayTag: String {
guard let c = category?.lowercased() else { return "基金" }
if c.contains("指数") { return "指数" }
if c.contains("股票") { return "股票" }
if c.contains("混合") { return "混合" }
if c.contains("债券") { return "债券" }
if c.contains("货币") { return "货币" }
if c.contains("qdii") { return "QDII" }
if c.contains("etf") { return "ETF" }
return "基金"
}
}
// MARK: - Gold
/// Gold realtime quote. Same struct services SHFE (RMB/g, used as
/// the chart price) and the foreign reference quotes (COMEX/London,
/// USD/oz with RMB/g conversion alongside).
struct GoldQuote: Equatable, Identifiable {
enum Source: String, Codable, Hashable, CaseIterable {
case shanghaiSpot = "shanghai_spot"
case shfeFutures = "shfe_futures"
case comex = "comex"
case london = "london"
var displayName: String {
switch self {
case .shanghaiSpot: return "上海金"
case .shfeFutures: return "沪金"
case .comex: return "纽约金"
case .london: return "伦敦金"
}
}
/// Sina symbol for `https://hq.sinajs.cn/list={symbol}`
var sinaSymbol: String {
switch self {
case .shanghaiSpot: return "AU0"
case .shfeFutures: return "AU0"
case .comex: return "hf_GC"
case .london: return "hf_XAU"
}
}
}
let source: Source
let last: Double
/// Today's opening price. Distinct from `prevClose` UI's ""
/// row needs this, not yesterday's close.
let open: Double?
let prevClose: Double?
let high: Double?
let low: Double?
let updatedAt: Date?
/// Set when source {comex, london} so UI can show ¥/g alongside USD/oz.
let rmbPerGram: Double?
var id: String { source.rawValue }
var change: Double? {
guard let p = prevClose else { return nil }
return last - p
}
var changeRate: Double? {
guard let p = prevClose, p != 0 else { return nil }
return (last - p) / p * 100.0
}
}
/// One day's OHLCV for the gold K-line chart.
struct GoldDailyBar: Equatable {
let date: Date
let open: Double
let high: Double
let low: Double
let close: Double
let volume: Double
}
/// User's gold holding position. Both fields optional so an empty
/// holding is valid (you might just want to watch the price without
/// committing). Persisted as JSON next to the watchlist.
struct GoldPosition: Codable, Equatable {
/// Total grams held (). Nil = no holding entered yet.
var grams: Double?
/// Average buy-in cost per gram (/).
var costPerGram: Double?
var hasPosition: Bool {
guard let g = grams, g > 0, let c = costPerGram, c > 0 else { return false }
return true
}
var costAmount: Double? {
guard let g = grams, let c = costPerGram else { return nil }
return g * c
}
}
/// Time range for the gold chart tab strip (1/3/1/).
enum GoldRange: String, CaseIterable, Identifiable {
case oneMonth = "1月"
case threeMonth = "3月"
case oneYear = "1年"
case all = "全部"
var id: String { rawValue }
/// Number of days to slice from the daily history. `nil` = all.
var days: Int? {
switch self {
case .oneMonth: return 22 // ~22 trading days/month
case .threeMonth: return 66
case .oneYear: return 250
case .all: return nil
}
}
}

View File

@ -0,0 +1,184 @@
//
// SinaQuoteParser.swift
// plugin
//
// Parses Sina Finance's classic JS-string quote format. Every realtime
// endpoint of the form `https://hq.sinajs.cn/list=XXX` returns the same
// shape:
//
// var hq_str_XXX="field1,field2,field3,...";
//
// Field positions are domain-dependent. We only decode the four flavors
// this plugin uses (hf_* foreign futures and AU* domestic futures).
//
// GBK is the encoding for the Chinese name fields. We don't care about
// those we only need the numeric fields, which are pure ASCII and
// parse fine even when the rest of the string is garbled bytes.
//
import Foundation
enum SinaQuoteParser {
/// Parse a Sina quote response into a single GoldQuote.
/// Returns nil if the data is empty (Sina returns a one-byte
/// `""` payload for unknown symbols).
static func parse(rawString: String, source: GoldQuote.Source) -> GoldQuote? {
// Find the first `="` ... `";` payload.
guard let eq = rawString.firstIndex(of: "="),
let firstQuote = rawString[eq...].firstIndex(of: "\"") else {
return nil
}
let afterQuote = rawString.index(after: firstQuote)
guard let lastQuote = rawString[afterQuote...].lastIndex(of: "\"") else {
return nil
}
let body = String(rawString[afterQuote..<lastQuote])
guard !body.isEmpty else { return nil }
let fields = body.components(separatedBy: ",")
switch source {
case .comex, .london:
return parseForeignFutures(fields: fields, source: source)
case .shfeFutures, .shanghaiSpot:
return parseDomesticFutures(fields: fields, source: source)
}
}
/// Parse a Sina quote response from raw `Data` (handles encoding).
/// We force decode with UTF-8 first; if that fails (Chinese name
/// fields use GBK), fall back to GBK. Numeric fields parse the
/// same either way.
static func parse(data: Data, source: GoldQuote.Source) -> GoldQuote? {
if let s = String(data: data, encoding: .utf8) {
return parse(rawString: s, source: source)
}
// Chinese encodings Sina uses GB18030 (superset of GBK).
let cfStr = CFStringConvertEncodingToNSStringEncoding(CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue))
let nsEnc = String.Encoding(rawValue: cfStr)
if let s = String(data: data, encoding: nsEnc) {
return parse(rawString: s, source: source)
}
// Last resort: ASCII with lossy conversion. Numeric fields survive.
let lossy = String(data: data, encoding: .ascii) ?? ""
return parse(rawString: lossy, source: source)
}
// MARK: - Foreign futures (hf_*)
//
// Verified field layout (from `https://hq.sinajs.cn/list=hf_GC` on
// 2026-04-25 nightly run see test-final.py output):
//
// [0] last (latest) 4728.253
// [1] (empty / bid sometimes)
// [2] open 4725.100
// [3] high 4725.300 bug-prone: actually high
// [4] day high 4757.100
// [5] day low 4672.200
// [6] update time "04:59:58"
// [7] prev settle 4724.000
// [8] prev close 4715.600
// [9-11] zeros / volume / position
// [12] date "2026-04-25"
// [13] name (GBK garbled here)
//
// We only need: last, day high, day low, prev close, update time, date.
private static func parseForeignFutures(fields: [String], source: GoldQuote.Source) -> GoldQuote? {
guard fields.count >= 13 else { return nil }
guard let last = Double(fields[0]) else { return nil }
let high = Double(fields[4])
let low = Double(fields[5])
let prevClose = Double(fields[8]).nonZero
let timeStr = fields[6]
let dateStr = fields[12]
let updatedAt = combineForeign(date: dateStr, time: timeStr)
// hf_GC / hf_XAU return USD/oz. RMB conversion is NOT included
// in the raw feed (akshare adds it on the Python side using a
// separate FX call). We compute it here only when the realtime
// USD/CNY rate is available for v0.1 we punt and let the UI
// call FxClient if needed.
let open = Double(fields[2])
return GoldQuote(
source: source, last: last,
open: open, prevClose: prevClose, high: high, low: low,
updatedAt: updatedAt, rmbPerGram: nil
)
}
// MARK: - Domestic futures (AU0 etc.)
//
// Verified field layout from `https://hq.sinajs.cn/list=AU0` (2026-04-25):
//
// [0] name (GBK)
// [1] (volume of last tick) 145957
// [2] open 574.86
// [3] high 585.84
// [4] low 574.40
// [5] last 574.94
// [6] bid1 581.58
// [7] ask1 581.60
// [8] last_settle? 581.56
// [9] reserved 0.00
// [10] prev close 572.76
// [13] volume 189097
// [14] open interest 308940
// [17] date "2024-07-17" stale on this snapshot, normal off-hours
//
// Domestic AU is RMB/g we set rmbPerGram = last directly.
private static func parseDomesticFutures(fields: [String], source: GoldQuote.Source) -> GoldQuote? {
guard fields.count >= 11 else { return nil }
guard let last = Double(fields[5]), last > 0 else { return nil }
let high = Double(fields[3])
let low = Double(fields[4])
let prevClose = Double(fields[10]).nonZero
// Sina exposes a settled date but not a precise time stamp
// for AU realtime. Use the trade date + current wall time
// when fields[17] is available; otherwise leave nil.
let dateStr = fields.count > 17 ? fields[17] : ""
let updatedAt = combineDomestic(date: dateStr)
let open = Double(fields[2])
return GoldQuote(
source: source, last: last,
open: open, prevClose: prevClose, high: high, low: low,
updatedAt: updatedAt, rmbPerGram: last
)
}
// MARK: - Date helpers
/// Foreign futures format "2026-04-25" + "04:59:58" Date in
/// America/New_York-equivalent rendered in CN time zone for display.
private static func combineForeign(date: String, time: String) -> Date? {
guard !date.isEmpty, !time.isEmpty else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
// Sina serves the NY/London exchange wall time as-is; we tag
// it with Asia/Shanghai so it lines up with the Mac's local
// clock for "". Investors care more about elapsed-time
// freshness than which time zone the exchange ran in.
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f.date(from: "\(date) \(time)")
}
private static func combineDomestic(date: String) -> Date? {
guard !date.isEmpty else { return nil }
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
f.dateFormat = "yyyy-MM-dd"
return f.date(from: date)
}
}
private extension Optional where Wrapped == Double {
/// Sina sometimes encodes "no prev close" as 0. Drop that to nil
/// so downstream code doesn't compute a + percent change.
var nonZero: Double? {
if case .some(let v) = self, v > 0 { return v }
return nil
}
}

View File

@ -0,0 +1,102 @@
//
// SpotGoldClient.swift
// plugin v0.3
//
// AU9999 (SGE) realtime same data Alipay shows on the
// tab. Distinct from the SHFE futures AU0 we already pull:
// AU9999 is the SGE physical-spot reference price retail jewellery
// banks quote off, whereas AU0 is the futures contract.
//
// Endpoint: `https://hq.sinajs.cn/list=gds_AU9999`
//
// Field layout (verified 2026-04-26):
// [0] current price 1040.00
// [1] ? 0
// [2] ? 1039.00 (looks like avg or bid?)
// [3] ? 1049.00
// [4] high 1044.00
// [5] low 1034.00
// [6] time "02:30:00"
// [7] prevClose 1033.25
// [8] open 1039.90
// [9] volume 3136
// [10] (purity?) 100.00
// [11] (purity?) 100.00
// [12] date "2026-04-25"
// [13] name (GBK) "99"
//
import Foundation
/// Spot gold quote (SGE AU9999 / AU99.99).
struct SpotGoldQuote: Equatable {
let last: Double // current
let prevClose: Double //
let open: Double //
let high: Double //
let low: Double //
let updatedAt: Date?
let name: String // "99"
var change: Double { last - prevClose }
var changeRate: Double { prevClose == 0 ? 0 : change / prevClose * 100 }
}
actor SpotGoldClient {
static let shared = SpotGoldClient()
private let session: URLSession
init() {
let cfg = URLSessionConfiguration.default
cfg.timeoutIntervalForRequest = 6
cfg.timeoutIntervalForResource = 12
cfg.requestCachePolicy = .reloadIgnoringLocalCacheData
self.session = URLSession(configuration: cfg)
}
/// Fetch the current AU9999 spot quote.
func quote() async throws -> SpotGoldQuote {
let url = URL(string: "https://hq.sinajs.cn/list=gds_AU9999")!
var req = URLRequest(url: url)
req.setValue("Mozilla/5.0", forHTTPHeaderField: "User-Agent")
req.setValue("https://finance.sina.com.cn/", forHTTPHeaderField: "Referer")
let (data, _) = try await session.data(for: req)
// Sina serves GB18030 here too (name field is Chinese).
let gbEnc = String.Encoding(rawValue:
CFStringConvertEncodingToNSStringEncoding(
CFStringEncoding(CFStringEncodings.GB_18030_2000.rawValue)))
let s = String(data: data, encoding: gbEnc)
?? String(data: data, encoding: .utf8)
?? ""
guard let lQ = s.firstIndex(of: "\""),
let rQ = s[s.index(after: lQ)...].firstIndex(of: "\"") else {
throw FundClientError.malformed("AU9999 payload parse")
}
let body = String(s[s.index(after: lQ)..<rQ])
let fields = body.components(separatedBy: ",")
// Off-hours can return an empty payload; guard.
guard fields.count >= 13,
let last = Double(fields[0]),
let high = Double(fields[4]),
let low = Double(fields[5]),
let prevClose = Double(fields[7]),
let open = Double(fields[8]) else {
throw FundClientError.malformed("AU9999 field decode (\(fields.count) fields)")
}
let timeStr = fields[6]
let dateStr = fields[12]
let fmt = DateFormatter()
fmt.locale = Locale(identifier: "en_US_POSIX")
fmt.timeZone = TimeZone(identifier: "Asia/Shanghai")
fmt.dateFormat = "yyyy-MM-dd HH:mm:ss"
let updatedAt = fmt.date(from: "\(dateStr) \(timeStr)")
let name = fields.count > 13 ? fields[13] : "上海金"
return SpotGoldQuote(
last: last, prevClose: prevClose, open: open,
high: high, low: low, updatedAt: updatedAt, name: name
)
}
}

View File

@ -0,0 +1,41 @@
//
// FundDebugLog.swift
// plugin
//
// Plugin-bundle NSLog calls don't reliably surface in `log show` from
// the host process they get filtered, deduped, or eaten by the
// subsystem. This helper writes timestamped lines to a known file so
// we can `tail -f` it during debugging without guessing log filters.
//
import Foundation
enum FundDebugLog {
static let path = "/tmp/fund-plugin.log"
private static let queue = DispatchQueue(label: "fund.debug.log")
private static let dateFmt: DateFormatter = {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone.current
f.dateFormat = "HH:mm:ss.SSS"
return f
}()
static func write(_ message: String) {
let stamp = dateFmt.string(from: Date())
let line = "[\(stamp)] \(message)\n"
queue.async {
if let data = line.data(using: .utf8) {
if FileManager.default.fileExists(atPath: path) {
if let handle = try? FileHandle(forWritingTo: URL(fileURLWithPath: path)) {
try? handle.seekToEnd()
try? handle.write(contentsOf: data)
try? handle.close()
}
} else {
try? data.write(to: URL(fileURLWithPath: path))
}
}
}
}
}

View File

@ -0,0 +1,211 @@
//
// FundStore.swift
// plugin v0.2
//
// Glue between watchlist + clients + UI. Now also caches gold daily
// K-line so the chart can switch ranges (1/3/1/) without
// re-fetching.
//
import Combine
import Foundation
import SwiftUI
@MainActor
final class FundStore: ObservableObject {
static let shared = FundStore()
@Published private(set) var estimates: [String: FundEstimate] = [:]
@Published private(set) var goldQuotes: [GoldQuote.Source: GoldQuote] = [:]
@Published private(set) var goldDailyBars: [GoldDailyBar] = []
/// Spot gold (AU9999) what Alipay shows on tab.
@Published private(set) var spotGold: SpotGoldQuote?
/// Today's intraday minute line for AU0 replaces daily K-line in chart.
@Published private(set) var goldMinuteLine: [GoldMinutePoint] = []
@Published private(set) var lastFundRefresh: Date?
@Published private(set) var lastGoldRefresh: Date?
@Published private(set) var lastGoldHistRefresh: Date?
@Published private(set) var isRefreshing = false
let watchlist: Watchlist
let goldPosition: GoldPositionStore
private let scheduler = RefreshScheduler()
private var fundTask: Task<Void, Never>?
private var goldTask: Task<Void, Never>?
init() {
self.watchlist = Watchlist()
self.goldPosition = GoldPositionStore()
}
// MARK: - Lifecycle
func start() {
guard fundTask == nil else { return }
fundTask = Task { [weak self] in await self?.fundLoop() }
goldTask = Task { [weak self] in await self?.goldLoop() }
// K-line history is rarely refreshed once on start, then once
// every hour (price moved enough to redraw the bottom of the chart).
Task { [weak self] in await self?.refreshGoldKLine() }
}
func stop() {
fundTask?.cancel(); fundTask = nil
goldTask?.cancel(); goldTask = nil
}
// MARK: - Manual triggers
func refreshNow() async {
await refreshFunds()
await refreshGold()
await refreshGoldKLine()
}
// MARK: - Fund refresh
private func fundLoop() async {
await refreshFunds()
while !Task.isCancelled {
let interval = scheduler.interval(for: .fundsActive)
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
if Task.isCancelled { break }
await refreshFunds()
}
}
private func refreshFunds() async {
let codes = watchlist.codes
guard !codes.isEmpty else { return }
isRefreshing = true
// Split codes by venue: ETFs ( Shanghai/Shenzhen exchange)
// get the stock-like Sina endpoint, mutual funds () get the
// Tiantian estimation feed. Same FundEstimate output shape so the
// UI doesn't care which venue a row came from.
let etfCodes = codes.filter { ETFClient.isETFCode($0) }
let otcCodes = codes.filter { !ETFClient.isETFCode($0) }
FundDebugLog.write("refreshFunds start codes=\(codes) etf=\(etfCodes) otc=\(otcCodes)")
async let otcResults: [FundEstimate] = FundClient.shared.estimates(for: otcCodes)
async let etfQuotes: [ETFQuote] = ETFClient.shared.quotes(for: etfCodes)
var dict = self.estimates
let otc = await otcResults
let etf = await etfQuotes
FundDebugLog.write("refreshFunds got otc=\(otc.count)/\(otcCodes.count) etf=\(etf.count)/\(etfCodes.count)")
for r in otc { dict[r.code] = r }
for q in etf {
// Map ETFQuote FundEstimate so UI is uniform. published =
// prevClose, intraday = current price, rate = day change %.
dict[q.code] = FundEstimate(
code: q.code,
name: q.name,
publishedDate: q.updatedAt ?? Date(),
publishedNav: q.prevClose,
estimatedNav: q.last,
estimatedRate: q.changeRate,
estimatedAt: q.updatedAt
)
}
self.estimates = dict
self.lastFundRefresh = Date()
isRefreshing = false
}
// MARK: - Gold realtime
private func goldLoop() async {
await refreshGold()
while !Task.isCancelled {
let interval = scheduler.interval(for: .gold)
try? await Task.sleep(nanoseconds: UInt64(interval * 1_000_000_000))
if Task.isCancelled { break }
await refreshGold()
}
}
private func refreshGold() async {
// Three things in parallel: legacy realtime (London/NY/SHFE),
// AU9999 spot, and the minute line. None block the others
// each falls back silently on failure.
async let realtime: [GoldQuote] = GoldClient.shared.quoteAll()
async let spot: SpotGoldQuote? = try? SpotGoldClient.shared.quote()
async let minute: [GoldMinutePoint] = (try? await GoldMinuteClient.shared.minuteLine()) ?? []
var dict = self.goldQuotes
for q in await realtime { dict[q.source] = q }
self.goldQuotes = dict
if let s = await spot { self.spotGold = s }
let m = await minute
if !m.isEmpty { self.goldMinuteLine = m }
self.lastGoldRefresh = Date()
}
// MARK: - Gold K-line history
private func refreshGoldKLine() async {
do {
let bars = try await GoldKlineClient.shared.dailyKLine(symbol: "AU0")
self.goldDailyBars = bars
self.lastGoldHistRefresh = Date()
} catch {
NSLog("[fund-plugin] gold kline fetch failed: \(error)")
}
}
/// Slice the loaded K-line for a given range. Returns the most
/// recent N bars (or all if range == .all).
func goldBars(for range: GoldRange) -> [GoldDailyBar] {
guard let n = range.days else { return goldDailyBars }
return Array(goldDailyBars.suffix(n))
}
// MARK: - Aggregate computed for hero card
/// Total market value across all watchlist funds that have a
/// position (shares + costNav set). Returns nil when no positions.
var totalMarketValue: Double? {
var total: Double = 0
var any = false
for f in watchlist.funds {
guard let shares = f.shares, shares > 0 else { continue }
let nav = estimates[f.code]?.bestNav
guard let n = nav else { continue }
total += shares * n
any = true
}
return any ? total : nil
}
/// Total cost basis (Σ shares × costNav) across positions. Nil if none.
var totalCost: Double? {
var total: Double = 0
var any = false
for f in watchlist.funds {
guard let cost = f.costAmount else { continue }
total += cost
any = true
}
return any ? total : nil
}
/// Sum of today's ¥ P&L across funds with positions.
/// Today's ¥ delta on one fund = shares × bestNav × (estimatedRate / (100 + estimatedRate))
/// We approximate with: shares × (bestNav - publishedNav).
var totalDayPnL: Double? {
var total: Double = 0
var any = false
for f in watchlist.funds {
guard let shares = f.shares, shares > 0 else { continue }
guard let est = estimates[f.code] else { continue }
// Day ¥ = shares × (intraday nav - last published nav)
let estNav = est.estimatedNav ?? est.publishedNav
let delta = (estNav - est.publishedNav) * shares
total += delta
any = true
}
return any ? total : nil
}
}

View File

@ -0,0 +1,87 @@
//
// RefreshScheduler.swift
// plugin
//
// Drives the refresh cadence based on what's tradeable right now.
// Different things have different refresh budgets:
//
// Tradeable now Cadence
//
// A-share funds (intraday) 60 s (estimate API is ~1 min granularity)
// A-share funds (closed) 30 min (just to pick up newly published NAV)
// Foreign gold (24/5) 30 s (Sina updates GC/XAU continuously)
// Domestic gold 30 s while SHFE open
//
// Trade hours (Asia/Shanghai):
// A-share: 09:3011:30, 13:0015:00, MonFri
// SHFE day: 09:0010:15, 10:3011:30, 13:3015:00
// SHFE night: 21:0002:30
// COMEX gold: 06:0005:00 next day (essentially 23h, 5 days/week)
// LBMA London: rolls with COMEX in practice
//
import Foundation
@MainActor
final class RefreshScheduler {
enum Tier {
/// Funds intraday cadence.
case fundsActive
/// Funds idle cadence only listening for end-of-day NAV publish.
case fundsIdle
/// Realtime gold tier (always on, foreign markets virtually 24/7).
case gold
}
/// Returns refresh interval (seconds) for a tier at the given moment.
func interval(for tier: Tier, at now: Date = Date()) -> TimeInterval {
switch tier {
case .fundsActive:
return isAShareTradeHour(now) ? 60 : 30 * 60
case .fundsIdle:
return 30 * 60
case .gold:
// Always 30s foreign gold trades virtually 24/7. SHFE has
// closed windows but we don't bother backing off; the request
// is cheap and Sina caches the latest tick anyway.
return 30
}
}
/// True iff `now` falls inside an A-share trading session
/// (09:3011:30, 13:0015:00 China time, MonFri, no holiday awareness yet).
func isAShareTradeHour(_ now: Date = Date()) -> Bool {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "Asia/Shanghai")!
let weekday = cal.component(.weekday, from: now) // 1=Sun 7=Sat
guard (2...6).contains(weekday) else { return false }
let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
// 09:3011:30
if hm >= 9 * 60 + 30 && hm < 11 * 60 + 30 { return true }
// 13:0015:00
if hm >= 13 * 60 && hm < 15 * 60 { return true }
return false
}
/// True iff SHFE gold is in a trading window (loose no holiday).
func isSHFETradeHour(_ now: Date = Date()) -> Bool {
var cal = Calendar(identifier: .gregorian)
cal.timeZone = TimeZone(identifier: "Asia/Shanghai")!
let weekday = cal.component(.weekday, from: now)
// Day session also runs Mon-Fri; night session technically can
// span into Sat early morning but we keep it simple.
guard (2...6).contains(weekday) else { return false }
let hm = cal.component(.hour, from: now) * 60 + cal.component(.minute, from: now)
// 09:00-10:15
if hm >= 9 * 60 && hm < 10 * 60 + 15 { return true }
// 10:30-11:30
if hm >= 10 * 60 + 30 && hm < 11 * 60 + 30 { return true }
// 13:30-15:00
if hm >= 13 * 60 + 30 && hm < 15 * 60 { return true }
// 21:00-23:59 + 00:00-02:30
if hm >= 21 * 60 { return true }
if hm < 2 * 60 + 30 { return true }
return false
}
}

View File

@ -0,0 +1,57 @@
//
// GoldPositionStore.swift
// plugin v0.2
//
// Persists the single GoldPosition to its own JSON file (separate
// from watchlist.json different shape, different lifecycle).
//
// Path: ~/Library/Application Support/Mio Island/Fund/gold-position.json
//
import Foundation
@MainActor
final class GoldPositionStore: ObservableObject {
@Published private(set) var position: GoldPosition = GoldPosition(grams: nil, costPerGram: nil)
private let storeURL: URL
private let queue = DispatchQueue(label: "com.mioisland.plugin.fund.gold-position", qos: .userInitiated)
init() {
let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let dir = appSupport.appendingPathComponent("Mio Island/Fund", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.storeURL = dir.appendingPathComponent("gold-position.json")
load()
}
func update(grams: Double?, costPerGram: Double?) {
position = GoldPosition(grams: grams, costPerGram: costPerGram)
save()
}
private func load() {
guard let data = try? Data(contentsOf: storeURL),
let decoded = try? JSONDecoder().decode(GoldPosition.self, from: data) else {
return
}
position = decoded
}
private func save() {
let snapshot = position
queue.async { [storeURL] in
do {
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let data = try encoder.encode(snapshot)
let tmp = storeURL.appendingPathExtension("tmp")
try data.write(to: tmp, options: .atomic)
_ = try? FileManager.default.replaceItemAt(storeURL, withItemAt: tmp)
} catch {
NSLog("[fund-plugin] gold position save failed: \(error)")
}
}
}
}

View File

@ -0,0 +1,118 @@
//
// Watchlist.swift
// plugin v0.2
//
// User's selected funds + their cost basis / shares (when set).
// Persisted as JSON to:
// ~/Library/Application Support/Mio Island/Fund/watchlist.json
//
import Foundation
@MainActor
final class Watchlist: ObservableObject {
@Published private(set) var funds: [WatchlistFund] = []
private let storeURL: URL
private let queue = DispatchQueue(label: "com.mioisland.plugin.fund.watchlist", qos: .userInitiated)
init() {
let appSupport = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
let dir = appSupport.appendingPathComponent("Mio Island/Fund", isDirectory: true)
try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
self.storeURL = dir.appendingPathComponent("watchlist.json")
load()
}
// MARK: - Public API
func add(_ hit: FundSearchHit) {
guard !funds.contains(where: { $0.code == hit.code }) else { return }
let item = WatchlistFund(
code: hit.code, name: hit.name,
addedAt: Date(),
displayOrder: (funds.map { $0.displayOrder }.max() ?? 0) + 1,
shares: nil, costNav: nil
)
funds.append(item)
save()
}
func remove(code: String) {
funds.removeAll { $0.code == code }
save()
}
func updatePosition(code: String, shares: Double?, costNav: Double?) {
guard let idx = funds.firstIndex(where: { $0.code == code }) else { return }
var f = funds[idx]
f.shares = shares
f.costNav = costNav
funds[idx] = f
save()
}
func move(from source: IndexSet, to destination: Int) {
funds.move(fromOffsets: source, toOffset: destination)
for (idx, var f) in funds.enumerated() {
f.displayOrder = idx
funds[idx] = f
}
save()
}
func contains(_ code: String) -> Bool {
funds.contains { $0.code == code }
}
var codes: [String] { funds.map(\.code) }
func fund(code: String) -> WatchlistFund? {
funds.first { $0.code == code }
}
// MARK: - Persistence
private func load() {
guard let data = try? Data(contentsOf: storeURL) else { return }
do {
let decoded = try JSONDecoder.fundDecoder.decode([WatchlistFund].self, from: data)
self.funds = decoded.sorted(by: { $0.displayOrder < $1.displayOrder })
} catch {
NSLog("[fund-plugin] watchlist load failed: \(error)")
}
}
private func save() {
let snapshot = self.funds
queue.async { [storeURL] in
do {
let encoder = JSONEncoder.fundEncoder
let data = try encoder.encode(snapshot)
let tmp = storeURL.appendingPathExtension("tmp")
try data.write(to: tmp, options: .atomic)
_ = try? FileManager.default.replaceItemAt(storeURL, withItemAt: tmp)
} catch {
NSLog("[fund-plugin] watchlist save failed: \(error)")
}
}
}
}
private extension JSONEncoder {
static var fundEncoder: JSONEncoder {
let e = JSONEncoder()
e.outputFormatting = [.prettyPrinted, .sortedKeys]
e.dateEncodingStrategy = .iso8601
return e
}
}
private extension JSONDecoder {
static var fundDecoder: JSONDecoder {
let d = JSONDecoder()
d.dateDecodingStrategy = .iso8601
return d
}
}

227
Sources/ui/AddView.swift Normal file
View File

@ -0,0 +1,227 @@
//
// AddView.swift
// plugin v0.2
//
// tab. Search field + scrollable list of hits with category
// tag, code, and an add button. Mirrors the design's panel-add.jsx
// but uses the live Eastmoney suggest API instead of mock data.
//
import SwiftUI
struct AddView: View {
@ObservedObject var store: FundStore
@State private var query = ""
@State private var hits: [FundSearchHit] = []
@State private var isSearching = false
@State private var debounceTask: Task<Void, Never>?
var body: some View {
VStack(spacing: 0) {
searchField
.padding(.horizontal, 16)
.padding(.top, 4)
.padding(.bottom, 6)
sectionTitle
.padding(.horizontal, 16)
.padding(.top, 4)
.padding(.bottom, 4)
list
}
}
private var searchField: some View {
ZStack(alignment: .leading) {
RoundedRectangle(cornerRadius: 10)
.fill(FundTheme.overlay06)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(FundTheme.overlay08, lineWidth: 0.5)
)
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.font(.system(size: 12, weight: .medium))
.foregroundColor(FundTheme.fg40)
TextField("搜索基金代码 / 名称", text: $query)
.textFieldStyle(.plain)
.font(.system(size: 13))
.foregroundColor(.white)
if isSearching {
ProgressView().scaleEffect(0.5)
}
}
.padding(.horizontal, 12)
}
.frame(height: 36)
.onChange(of: query) { _, newValue in
debounceTask?.cancel()
let q = newValue
debounceTask = Task { @MainActor in
try? await Task.sleep(nanoseconds: 300_000_000)
guard !Task.isCancelled else { return }
await runSearch(q)
}
}
}
private var sectionTitle: some View {
HStack {
Text(query.isEmpty ? "添加你的基金" : "搜索结果")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(FundTheme.fg40)
.tracking(0.5)
Spacer()
}
}
@ViewBuilder
private var list: some View {
if query.isEmpty {
VStack(spacing: 12) {
Spacer().frame(height: 30)
Image(systemName: "magnifyingglass")
.font(.system(size: 22))
.foregroundColor(FundTheme.fg35)
.padding(20)
.background(Circle().fill(FundTheme.overlay04))
Text("输入基金代码或名称开始搜索")
.font(.system(size: 12))
.foregroundColor(FundTheme.fg40)
Text("数据源: 东方财富 fund.eastmoney.com")
.font(.system(size: 10))
.foregroundColor(FundTheme.fg35)
Spacer()
}
.frame(maxWidth: .infinity)
} else if hits.isEmpty && !isSearching {
VStack {
Spacer().frame(height: 40)
Text("未找到匹配的基金")
.font(.system(size: 12))
.foregroundColor(FundTheme.fg35)
Spacer()
}
.frame(maxWidth: .infinity)
} else {
ScrollView {
LazyVStack(spacing: 0) {
ForEach(hits) { hit in
SearchRow(
hit: hit,
alreadyAdded: store.watchlist.contains(hit.code)
) {
if !store.watchlist.contains(hit.code) {
store.watchlist.add(hit)
Task { await store.refreshNow() }
}
}
}
}
.padding(.bottom, 4)
}
}
}
private func runSearch(_ q: String) async {
guard !q.isEmpty else { hits = []; return }
isSearching = true
defer { isSearching = false }
do {
hits = try await FundClient.shared.search(q)
} catch {
hits = []
}
}
}
private struct SearchRow: View {
let hit: FundSearchHit
let alreadyAdded: Bool
let onTap: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onTap) {
HStack(spacing: 10) {
VStack(alignment: .leading, spacing: 2) {
Text(hit.name)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(FundTheme.fgPrimary)
.lineLimit(1)
.truncationMode(.tail)
.frame(maxWidth: .infinity, alignment: .leading)
HStack(spacing: 8) {
tagChip(hit.displayTag, color: tagColor(hit.displayTag))
Text(hit.code)
.font(.system(size: 10.5, design: .monospaced))
.foregroundColor(FundTheme.fg40)
if let cat = hit.category, cat != hit.displayTag {
Text(cat)
.font(.system(size: 10))
.foregroundColor(FundTheme.fg40)
.lineLimit(1)
}
}
}
Spacer()
addBtn
}
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(isHovered ? FundTheme.overlay04 : Color.clear)
}
.buttonStyle(.plain)
.disabled(alreadyAdded)
.onHover { isHovered = $0 }
}
private var addBtn: some View {
Group {
if alreadyAdded {
ZStack {
Circle()
.fill(FundTheme.overlay08)
.frame(width: 26, height: 26)
Image(systemName: "checkmark")
.font(.system(size: 11, weight: .bold))
.foregroundColor(FundTheme.downGreen)
}
} else {
ZStack {
Circle()
.fill(FundTheme.lime)
.frame(width: 26, height: 26)
Image(systemName: "plus")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.black)
}
}
}
}
private func tagChip(_ text: String, color: Color) -> some View {
Text(text)
.font(.system(size: 9.5, weight: .bold))
.foregroundColor(color)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(
RoundedRectangle(cornerRadius: 3)
.fill(color.opacity(0.18))
)
}
private func tagColor(_ tag: String) -> Color {
switch tag {
case "ETF": return Color(red: 0xFF/255, green: 0x8A/255, blue: 0x4C/255)
case "指数": return Color(red: 0x4C/255, green: 0xB6/255, blue: 0xFF/255)
case "股票": return Color(red: 0xA7/255, green: 0x85/255, blue: 0xFF/255)
case "QDII": return Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255)
case "混合": return Color(red: 0x9A/255, green: 0xA0/255, blue: 0xA8/255)
case "债券": return Color(red: 0x2C/255, green: 0xD4/255, blue: 0x7E/255)
case "货币": return Color(red: 0x7A/255, green: 0xC0/255, blue: 0xC0/255)
default: return Color(red: 0x88/255, green: 0x88/255, blue: 0x88/255)
}
}
}

View File

@ -0,0 +1,243 @@
//
// ExpandedView.swift
// plugin v0.2
//
// Top-level panel container 380×540 to match design spec. Renders
// the title bar (with refresh button + live dot) + tab pill strip +
// active tab body (HoldingsView / GoldView / AddView).
//
import SwiftUI
struct ExpandedView: View {
@ObservedObject var store: FundStore = .shared
enum Tab: String, CaseIterable, Identifiable {
case holdings = "持仓"
case gold = "黄金"
case add = "添加"
var id: String { rawValue }
}
@State private var tab: Tab = .holdings
@State private var refreshSpinning = false
var body: some View {
VStack(spacing: 0) {
// Notch reservation strip (40pt) same pattern Music Player
// uses. The physical camera/notch module + the host's
// floating back-chevron (at y=12, host-controlled) live in
// this band. No plugin paint here so the dark Island shell
// shows through cleanly behind the notch.
Color.clear.frame(height: 40)
topBar
tabStrip
body_
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
footer
}
.frame(width: 380, height: 580)
.background(
ZStack {
FundTheme.panelBg
// very subtle radial highlight at the top to feel like the
// notch glow rolling onto the panel
RadialGradient(
colors: [Color.white.opacity(0.04), Color.clear],
center: .top,
startRadius: 4, endRadius: 220
)
}
)
.clipShape(
UnevenRoundedRectangle(
cornerRadii: .init(topLeading: 0, bottomLeading: 28, bottomTrailing: 28, topTrailing: 0)
)
)
.onAppear {
store.start()
Task { await store.refreshNow() }
}
}
// MARK: - Top bar
private var topBar: some View {
HStack(spacing: 10) {
// The notch strip above (40pt) already clears the host's
// floating back-chevron, so the title can sit flush at the
// panel's leading edge no horizontal indent needed.
HStack(spacing: 8) {
Circle()
.fill(FundTheme.lime)
.frame(width: 7, height: 7)
.shadow(color: FundTheme.lime.opacity(0.6), radius: 4)
Text("看盘侠")
.font(.system(size: 14, weight: .semibold))
.foregroundColor(FundTheme.fgPrimary)
}
Spacer()
// Refresh button
Button {
refreshSpinning = true
Task {
await store.refreshNow()
try? await Task.sleep(nanoseconds: 500_000_000)
refreshSpinning = false
}
} label: {
Image(systemName: "arrow.clockwise")
.font(.system(size: 12))
.foregroundColor(refreshSpinning ? FundTheme.lime : FundTheme.fg55)
.frame(width: 28, height: 28)
.background(Circle().fill(Color.clear))
.rotationEffect(.degrees(refreshSpinning ? 360 : 0))
.animation(.easeInOut(duration: 0.6), value: refreshSpinning)
}
.buttonStyle(.plain)
}
.padding(.horizontal, 16)
.padding(.top, 14)
.padding(.bottom, 8)
}
// MARK: - Tabs
private var tabStrip: some View {
HStack(spacing: 8) {
ForEach(Tab.allCases) { t in
tabPill(label: t.rawValue, count: count(for: t), selected: tab == t) {
tab = t
}
}
Spacer()
}
.padding(.horizontal, 16)
.padding(.bottom, 10)
}
private func tabPill(label: String, count: Int?, selected: Bool, action: @escaping () -> Void) -> some View {
Button(action: action) {
HStack(spacing: 6) {
Text(label)
.font(.system(size: 12.5, weight: .semibold))
if let c = count {
Text("\(c)")
.font(.system(size: 10.5, weight: .semibold))
.opacity(selected ? 0.55 : 0.7)
.monospacedDigit()
}
}
.foregroundColor(selected ? Color(red: 0x0B/255, green: 0x0B/255, blue: 0x0B/255) : FundTheme.fg70)
.padding(.horizontal, 14)
.frame(height: 28)
.background(
Capsule()
.fill(selected ? FundTheme.lime : FundTheme.overlay04)
.overlay(
Capsule()
.stroke(selected ? Color.clear : FundTheme.overlay08, lineWidth: 0.5)
)
)
}
.buttonStyle(.plain)
}
private func count(for tab: Tab) -> Int? {
switch tab {
case .holdings: return store.watchlist.funds.count
default: return nil
}
}
// MARK: - Body
@ViewBuilder
private var body_: some View {
switch tab {
case .holdings:
HoldingsView(store: store) { tab = .add }
case .gold:
GoldView(store: store)
case .add:
AddView(store: store)
}
}
// MARK: - Footer
private var footer: some View {
HStack {
HStack(spacing: 6) {
LiveDot()
Text(footerLeftText)
}
Spacer()
Text(footerRightText)
}
.font(.system(size: 11))
.foregroundColor(FundTheme.fg40)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(
Rectangle()
.fill(Color.clear)
.overlay(
Rectangle()
.fill(FundTheme.overlay04)
.frame(height: 0.5),
alignment: .top
)
)
}
private var footerLeftText: String {
switch tab {
case .holdings: return store.estimates.isEmpty ? "等待数据..." : "实时估值"
case .gold: return "沪金 SHFE"
case .add: return "东方财富搜索"
}
}
private var footerRightText: String {
if let t = lastUpdate {
return "更新于 \(relative(t))"
}
return ""
}
private var lastUpdate: Date? {
switch tab {
case .holdings: return store.lastFundRefresh
case .gold: return store.lastGoldRefresh
case .add: return nil
}
}
private func relative(_ d: Date) -> String {
let s = Int(Date().timeIntervalSince(d))
if s < 60 { return "\(s)s 前" }
if s < 3600 { return "\(s / 60)分前" }
return "\(s / 3600)时前"
}
}
// MARK: - Live dot (small pulsing green dot, copied from design)
private struct LiveDot: View {
@State private var pulse = false
var body: some View {
Circle()
.fill(FundTheme.downGreen)
.frame(width: 6, height: 6)
.shadow(color: FundTheme.downGreen.opacity(0.7), radius: 4)
.scaleEffect(pulse ? 0.8 : 1.0)
.opacity(pulse ? 0.4 : 1.0)
.onAppear {
withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {
pulse = true
}
}
}
}

View File

@ -0,0 +1,239 @@
//
// GoldPositionCard.swift
// plugin v0.2
//
// card under the gold chart. Mirrors the design's
// `.gold-position` block. Tap-to-edit grams + cost-per-gram inline.
//
import SwiftUI
struct GoldPositionCard: View {
@ObservedObject var store: FundStore
@State private var isEditing = false
@State private var gramsText = ""
@State private var costText = ""
@FocusState private var focused: Field?
enum Field { case grams, cost }
/// Current gold price in RMB/g (from SHFE realtime).
private var currentPrice: Double? {
store.goldQuotes[.shfeFutures]?.last
}
var body: some View {
Group {
if isEditing {
editor
} else {
display
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(FundTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(FundTheme.overlay08, lineWidth: 0.5)
)
)
}
// MARK: - Display
private var display: some View {
let p = store.goldPosition.position
let cur = currentPrice
let marketValue: Double? = {
guard let g = p.grams, let cur else { return nil }
return g * cur
}()
let pnl: Double? = {
guard let mv = marketValue, let cost = p.costAmount else { return nil }
return mv - cost
}()
let pnlPct: Double? = {
guard let pnl = pnl, let cost = p.costAmount, cost > 0 else { return nil }
return pnl / cost * 100
}()
return VStack(alignment: .leading, spacing: 8) {
if !p.hasPosition {
// Empty state inline encourage the user to fill in.
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("我的持仓")
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(FundTheme.fg55)
Text("点击右侧添加持仓信息")
.font(.system(size: 11))
.foregroundColor(FundTheme.fg40)
}
Spacer()
Button {
beginEdit()
} label: {
Image(systemName: "plus")
.font(.system(size: 11, weight: .bold))
.foregroundColor(.black)
.frame(width: 24, height: 24)
.background(Circle().fill(FundTheme.gold))
}
.buttonStyle(.plain)
}
} else {
HStack(alignment: .firstTextBaseline) {
Text("我的持仓")
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(FundTheme.fg55)
Spacer()
if let mv = marketValue {
Text("¥ \(FundFormat.unsignedMoney(mv))")
.font(.system(size: 17, weight: .bold))
.foregroundColor(FundTheme.fgPrimary)
.monospacedDigit()
} else {
Text("")
.font(.system(size: 17, weight: .bold))
.foregroundColor(FundTheme.fg40)
}
}
HStack(alignment: .firstTextBaseline, spacing: 4) {
if let g = p.grams, let c = p.costPerGram {
Text("\(FundFormat.unsignedMoney(g, decimals: 2)) g · 成本 \(String(format: "%.2f", c)) / g")
.font(.system(size: 11.5))
.foregroundColor(FundTheme.fg55)
}
Spacer()
if let pnl, let pct = pnlPct {
Text("\(FundFormat.money(pnl)) · \(FundFormat.percent(pct))")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.upDown(pnl))
.monospacedDigit()
} else if marketValue != nil {
Text("等价格刷新…")
.font(.system(size: 11))
.foregroundColor(FundTheme.fg40)
}
}
HStack(spacing: 6) {
Spacer()
Button(action: beginEdit) {
Text("编辑")
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(FundTheme.fg55)
.padding(.horizontal, 10)
.frame(height: 22)
.background(Capsule().fill(FundTheme.overlay06))
}
.buttonStyle(.plain)
}
}
}
}
// MARK: - Editor
private var editor: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("编辑黄金持仓")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(FundTheme.fgPrimary)
Spacer()
Button(action: cancel) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(FundTheme.fg55)
.frame(width: 18, height: 18)
.background(Circle().fill(FundTheme.overlay06))
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
inputField(label: "克数 (g)", text: $gramsText, placeholder: "50", field: .grams)
inputField(label: "成本(元/g)", text: $costText, placeholder: "550.00", field: .cost)
}
HStack(spacing: 6) {
Button(action: clear) {
Text("清除")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(FundTheme.fg55)
.padding(.horizontal, 14)
.frame(height: 28)
.background(Capsule().fill(FundTheme.overlay06))
}
.buttonStyle(.plain)
Spacer()
Button(action: save) {
Text("保存")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.black)
.padding(.horizontal, 14)
.frame(height: 28)
.background(Capsule().fill(FundTheme.gold))
}
.buttonStyle(.plain)
}
}
}
private func inputField(label: String, text: Binding<String>, placeholder: String, field: Field) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.system(size: 9.5))
.foregroundColor(FundTheme.fg55)
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(FundTheme.fgPrimary)
.focused($focused, equals: field)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.black.opacity(0.4))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
focused == field
? FundTheme.gold.opacity(0.5)
: FundTheme.overlay08,
lineWidth: 0.8
)
)
)
}
.frame(maxWidth: .infinity)
}
private func beginEdit() {
let p = store.goldPosition.position
gramsText = p.grams.map { String(format: "%.2f", $0) } ?? ""
costText = p.costPerGram.map { String(format: "%.2f", $0) } ?? ""
isEditing = true
focused = .grams
}
private func cancel() {
isEditing = false
}
private func save() {
let g = Double(gramsText.trimmingCharacters(in: .whitespaces))
let c = Double(costText.trimmingCharacters(in: .whitespaces))
let cleanG = (g.map { $0 > 0 } ?? false) ? g : nil
let cleanC = (c.map { $0 > 0 } ?? false) ? c : nil
store.goldPosition.update(grams: cleanG, costPerGram: cleanC)
isEditing = false
}
private func clear() {
store.goldPosition.update(grams: nil, costPerGram: nil)
isEditing = false
}
}

237
Sources/ui/GoldView.swift Normal file
View File

@ -0,0 +1,237 @@
//
// GoldView.swift
// plugin v0.3
//
// tab Alipay-style layout:
//
//
// 99 AU9999.SGE 04-25 hero
// 1040.00 +6.75 +0.65%
// 1039.90 · 1044.00 · 1034.00
//
// intraday line chart chart
// 20:00 02:30/09:00 15:30
//
// GoldPositionCard
//
// [ pill] [ pill] reference
//
import SwiftUI
struct GoldView: View {
@ObservedObject var store: FundStore
var body: some View {
ScrollView {
VStack(spacing: 8) {
hero
.padding(.horizontal, 16)
.padding(.top, 4)
chart
.padding(.horizontal, 16)
sessionAxisStrip
.padding(.horizontal, 16)
GoldPositionCard(store: store)
.padding(.horizontal, 16)
referencePills
.padding(.horizontal, 16)
Spacer(minLength: 8)
}
.padding(.bottom, 12)
}
}
// MARK: - Hero (AU9999 spot)
private var hero: some View {
let q = store.spotGold
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 6) {
Circle()
.fill(FundTheme.gold)
.frame(width: 6, height: 6)
.shadow(color: FundTheme.gold.opacity(0.6), radius: 4)
Text("国内金价")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(FundTheme.gold.opacity(0.85))
.tracking(0.5)
Text("AU9999.SGE")
.font(.system(size: 9.5, design: .monospaced))
.foregroundColor(FundTheme.fg45)
Spacer()
if let t = q?.updatedAt {
Text(timeOnly(t))
.font(.system(size: 10, design: .monospaced))
.foregroundColor(FundTheme.fg45)
}
}
HStack(alignment: .lastTextBaseline, spacing: 6) {
if let q {
Text(String(format: "%.2f", q.last))
.font(.system(size: 30, weight: .bold))
.foregroundColor(.upDown(q.change))
.monospacedDigit()
Text(FundFormat.money(q.change))
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.upDown(q.change))
.monospacedDigit()
Text(FundFormat.percent(q.changeRate))
.font(.system(size: 12, weight: .semibold))
.foregroundColor(.upDown(q.change))
.monospacedDigit()
Spacer()
Text("元/克")
.font(.system(size: 11))
.foregroundColor(FundTheme.fg55)
} else {
Text("--")
.font(.system(size: 30, weight: .bold))
.foregroundColor(FundTheme.fg40)
Text("加载中…")
.font(.system(size: 11))
.foregroundColor(FundTheme.fg40)
}
}
.padding(.top, 1)
if let q {
HStack(alignment: .top, spacing: 6) {
metaItem("今开", String(format: "%.2f", q.open))
metaItem("最高", String(format: "%.2f", q.high))
metaItem("最低", String(format: "%.2f", q.low))
metaItem("昨收", String(format: "%.2f", q.prevClose))
}
.padding(.top, 4)
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(
LinearGradient(
colors: [
FundTheme.gold.opacity(0.08),
FundTheme.gold.opacity(0.02)
],
startPoint: .top, endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(FundTheme.gold.opacity(0.22), lineWidth: 0.8)
)
)
}
/// Stacked vertical (label tiny on top, value below) so 4-digit
/// values like 1039.90 don't wrap mid-number when packed across the
/// 380pt panel.
private func metaItem(_ k: String, _ v: String) -> some View {
VStack(alignment: .leading, spacing: 1) {
Text(k)
.font(.system(size: 9.5))
.foregroundColor(FundTheme.fg45)
Text(v)
.font(.system(size: 11.5, weight: .medium))
.foregroundColor(FundTheme.fgPrimary)
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.85)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private func timeOnly(_ d: Date) -> String {
let f = DateFormatter()
f.locale = Locale(identifier: "en_US_POSIX")
f.timeZone = TimeZone(identifier: "Asia/Shanghai")
f.dateFormat = "MM-dd HH:mm"
return f.string(from: d)
}
// MARK: - Chart (intraday minute line AU0)
private var chart: some View {
let prices = store.goldMinuteLine.map(\.price)
return SparkLine(
values: prices,
lineColor: FundTheme.gold,
fillColor: FundTheme.gold,
pulseColor: FundTheme.gold
)
.frame(height: 110)
.padding(8)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(Color.white.opacity(0.02))
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(Color.white.opacity(0.05), lineWidth: 0.5)
)
)
}
// MARK: - Session axis (replaces 1/3/1/ with trade-session anchors)
private var sessionAxisStrip: some View {
HStack {
Text("20:00")
Spacer()
Text("02:30 / 09:00")
Spacer()
Text("15:30")
}
.font(.system(size: 10, design: .monospaced))
.foregroundColor(FundTheme.fg45)
.padding(.horizontal, 6)
.padding(.top, -2)
}
// MARK: - Reference pills
private var referencePills: some View {
HStack(spacing: 8) {
referencePill(source: .london)
referencePill(source: .comex)
}
}
private func referencePill(source: GoldQuote.Source) -> some View {
let q = store.goldQuotes[source]
return VStack(alignment: .leading, spacing: 4) {
Text(source.displayName)
.font(.system(size: 10, weight: .medium))
.foregroundColor(FundTheme.fg55)
if let q {
Text("$\(String(format: "%.2f", q.last))")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.monospacedDigit()
if let r = q.changeRate {
Text(FundFormat.percent(r))
.font(.system(size: 10.5, weight: .semibold))
.foregroundColor(.upDown(r))
.monospacedDigit()
}
} else {
Text("--")
.font(.system(size: 13, weight: .bold))
.foregroundColor(FundTheme.fg40)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 10)
.fill(FundTheme.overlay04)
.overlay(
RoundedRectangle(cornerRadius: 10)
.stroke(FundTheme.overlay08, lineWidth: 0.5)
)
)
}
}

View File

@ -0,0 +1,41 @@
//
// HeaderSlotView.swift
// plugin
//
// 20×20 icon that lives in the notch header bar. We can't fit text
// in 20pt, so the visual contract is: solid icon = idle / red icon
// = portfolio down today / green icon = portfolio up today. Tap
// opens the expanded panel.
//
import SwiftUI
struct HeaderSlotView: View {
@ObservedObject var store: FundStore = .shared
var body: some View {
ZStack {
Image(systemName: "chart.line.uptrend.xyaxis")
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(tint)
.frame(width: 20, height: 20)
}
}
/// Composite tint: bias toward red/green when the day's average
/// estimated rate across watchlist funds has a sign. Falls back
/// to white when no estimates yet (e.g. first launch, off-hours).
private var tint: Color {
let rates = store.estimates.values.compactMap { $0.estimatedRate }
guard !rates.isEmpty else { return .white.opacity(0.85) }
let avg = rates.reduce(0, +) / Double(rates.count)
if avg > 0.05 {
// Chinese convention: red = up.
return Color(red: 1.0, green: 0.30, blue: 0.30)
} else if avg < -0.05 {
// Green = down.
return Color(red: 0.20, green: 0.80, blue: 0.40)
}
return .white.opacity(0.85)
}
}

View File

@ -0,0 +1,360 @@
//
// HoldingsView.swift
// plugin v0.2
//
// tab. Hero card with + / / ,
// followed by a scrollable list of fund rows. Falls back to an
// "empty state" when no funds are added (matches design).
//
import SwiftUI
struct HoldingsView: View {
@ObservedObject var store: FundStore
let onAddTap: () -> Void
@State private var editingCode: String? = nil
var body: some View {
if store.watchlist.funds.isEmpty {
emptyState
} else {
content
}
}
// MARK: - Empty
private var emptyState: some View {
VStack(spacing: 12) {
Spacer()
ZStack {
Circle()
.fill(FundTheme.overlay04)
.frame(width: 48, height: 48)
Image(systemName: "magnifyingglass")
.font(.system(size: 20))
.foregroundColor(FundTheme.fg45)
}
Text("还没添加自选基金")
.font(.system(size: 12.5))
.foregroundColor(FundTheme.fg35)
Button(action: onAddTap) {
Text("去添加")
.font(.system(size: 12.5, weight: .semibold))
.foregroundColor(.black)
.padding(.horizontal, 18)
.frame(height: 30)
.background(Capsule().fill(FundTheme.lime))
}
.buttonStyle(.plain)
Spacer()
}
.frame(maxWidth: .infinity)
}
// MARK: - Hero + list
private var content: some View {
VStack(spacing: 0) {
heroCard
.padding(.horizontal, 16)
.padding(.top, 4)
.padding(.bottom, 8)
columnHeader
.padding(.horizontal, 18)
.padding(.bottom, 4)
ScrollView {
LazyVStack(spacing: 2) {
ForEach(store.watchlist.funds) { f in
VStack(spacing: 6) {
HoldingRow(
fund: f,
estimate: store.estimates[f.code],
onTap: {
editingCode = (editingCode == f.code) ? nil : f.code
},
onRemove: {
if editingCode == f.code { editingCode = nil }
store.watchlist.remove(code: f.code)
}
)
if editingCode == f.code {
PositionEditor(
fund: f,
onSave: { shares, cost in
store.watchlist.updatePosition(
code: f.code, shares: shares, costNav: cost
)
editingCode = nil
},
onCancel: { editingCode = nil }
)
.padding(.horizontal, 4)
}
}
.padding(.horizontal, 8)
}
}
.padding(.bottom, 4)
}
}
}
// MARK: - Hero card
private var heroCard: some View {
let mv = store.totalMarketValue
let cost = store.totalCost
let day = store.totalDayPnL
let total: Double? = {
guard let mv, let cost else { return nil }
return mv - cost
}()
let totalRate: Double? = {
guard let total, let cost, cost > 0 else { return nil }
return total / cost * 100
}()
return VStack(alignment: .leading, spacing: 8) {
Text(mv == nil ? "自选监控" : "总市值")
.font(.system(size: 11))
.foregroundColor(FundTheme.fg55)
.tracking(0.3)
HStack(alignment: .lastTextBaseline, spacing: 6) {
if let mv {
Text("¥ \(FundFormat.unsignedMoney(mv))")
.font(.system(size: 28, weight: .bold))
.foregroundColor(FundTheme.fgPrimary)
.monospacedDigit()
Text("CNY")
.font(.system(size: 13, weight: .medium))
.foregroundColor(FundTheme.fg55)
} else {
Text("\(store.watchlist.funds.count) 只基金")
.font(.system(size: 22, weight: .bold))
.foregroundColor(FundTheme.fgPrimary)
Text("· 添加持仓查看盈亏")
.font(.system(size: 11))
.foregroundColor(FundTheme.fg45)
}
}
if mv != nil {
HStack(spacing: 18) {
statBlock(label: "今日盈亏", value: day)
statBlock(label: "累计盈亏", value: total)
statBlock(label: "收益率", value: totalRate, isPercent: true)
}
.padding(.top, 4)
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(
LinearGradient(
colors: [
FundTheme.lime.opacity(0.10),
FundTheme.lime.opacity(0.02)
],
startPoint: .top,
endPoint: .bottom
)
)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(FundTheme.lime.opacity(0.18), lineWidth: 0.8)
)
)
}
/// Column header strip aligning with HoldingRow's two stat columns.
/// Without it the user can't tell which number is vs both
/// look identical in a single row, and uses the same affordance.
private var columnHeader: some View {
HStack(spacing: 8) {
Spacer(minLength: 0)
Text("今日")
.frame(width: 80, alignment: .trailing)
Text("累计")
.frame(width: 80, alignment: .trailing)
}
.font(.system(size: 10))
.foregroundColor(FundTheme.fg45)
}
private func statBlock(label: String, value: Double?, isPercent: Bool = false) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(label)
.font(.system(size: 10.5))
.foregroundColor(FundTheme.fg45)
if let v = value {
Text(isPercent ? FundFormat.percent(v) : FundFormat.money(v))
.font(.system(size: 13, weight: .semibold))
.foregroundColor(.upDown(v))
.monospacedDigit()
} else {
Text("")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(FundTheme.fg40)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
// MARK: - Holding row
private struct HoldingRow: View {
let fund: WatchlistFund
let estimate: FundEstimate?
let onTap: () -> Void
let onRemove: () -> Void
@State private var isHovered = false
var body: some View {
Button(action: onTap) {
content
}
.buttonStyle(.plain)
}
/// Three-column layout matching :
/// [name + ¥market value · ] [ ¥ / %] [ ¥ / %]
/// When no position is set, left side falls back to code · NAV and
/// the column is hidden (no cost basis to compute against).
private var content: some View {
HStack(alignment: .center, spacing: 8) {
// Left: name + market value (or code) · realtime nav
VStack(alignment: .leading, spacing: 3) {
Text(fund.name)
.font(.system(size: 13, weight: .semibold))
.foregroundColor(FundTheme.fgPrimary)
.lineLimit(1)
.truncationMode(.tail)
HStack(spacing: 4) {
if let mv = marketValue {
Text("¥ \(FundFormat.unsignedMoney(mv))")
.font(.system(size: 11, weight: .medium))
.foregroundColor(FundTheme.fg55)
.monospacedDigit()
} else {
Text(fund.code)
.font(.system(size: 10.5, design: .monospaced))
.foregroundColor(FundTheme.fg40)
}
if let nav = estimate?.bestNav, nav > 0 {
Text("·")
.font(.system(size: 10))
.foregroundColor(FundTheme.fg35)
Text(FundFormat.nav(nav))
.font(.system(size: 10.5, design: .monospaced))
.foregroundColor(FundTheme.fg45)
.monospacedDigit()
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
// Middle: (today's gain). Always rendered; falls back
// to em-dash when estimate is missing so columns stay aligned
// with the header strip above the list.
statColumn(amount: todayPnL, rate: estimate?.estimatedRate)
// Right: (cumulative). Always rendered for column
// alignment shows a dim em-dash when the user hasn't
// entered cost basis yet so the user knows where to look
// once they fill it in.
statColumn(amount: cumulativePnL, rate: cumulativeRate)
if isHovered {
Button(action: onRemove) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 14))
.foregroundColor(FundTheme.fg45)
}
.buttonStyle(.plain)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(isHovered ? FundTheme.overlay04 : Color.clear)
)
.onHover { isHovered = $0 }
}
/// One stat column: ¥ amount on top (large bold), % rate below
/// (small). When ¥ is unavailable (no position) but rate is known,
/// promote the rate to the headline slot. When neither is known,
/// dim em-dashes preserve column alignment with the header strip.
/// Width 80pt so 5-digit ¥ amounts like "+4,329.79" don't truncate.
@ViewBuilder
private func statColumn(amount: Double?, rate: Double?) -> some View {
VStack(alignment: .trailing, spacing: 2) {
if let a = amount {
Text(FundFormat.money(a))
.font(.system(size: 13, weight: .bold))
.foregroundColor(.upDown(a))
.monospacedDigit()
.lineLimit(1)
.minimumScaleFactor(0.85)
if let r = rate {
Text(FundFormat.percent(r))
.font(.system(size: 10.5, weight: .medium))
.foregroundColor(.upDown(r))
.monospacedDigit()
.lineLimit(1)
}
} else if let r = rate {
Text(FundFormat.percent(r))
.font(.system(size: 13, weight: .bold))
.foregroundColor(.upDown(r))
.monospacedDigit()
.lineLimit(1)
} else {
Text("")
.font(.system(size: 13, weight: .semibold))
.foregroundColor(FundTheme.fg35)
}
}
.frame(width: 80, alignment: .trailing)
}
// MARK: - Computed P&L
/// Today's market value = shares × current best NAV.
/// Nil when shares unset (we won't show ¥ holding amount then).
private var marketValue: Double? {
guard let shares = fund.shares, shares > 0,
let est = estimate, est.bestNav > 0 else { return nil }
return shares * est.bestNav
}
/// Today's ¥ P&L: shares × (intraday est nav - last published nav).
private var todayPnL: Double? {
guard let shares = fund.shares, shares > 0 else { return nil }
guard let est = estimate else { return nil }
let estNav = est.estimatedNav ?? est.publishedNav
return (estNav - est.publishedNav) * shares
}
/// Cumulative ¥ P&L: shares × (current best nav - cost basis).
private var cumulativePnL: Double? {
guard let shares = fund.shares, let cost = fund.costNav,
let est = estimate else { return nil }
return (est.bestNav - cost) * shares
}
/// Cumulative % return: (current - cost) / cost × 100.
private var cumulativeRate: Double? {
guard let cost = fund.costNav, cost > 0,
let est = estimate else { return nil }
return (est.bestNav - cost) / cost * 100
}
}

View File

@ -0,0 +1,141 @@
//
// PositionEditor.swift
// plugin v0.2
//
// Inline editor for a holding's shares + cost basis. Opened from the
// holding row's "edit" button, dismissed by Save / Cancel.
//
// Why inline (rather than a separate window): plugins can't open NSWindows
// cleanly without going through the host. Inline editing inside the
// panel keeps the experience contained and the user is already
// looking at this row.
//
import SwiftUI
struct PositionEditor: View {
let fund: WatchlistFund
let onSave: (Double?, Double?) -> Void
let onCancel: () -> Void
@State private var sharesText: String = ""
@State private var costText: String = ""
@FocusState private var focused: Field?
enum Field { case shares, cost }
var body: some View {
VStack(alignment: .leading, spacing: 10) {
HStack {
Text("编辑持仓")
.font(.system(size: 12, weight: .semibold))
.foregroundColor(FundTheme.fgPrimary)
Spacer()
Button(action: onCancel) {
Image(systemName: "xmark")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(FundTheme.fg55)
.frame(width: 18, height: 18)
.background(Circle().fill(FundTheme.overlay06))
}
.buttonStyle(.plain)
}
HStack(spacing: 8) {
inputField(
label: "持仓份额",
text: $sharesText,
placeholder: "0",
field: .shares
)
inputField(
label: "成本(元/份)",
text: $costText,
placeholder: "1.0000",
field: .cost
)
}
HStack(spacing: 6) {
Button(action: clear) {
Text("清除")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(FundTheme.fg55)
.padding(.horizontal, 14)
.frame(height: 28)
.background(
Capsule().fill(FundTheme.overlay06)
)
}
.buttonStyle(.plain)
Spacer()
Button(action: save) {
Text("保存")
.font(.system(size: 11, weight: .semibold))
.foregroundColor(.black)
.padding(.horizontal, 14)
.frame(height: 28)
.background(Capsule().fill(FundTheme.lime))
}
.buttonStyle(.plain)
}
}
.padding(12)
.background(
RoundedRectangle(cornerRadius: 12)
.fill(FundTheme.overlay06)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(FundTheme.lime.opacity(0.18), lineWidth: 0.8)
)
)
.onAppear {
// Pre-fill if values already exist
if let s = fund.shares { sharesText = String(format: "%.2f", s) }
if let c = fund.costNav { costText = String(format: "%.4f", c) }
focused = .shares
}
}
private func inputField(label: String, text: Binding<String>, placeholder: String, field: Field) -> some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.system(size: 9.5))
.foregroundColor(FundTheme.fg55)
TextField(placeholder, text: text)
.textFieldStyle(.plain)
.font(.system(size: 12, design: .monospaced))
.foregroundColor(FundTheme.fgPrimary)
.focused($focused, equals: field)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(Color.black.opacity(0.4))
.overlay(
RoundedRectangle(cornerRadius: 6)
.stroke(
focused == field
? FundTheme.lime.opacity(0.5)
: FundTheme.overlay08,
lineWidth: 0.8
)
)
)
}
.frame(maxWidth: .infinity)
}
private func save() {
let shares = Double(sharesText.trimmingCharacters(in: .whitespaces))
let cost = Double(costText.trimmingCharacters(in: .whitespaces))
// Treat 0 / negative as "clear" too.
let cleanShares = (shares.map { $0 > 0 } ?? false) ? shares : nil
let cleanCost = (cost.map { $0 > 0 } ?? false) ? cost : nil
onSave(cleanShares, cleanCost)
}
private func clear() {
onSave(nil, nil)
}
}

125
Sources/ui/SparkLine.swift Normal file
View File

@ -0,0 +1,125 @@
//
// SparkLine.swift
// plugin v0.2
//
// Pure-SwiftUI port of the design's <svg> sparkline for the gold
// chart. Handles:
// - smooth quadratic curve through points
// - filled area under the curve with a vertical gradient
// - pulsing "current point" indicator
// - dashed midline grid
// - empty / 1-point degenerate cases
//
import SwiftUI
struct SparkLine: View {
let values: [Double]
let lineColor: Color
let fillColor: Color
let pulseColor: Color
var body: some View {
GeometryReader { geo in
let W = geo.size.width
let H = geo.size.height
let padX: CGFloat = 8
let padY: CGFloat = 14
ZStack {
// dashed midline
Path { p in
p.move(to: CGPoint(x: 0, y: H / 2))
p.addLine(to: CGPoint(x: W, y: H / 2))
}
.stroke(
Color.white.opacity(0.04),
style: StrokeStyle(lineWidth: 0.5, lineCap: .round, dash: [2, 4])
)
// bail when not enough data
if values.count >= 2,
let maxV = values.max(),
let minV = values.min(),
maxV != minV {
let xs: (Int) -> CGFloat = { i in
padX + (CGFloat(i) / CGFloat(values.count - 1)) * (W - padX * 2)
}
let ys: (Double) -> CGFloat = { v in
let t = (v - minV) / (maxV - minV)
return padY + (1 - CGFloat(t)) * (H - padY * 2)
}
// area fill
Path { p in
p.move(to: CGPoint(x: xs(0), y: ys(values[0])))
for i in 1..<values.count {
p.addLine(to: CGPoint(x: xs(i), y: ys(values[i])))
}
p.addLine(to: CGPoint(x: xs(values.count - 1), y: H))
p.addLine(to: CGPoint(x: xs(0), y: H))
p.closeSubpath()
}
.fill(
LinearGradient(
colors: [fillColor.opacity(0.45), fillColor.opacity(0)],
startPoint: .top,
endPoint: .bottom
)
)
// line
Path { p in
p.move(to: CGPoint(x: xs(0), y: ys(values[0])))
for i in 1..<values.count {
p.addLine(to: CGPoint(x: xs(i), y: ys(values[i])))
}
}
.stroke(
LinearGradient(
colors: [lineColor.opacity(0.9), lineColor],
startPoint: .leading,
endPoint: .trailing
),
style: StrokeStyle(lineWidth: 1.6, lineCap: .round, lineJoin: .round)
)
// current point dot + pulsing halo
let lastX = xs(values.count - 1)
let lastY = ys(values[values.count - 1])
Circle()
.fill(pulseColor)
.frame(width: 7, height: 7)
.position(x: lastX, y: lastY)
PulsingRing(color: pulseColor)
.frame(width: 18, height: 18)
.position(x: lastX, y: lastY)
} else {
// degenerate: flat line in the middle
Path { p in
p.move(to: CGPoint(x: padX, y: H / 2))
p.addLine(to: CGPoint(x: W - padX, y: H / 2))
}
.stroke(lineColor.opacity(0.5), style: StrokeStyle(lineWidth: 1.2))
}
}
}
}
}
private struct PulsingRing: View {
let color: Color
@State private var pulse = false
var body: some View {
Circle()
.stroke(color.opacity(pulse ? 0 : 0.6), lineWidth: 1.2)
.scaleEffect(pulse ? 1.4 : 0.6)
.onAppear {
withAnimation(.easeOut(duration: 2.0).repeatForever(autoreverses: false)) {
pulse = true
}
}
}
}

88
Sources/ui/Theme.swift Normal file
View File

@ -0,0 +1,88 @@
//
// Theme.swift
// plugin v0.2
//
// Single source of truth for the design's colour tokens. Keeps the
// per-view code from re-deriving the same hex values.
//
import SwiftUI
enum FundTheme {
// Lime accent same value as the design's `#d4ff3a`.
static let lime = Color(red: 0xD4/255, green: 0xFF/255, blue: 0x3A/255)
// Gold accent for the gold hero card and chart.
static let gold = Color(red: 0xFF/255, green: 0xC4/255, blue: 0x54/255)
// China convention: red = up, green = down.
static let upRed = Color(red: 0xFF/255, green: 0x5E/255, blue: 0x5E/255)
static let downGreen = Color(red: 0x2C/255, green: 0xD4/255, blue: 0x7E/255)
// Panel background pure black with a hint of warmth.
static let panelBg = Color(red: 0x05/255, green: 0x05/255, blue: 0x05/255)
// Subtle white overlays used everywhere.
static let overlay04 = Color.white.opacity(0.04)
static let overlay06 = Color.white.opacity(0.06)
static let overlay08 = Color.white.opacity(0.08)
static let overlay12 = Color.white.opacity(0.12)
static let overlay18 = Color.white.opacity(0.18)
// Foreground tints
static let fgPrimary = Color(red: 0xF4/255, green: 0xF4/255, blue: 0xF5/255)
static let fg85 = Color.white.opacity(0.85)
static let fg70 = Color.white.opacity(0.7)
static let fg55 = Color.white.opacity(0.55)
static let fg45 = Color.white.opacity(0.45)
static let fg40 = Color.white.opacity(0.4)
static let fg35 = Color.white.opacity(0.35)
}
// MARK: - Color helper
extension Color {
/// Pick red/green based on a signed value. `0` returns a neutral white.
/// Chinese convention: positive = red, negative = green.
static func upDown(_ value: Double) -> Color {
if value > 0.0001 { return FundTheme.upRed }
if value < -0.0001 { return FundTheme.downGreen }
return FundTheme.fg70
}
}
// MARK: - Number formatting
enum FundFormat {
/// "+0.53%" / "-1.20%" / "0.00%"
static func percent(_ value: Double, decimals: Int = 2) -> String {
let sign = value > 0 ? "+" : ""
return "\(sign)\(String(format: "%.\(decimals)f", value))%"
}
/// "+1,234.56" / "-1,234.56" / "0.00"
static func money(_ value: Double, decimals: Int = 2) -> String {
let sign = value > 0 ? "+" : ""
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = decimals
formatter.maximumFractionDigits = decimals
let abs = formatter.string(from: NSNumber(value: Swift.abs(value))) ?? "0"
if value < 0 { return "-\(abs)" }
return "\(sign)\(abs)"
}
/// "1,234.56" no sign, used for ""-like displays.
static func unsignedMoney(_ value: Double, decimals: Int = 2) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .decimal
formatter.minimumFractionDigits = decimals
formatter.maximumFractionDigits = decimals
return formatter.string(from: NSNumber(value: value)) ?? "0"
}
/// "1.7669" fixed decimals for NAV display.
static func nav(_ value: Double, decimals: Int = 4) -> String {
String(format: "%.\(decimals)f", value)
}
}

67
build.sh Executable file
View File

@ -0,0 +1,67 @@
#!/bin/bash
# Build the 盯基金 plugin as a .bundle for Mio Island.
#
# Usage:
# ./build.sh # produce build/fund.bundle + build/fund.zip
# ./build.sh install # also copy bundle to ~/.config/codeisland/plugins/
set -e
set -o pipefail
PLUGIN_NAME="fund"
MODULE_NAME="FundPlugin"
BUNDLE_NAME="${PLUGIN_NAME}.bundle"
BUILD_DIR="build"
SOURCES=$(find Sources -name "*.swift" -type f)
SOURCE_COUNT=$(echo "$SOURCES" | wc -l | tr -d ' ')
echo "Building ${PLUGIN_NAME} plugin (${SOURCE_COUNT} swift files)..."
rm -rf "${BUILD_DIR}"
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS"
# arm64-only is fine for v0.1 — Mio Island host requires macOS 15+
# which means Apple Silicon dominant, and the Sina/Eastmoney HTTP
# bits are platform-agnostic. Add x86_64 + lipo if Intel users surface.
swiftc \
-emit-library \
-module-name "${MODULE_NAME}" \
-target arm64-apple-macos15.0 \
-sdk "$(xcrun --show-sdk-path)" \
-O \
-o "${BUILD_DIR}/${BUNDLE_NAME}/Contents/MacOS/${MODULE_NAME}" \
${SOURCES}
cp Info.plist "${BUILD_DIR}/${BUNDLE_NAME}/Contents/"
if [ -d "Resources" ] && [ "$(ls -A Resources 2>/dev/null)" ]; then
mkdir -p "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources"
cp -R Resources/* "${BUILD_DIR}/${BUNDLE_NAME}/Contents/Resources/"
fi
# Ad-hoc sign the whole bundle. --deep covers any future nested
# resources without needing the script changed.
codesign --force --deep --sign - "${BUILD_DIR}/${BUNDLE_NAME}"
echo "✓ Built ${BUILD_DIR}/${BUNDLE_NAME}"
# zip for marketplace upload (顶层是 .bundle 目录, 没有 ._* 垃圾)
cd "${BUILD_DIR}"
rm -f "${PLUGIN_NAME}.zip"
zip -rq "${PLUGIN_NAME}.zip" "${BUNDLE_NAME}"
cd ..
echo "✓ Created ${BUILD_DIR}/${PLUGIN_NAME}.zip"
if [ "${1:-}" = "install" ]; then
PLUGIN_DIR="${HOME}/.config/codeisland/plugins"
mkdir -p "${PLUGIN_DIR}"
rm -rf "${PLUGIN_DIR}/${BUNDLE_NAME}"
cp -R "${BUILD_DIR}/${BUNDLE_NAME}" "${PLUGIN_DIR}/"
echo "✓ Installed to ${PLUGIN_DIR}/${BUNDLE_NAME}"
echo " Restart Mio Island (Cmd+Q + reopen) to load the new build."
else
echo ""
echo "Install locally:"
echo " ./build.sh install"
fi