diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f99849a --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon? + +# Xcode +build/ +DerivedData/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.xccheckout +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# XcodeGen-managed: project file is generated from project.yml on demand. +macapp/Sidecar.xcodeproj/ +macapp/Sidecar.xcworkspace/ + +# Swift Package Manager +.swiftpm/ +.build/ +Packages/ +Package.resolved + +# Go +agent/bin/ +agent/dist/ +*.test +*.out +coverage.txt +vendor/ + +# Editors +.vscode/ +.idea/ +*.swp +*~ + +# Env +.env +.env.local diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec51bc3 --- /dev/null +++ b/README.md @@ -0,0 +1,87 @@ +# Sidecar + +一个面向 Tailscale 网络的轻量级设备监控 dashboard,原生 macOS 形态,桌面 widget 优先。 + +## 起源 + +我有好几台设备同时挂在 Tailscale 网络里 —— Win、Mac、Linux 都有,我希望能在主力 Mac 上有一个常驻的面板,持续看到这些设备的基础状态(在线/离线、CPU/内存/流量等),必要时可以展开看详情。 + +我没找到特别趁手的现成方案 —— 要么太重(Grafana 全家桶),要么只看在线状态没看系统指标,要么不是 macOS 原生形态。所以做这个: + +- **形态贴合 macOS** — 桌面 widget 驻留,可选 menu bar,主 app 用于详情展开 +- **科技美学** — 暗色为主、等宽数字、网格背景、细发光描边,多个 theme 可切换 +- **多视图** — 设备网格概览 / 单设备详情卡片 / widget 时间线 + +## 架构总览 + +``` +┌─────────────────────┐ tailnet (MagicDNS) ┌───────────────────────┐ +│ sidecar-agent │ │ sidecar-mac │ +│ (Win/Mac/Linux) │ ─── HTTP /info /metrics ────────► │ SwiftUI + WidgetKit │ +│ │ │ │ +│ - 采集 CPU/Mem │ │ - 设备网格 │ +│ - 采集 Disk/Net │ │ - 详情卡片 │ +│ - 暴露 :8765 │ │ - 桌面 widgets │ +└─────────────────────┘ └───────────────────────┘ + ▲ │ + │ │ + └────────── 都通过 tailscaled 加入 tailnet ──────────────────┘ +``` + +- agent 是一个 Go 写的单二进制,跨平台编译,依赖宿主机已经装好的 Tailscale 客户端(tailscaled), + 只在 `tailscale0` 接口监听固定端口,不暴露公网。 +- mac app 通过本地 `tailscale status --json` 列出 peer,按约定端口探测,发现 agent 后开始轮询。 +- widget 通过 App Group 共享容器读 mac app 写下的最新快照。 + +## 仓库结构 + +``` +sidecar-mac/ +├── agent/ Go 写的跨平台采集 agent(单二进制) +├── macapp/ Xcode 工程:主 app + widget extension +├── docs/ +│ ├── architecture.md 架构与设计细节 +│ └── protocol.md agent <-> app 的 JSON 协议草案 +└── README.md +``` + +## 当前状态 + +仓库刚起步,目前只有架构与协议文档。下一步按 milestone 推进。 + +## Roadmap + +- **M0 — 文档与协议** *(当前)* + - [x] README / 架构 / 协议草案 +- **M1 — agent 最小可用** + - [ ] Go 项目骨架 + gopsutil 采集 + - [ ] HTTP server 绑 tailscale0 监听 :8765 + - [ ] `/info`、`/metrics`、`/healthz` 三个端点 + - [ ] Win / Mac / Linux 交叉编译 +- **M2 — Mac 主 app 骨架** + - [ ] SwiftUI 工程 + 主窗口 + - [ ] PeerDiscovery:解析 `tailscale status --json` + 端口探测 + - [ ] Poller:定时抓 `/metrics` + - [ ] 设备网格视图(mock + 真实数据切换) +- **M3 — Widget extension** + - [ ] App Group 共享容器 + - [ ] Small / Medium / Large 三档 widget + - [ ] TimelineProvider 节流(5–15 分钟) +- **M4 — 视觉与 theme** + - [ ] 主题系统(暗色 + 2–3 个 accent) + - [ ] 单设备详情卡(迷你火花线 / 流量图) +- **M5 — 打包与分发** + - [ ] agent installer / launchd / systemd / Windows service + - [ ] mac app 签名与公证 + +## 非目标 + +- 不做 alerting / 通知中心(先把"看"做好) +- 不做长周期历史存储(短窗口环形缓冲即可,长历史去用 Grafana) +- 不做 Tailscale 控制平面管理(不接管 ACL / device approval) +- 不做 Win / Linux 端的 dashboard(其它平台只跑 agent) + +## 文档 + +- [架构与设计](docs/architecture.md) +- [agent 协议草案](docs/protocol.md) diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..e336de3 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,77 @@ +# sidecar-agent + +Cross-platform metrics agent for Sidecar. Single Go binary; runs on every +device in the tailnet you want to monitor. + +See [../docs/protocol.md](../docs/protocol.md) for the JSON contract and +[../docs/architecture.md](../docs/architecture.md) for design background. + +## Build + +Local build for the current platform: + +```sh +go build ./cmd/sidecar-agent +``` + +Cross-compile all four targets: + +```sh +for tuple in linux/amd64 darwin/arm64 darwin/amd64 windows/amd64; do + GOOS=${tuple%/*} GOARCH=${tuple#*/} CGO_ENABLED=0 \ + go build -ldflags="-s -w -X main.Version=0.1.0" \ + -o "bin/sidecar-agent-${tuple%/*}-${tuple#*/}" ./cmd/sidecar-agent +done +``` + +CGO is not required; binaries are statically linked. + +## Run + +By default the agent auto-detects the host's Tailscale interface (any +interface with an IP in `100.64.0.0/10` or `fd7a:115c:a1e0::/48`) and binds +**only** to those addresses on port 8765: + +```sh +./sidecar-agent +``` + +For local development without Tailscale, override with `--bind`: + +```sh +./sidecar-agent --bind 127.0.0.1 --port 18765 +``` + +Flags: + +| Flag | Default | Notes | +| -------------- | ------- | -------------------------------------------------------- | +| `--port` | `8765` | TCP port for each Tailscale address | +| `--bind` | *(auto)*| Comma-separated `host` or `host:port` overrides | +| `--sample` | `1s` | Metrics sampling interval | +| `--print-bind` | `false` | Print the addresses that would be bound and exit | + +## Endpoints + +| Path | Returns | +| ----------- | ------------------------------------------------ | +| `/healthz` | `200 ok` plain text | +| `/info` | Static host / agent / tailnet info (JSON) | +| `/metrics` | Latest snapshot (JSON, see protocol.md) | + +All responses include `X-Sidecar-Agent: v0.1` (protocol version). + +## Tailscale interface detection + +The agent identifies the Tailscale interface by **IP range**, not by name — +so it works whether the OS named it `tailscale0` (Linux), `utun*` (older +macOS), or `Tailscale` (Windows). If your Tailscale node has an IP in the +CGNAT range, the agent will find it. + +## Security + +- The agent never binds to `0.0.0.0`. It refuses to start if the only + available addresses are non-Tailscale ones (use `--bind` explicitly to + opt out). +- All endpoints are read-only `GET`. There is no application-layer auth in + v0; tailnet ACLs are the trust boundary. diff --git a/agent/cmd/sidecar-agent/main.go b/agent/cmd/sidecar-agent/main.go new file mode 100644 index 0000000..30f1247 --- /dev/null +++ b/agent/cmd/sidecar-agent/main.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "flag" + "fmt" + "log" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/firenzemc/sidecar-mac/agent/internal/collector" + "github.com/firenzemc/sidecar-mac/agent/internal/netif" + "github.com/firenzemc/sidecar-mac/agent/internal/server" +) + +// Version is the agent's semver string. Override with -ldflags="-X main.Version=...". +var Version = "0.1.0-dev" + +func main() { + var ( + port = flag.Int("port", 8765, "TCP port to bind on each Tailscale address") + bind = flag.String("bind", "", "comma-separated host:port overrides (skips Tailscale auto-detect)") + sample = flag.Duration("sample", time.Second, "metrics sampling interval") + printOnly = flag.Bool("print-bind", false, "print bind addresses and exit") + ) + flag.Parse() + + addrs, err := resolveBindAddrs(*bind, *port) + if err != nil { + log.Fatalf("bind: %v", err) + } + + if *printOnly { + for _, a := range addrs { + fmt.Println(a) + } + return + } + + ts, _ := netif.Detect() // best-effort, may be nil if --bind override is used + + col := collector.New(*sample) + + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer cancel() + + go col.Run(ctx) + + srv := server.New(Version, col, ts) + log.Printf("sidecar-agent %s starting; bind=%v", Version, addrs) + if err := srv.ListenAndServe(ctx, addrs); err != nil { + log.Fatalf("server: %v", err) + } + log.Printf("sidecar-agent stopped") +} + +// resolveBindAddrs returns the list of host:port to listen on. Either: +// - explicit --bind override: comma-separated list, each entry is "host" or "host:port". +// Bare hosts use the global --port. +// - auto-detected Tailscale addresses (CGNAT v4 and Tailscale ULA v6 on a single interface). +func resolveBindAddrs(override string, port int) ([]string, error) { + if override != "" { + var out []string + for _, raw := range strings.Split(override, ",") { + a := strings.TrimSpace(raw) + if a == "" { + continue + } + if !strings.Contains(a, ":") || (strings.HasPrefix(a, "[") && strings.HasSuffix(a, "]")) { + a = fmt.Sprintf("%s:%d", a, port) + } + out = append(out, a) + } + if len(out) == 0 { + return nil, fmt.Errorf("--bind was set but produced no addresses") + } + return out, nil + } + + ts, err := netif.Detect() + if err != nil { + return nil, fmt.Errorf("auto-detect Tailscale interface: %w (use --bind to override)", err) + } + addrs := ts.BindAddrs(port) + log.Printf("detected Tailscale interface: %s", ts.Summary()) + return addrs, nil +} diff --git a/agent/go.mod b/agent/go.mod new file mode 100644 index 0000000..30684c8 --- /dev/null +++ b/agent/go.mod @@ -0,0 +1,16 @@ +module github.com/firenzemc/sidecar-mac/agent + +go 1.24.7 + +require github.com/shirou/gopsutil/v3 v3.24.5 + +require ( + github.com/go-ole/go-ole v1.2.6 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect + github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect + github.com/shoenig/go-m1cpu v0.1.6 // indirect + github.com/tklauser/go-sysconf v0.3.12 // indirect + github.com/tklauser/numcpus v0.6.1 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/sys v0.20.0 // indirect +) diff --git a/agent/go.sum b/agent/go.sum new file mode 100644 index 0000000..61e6f38 --- /dev/null +++ b/agent/go.sum @@ -0,0 +1,36 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw= +github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= +github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= +github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= +github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= +github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= +github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU= +github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= +github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= +github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/agent/internal/collector/collector.go b/agent/internal/collector/collector.go new file mode 100644 index 0000000..a6cd3ec --- /dev/null +++ b/agent/internal/collector/collector.go @@ -0,0 +1,145 @@ +package collector + +import ( + "context" + "runtime" + "sync/atomic" + "time" + + "github.com/shirou/gopsutil/v3/cpu" + "github.com/shirou/gopsutil/v3/disk" + "github.com/shirou/gopsutil/v3/host" + "github.com/shirou/gopsutil/v3/load" + "github.com/shirou/gopsutil/v3/mem" + gnet "github.com/shirou/gopsutil/v3/net" +) + +const minDiskBytes = 1 << 30 // 1 GiB + +type Collector struct { + interval time.Duration + latest atomic.Pointer[Snapshot] + prevNet map[string]gnet.IOCountersStat + prevTS time.Time +} + +func New(interval time.Duration) *Collector { + if interval <= 0 { + interval = time.Second + } + return &Collector{interval: interval} +} + +func (c *Collector) Latest() *Snapshot { + return c.latest.Load() +} + +func (c *Collector) Run(ctx context.Context) { + c.sampleOnce(ctx) + t := time.NewTicker(c.interval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c.sampleOnce(ctx) + } + } +} + +func (c *Collector) sampleOnce(ctx context.Context) { + now := time.Now().UTC() + snap := &Snapshot{TS: now} + + if up, err := host.UptimeWithContext(ctx); err == nil { + snap.UptimeSeconds = up + } + + cpuPct, _ := cpu.PercentWithContext(ctx, 0, false) + snap.CPU = CPU{Cores: runtime.NumCPU()} + if len(cpuPct) > 0 { + snap.CPU.UsagePercent = roundTo(cpuPct[0], 1) + } + + if vm, err := mem.VirtualMemoryWithContext(ctx); err == nil { + snap.Mem.UsedBytes = vm.Used + snap.Mem.TotalBytes = vm.Total + } + if sm, err := mem.SwapMemoryWithContext(ctx); err == nil { + used, total := sm.Used, sm.Total + snap.Mem.SwapUsedBytes = &used + snap.Mem.SwapTotalBytes = &total + } + + if runtime.GOOS != "windows" { + if l, err := load.AvgWithContext(ctx); err == nil { + snap.Load = &Load{Load1: l.Load1, Load5: l.Load5, Load15: l.Load15} + } + } + + if parts, err := disk.PartitionsWithContext(ctx, false); err == nil { + for _, p := range parts { + usage, err := disk.UsageWithContext(ctx, p.Mountpoint) + if err != nil || usage.Total < minDiskBytes { + continue + } + snap.Disks = append(snap.Disks, Disk{ + Mount: p.Mountpoint, + FSType: p.Fstype, + UsedBytes: usage.Used, + TotalBytes: usage.Total, + }) + } + } + + if counters, err := gnet.IOCountersWithContext(ctx, true); err == nil { + curMap := make(map[string]gnet.IOCountersStat, len(counters)) + for _, c := range counters { + curMap[c.Name] = c + } + dt := now.Sub(c.prevTS).Seconds() + for _, cur := range counters { + if isVirtualNIC(cur.Name) { + continue + } + n := NetIO{ + Name: cur.Name, + RxTotalBytes: cur.BytesRecv, + TxTotalBytes: cur.BytesSent, + } + if prev, ok := c.prevNet[cur.Name]; ok && dt > 0 { + n.RxBps = ratePerSec(cur.BytesRecv, prev.BytesRecv, dt) + n.TxBps = ratePerSec(cur.BytesSent, prev.BytesSent, dt) + } + snap.Net = append(snap.Net, n) + } + c.prevNet = curMap + c.prevTS = now + } + + c.latest.Store(snap) +} + +func ratePerSec(cur, prev uint64, dt float64) uint64 { + if cur < prev || dt <= 0 { + return 0 + } + return uint64(float64(cur-prev) / dt) +} + +func roundTo(v float64, decimals int) float64 { + pow := 1.0 + for i := 0; i < decimals; i++ { + pow *= 10 + } + return float64(int64(v*pow+0.5)) / pow +} + +func isVirtualNIC(name string) bool { + switch name { + case "lo", "lo0": + return true + } + return false +} diff --git a/agent/internal/collector/snapshot.go b/agent/internal/collector/snapshot.go new file mode 100644 index 0000000..dbf05cd --- /dev/null +++ b/agent/internal/collector/snapshot.go @@ -0,0 +1,72 @@ +package collector + +import "time" + +type Snapshot struct { + TS time.Time `json:"ts"` + UptimeSeconds uint64 `json:"uptime_seconds"` + CPU CPU `json:"cpu"` + Mem Mem `json:"mem"` + Load *Load `json:"load"` + Disks []Disk `json:"disks"` + Net []NetIO `json:"net"` +} + +type CPU struct { + UsagePercent float64 `json:"usage_percent"` + Cores int `json:"cores"` +} + +type Mem struct { + UsedBytes uint64 `json:"used_bytes"` + TotalBytes uint64 `json:"total_bytes"` + SwapUsedBytes *uint64 `json:"swap_used_bytes"` + SwapTotalBytes *uint64 `json:"swap_total_bytes"` +} + +type Load struct { + Load1 float64 `json:"load1"` + Load5 float64 `json:"load5"` + Load15 float64 `json:"load15"` +} + +type Disk struct { + Mount string `json:"mount"` + FSType string `json:"fstype"` + UsedBytes uint64 `json:"used_bytes"` + TotalBytes uint64 `json:"total_bytes"` +} + +type NetIO struct { + Name string `json:"name"` + RxBps uint64 `json:"rx_bps"` + TxBps uint64 `json:"tx_bps"` + RxTotalBytes uint64 `json:"rx_total_bytes"` + TxTotalBytes uint64 `json:"tx_total_bytes"` +} + +type Info struct { + Agent AgentInfo `json:"agent"` + Host HostInfo `json:"host"` + Tailnet Tailnet `json:"tailnet"` + Capabilities []string `json:"capabilities"` +} + +type AgentInfo struct { + Version string `json:"version"` + StartedAt time.Time `json:"started_at"` +} + +type HostInfo struct { + Hostname string `json:"hostname"` + OS string `json:"os"` + Arch string `json:"arch"` + Kernel string `json:"kernel"` + Platform string `json:"platform"` +} + +type Tailnet struct { + TailscaleIP4 string `json:"tailscale_ip4"` + TailscaleIP6 string `json:"tailscale_ip6"` + MagicDNSName string `json:"magicdns_name"` +} diff --git a/agent/internal/netif/tailscale.go b/agent/internal/netif/tailscale.go new file mode 100644 index 0000000..ad23ac8 --- /dev/null +++ b/agent/internal/netif/tailscale.go @@ -0,0 +1,84 @@ +package netif + +import ( + "fmt" + "net" + "net/netip" + "strings" +) + +var ( + cgnatPrefix = netip.MustParsePrefix("100.64.0.0/10") + tsv6Prefix = netip.MustParsePrefix("fd7a:115c:a1e0::/48") +) + +type TailscaleAddrs struct { + Interface string + IPv4 netip.Addr + IPv6 netip.Addr +} + +// Detect scans system interfaces for Tailscale addresses. +// It identifies Tailscale by IP range (CGNAT 100.64/10 for v4, fd7a:115c:a1e0::/48 for v6), +// which works regardless of interface name (tailscale0, utun*, "Tailscale" on Win). +func Detect() (*TailscaleAddrs, error) { + ifaces, err := net.Interfaces() + if err != nil { + return nil, fmt.Errorf("list interfaces: %w", err) + } + for _, ifi := range ifaces { + if ifi.Flags&net.FlagUp == 0 { + continue + } + addrs, err := ifi.Addrs() + if err != nil { + continue + } + var v4, v6 netip.Addr + for _, a := range addrs { + ipNet, ok := a.(*net.IPNet) + if !ok { + continue + } + ip, ok := netip.AddrFromSlice(ipNet.IP) + if !ok { + continue + } + ip = ip.Unmap() + switch { + case ip.Is4() && cgnatPrefix.Contains(ip): + v4 = ip + case ip.Is6() && tsv6Prefix.Contains(ip): + v6 = ip + } + } + if v4.IsValid() || v6.IsValid() { + return &TailscaleAddrs{Interface: ifi.Name, IPv4: v4, IPv6: v6}, nil + } + } + return nil, fmt.Errorf("no Tailscale interface found (looked for IPs in %s and %s)", cgnatPrefix, tsv6Prefix) +} + +// BindAddrs renders host:port strings for net.Listen. +// IPv6 addresses are wrapped in brackets per RFC 3986. +func (t *TailscaleAddrs) BindAddrs(port int) []string { + var out []string + if t.IPv4.IsValid() { + out = append(out, fmt.Sprintf("%s:%d", t.IPv4.String(), port)) + } + if t.IPv6.IsValid() { + out = append(out, fmt.Sprintf("[%s]:%d", t.IPv6.String(), port)) + } + return out +} + +func (t *TailscaleAddrs) Summary() string { + var parts []string + if t.IPv4.IsValid() { + parts = append(parts, t.IPv4.String()) + } + if t.IPv6.IsValid() { + parts = append(parts, t.IPv6.String()) + } + return fmt.Sprintf("%s (%s)", t.Interface, strings.Join(parts, ", ")) +} diff --git a/agent/internal/server/server.go b/agent/internal/server/server.go new file mode 100644 index 0000000..16703a0 --- /dev/null +++ b/agent/internal/server/server.go @@ -0,0 +1,211 @@ +package server + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "os/exec" + "runtime" + "strings" + "sync" + "time" + + "github.com/firenzemc/sidecar-mac/agent/internal/collector" + "github.com/firenzemc/sidecar-mac/agent/internal/netif" + "github.com/shirou/gopsutil/v3/host" +) + +const ( + headerAgentVersion = "X-Sidecar-Agent" + protocolVersion = "v0.1" +) + +type Server struct { + version string + startedAt time.Time + col *collector.Collector + ts *netif.TailscaleAddrs +} + +func New(version string, col *collector.Collector, ts *netif.TailscaleAddrs) *Server { + return &Server{ + version: version, + startedAt: time.Now().UTC(), + col: col, + ts: ts, + } +} + +func (s *Server) routes() *http.ServeMux { + mux := http.NewServeMux() + mux.HandleFunc("/healthz", s.handleHealthz) + mux.HandleFunc("/info", s.handleInfo) + mux.HandleFunc("/metrics", s.handleMetrics) + return mux +} + +// ListenAndServe binds the agent's HTTP server to every address in addrs and +// blocks until ctx is cancelled or any listener fails. +func (s *Server) ListenAndServe(ctx context.Context, addrs []string) error { + if len(addrs) == 0 { + return errors.New("no bind addresses") + } + mux := s.routes() + wrapped := withVersionHeader(mux, s.version) + + var wg sync.WaitGroup + errCh := make(chan error, len(addrs)) + servers := make([]*http.Server, 0, len(addrs)) + + for _, addr := range addrs { + ln, err := net.Listen("tcp", addr) + if err != nil { + for _, srv := range servers { + _ = srv.Close() + } + return fmt.Errorf("listen %s: %w", addr, err) + } + srv := &http.Server{ + Handler: wrapped, + ReadHeaderTimeout: 5 * time.Second, + } + servers = append(servers, srv) + wg.Add(1) + go func(addr string) { + defer wg.Done() + log.Printf("listening on %s", addr) + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + errCh <- fmt.Errorf("serve %s: %w", addr, err) + } + }(addr) + } + + select { + case <-ctx.Done(): + case err := <-errCh: + for _, srv := range servers { + _ = srv.Close() + } + wg.Wait() + return err + } + + shutCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + for _, srv := range servers { + _ = srv.Shutdown(shutCtx) + } + wg.Wait() + return nil +} + +func withVersionHeader(h http.Handler, _ string) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set(headerAgentVersion, protocolVersion) + h.ServeHTTP(w, r) + }) +} + +func (s *Server) handleHealthz(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + _, _ = w.Write([]byte("ok\n")) +} + +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + hostname, _ := os.Hostname() + platform, _, version, _ := host.PlatformInformationWithContext(r.Context()) + kernel, _ := host.KernelVersionWithContext(r.Context()) + platformStr := platform + if version != "" { + platformStr = strings.TrimSpace(platform + " " + version) + } + if platformStr == "" { + platformStr = runtime.GOOS + } + + tn := collector.Tailnet{} + if s.ts != nil { + if s.ts.IPv4.IsValid() { + tn.TailscaleIP4 = s.ts.IPv4.String() + } + if s.ts.IPv6.IsValid() { + tn.TailscaleIP6 = s.ts.IPv6.String() + } + } + tn.MagicDNSName = lookupMagicDNS(r.Context()) + + info := collector.Info{ + Agent: collector.AgentInfo{ + Version: s.version, + StartedAt: s.startedAt, + }, + Host: collector.HostInfo{ + Hostname: hostname, + OS: runtime.GOOS, + Arch: runtime.GOARCH, + Kernel: kernel, + Platform: platformStr, + }, + Tailnet: tn, + Capabilities: capabilities(), + } + writeJSON(w, http.StatusOK, info) +} + +func (s *Server) handleMetrics(w http.ResponseWriter, _ *http.Request) { + snap := s.col.Latest() + if snap == nil { + writeError(w, http.StatusServiceUnavailable, "transient", "first sample not ready") + return + } + writeJSON(w, http.StatusOK, snap) +} + +func capabilities() []string { + caps := []string{"metrics.cpu", "metrics.mem", "metrics.disk", "metrics.net"} + if runtime.GOOS != "windows" { + caps = append(caps, "metrics.load") + } + return caps +} + +// lookupMagicDNS shells out to `tailscale status --json` to discover the MagicDNS +// name. Returns "" if the CLI isn't available; agent must keep working without it. +func lookupMagicDNS(ctx context.Context) string { + tsCtx, cancel := context.WithTimeout(ctx, 2*time.Second) + defer cancel() + out, err := exec.CommandContext(tsCtx, "tailscale", "status", "--json").Output() + if err != nil { + return "" + } + var status struct { + Self struct { + DNSName string `json:"DNSName"` + } `json:"Self"` + } + if err := json.Unmarshal(out, &status); err != nil { + return "" + } + return strings.TrimSuffix(status.Self.DNSName, ".") +} + +func writeJSON(w http.ResponseWriter, code int, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(code) + enc := json.NewEncoder(w) + enc.SetEscapeHTML(false) + if err := enc.Encode(v); err != nil { + log.Printf("encode response: %v", err) + } +} + +func writeError(w http.ResponseWriter, status int, code, msg string) { + writeJSON(w, status, map[string]any{ + "error": map[string]string{"code": code, "message": msg}, + }) +} diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..abdc67b --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,117 @@ +# 架构与设计 + +## 设计原则 + +1. **Tailnet 是信任边界** — agent 只监听 `tailscale0`,不暴露公网,认证依赖 ACL。 +2. **Pull 优于 push** — mac 端轮询,简单、可缓存、widget timeline 好对齐。 +3. **零配置发现** — 不维护 device 列表,靠 `tailscale status --json` + 端口探测。 +4. **轻量优先** — agent 内存占用目标 < 30 MB,CPU 空闲 < 1%;不做长周期存储。 +5. **Mac app 是唯一前端** — Win / Linux 端只跑 agent,不写跨平台 UI。 + +## agent + +### 接入 tailnet + +依赖宿主机已经在跑 `tailscaled`。agent 启动时: + +1. 解析本机 `tailscale0` 的 IP(IPv4 / IPv6 各一个)。 +2. HTTP server 只在这两个 IP + 固定端口 `:8765` 上监听,**不绑 0.0.0.0**。 +3. 不做应用层鉴权(v1);信任 tailnet ACL。后续可加 `Tailscale-Whois` 反查调用方身份。 + +> **为什么不用 tsnet?** —— 用户指定依赖宿主机 tailscaled。优点:每台机器一个身份, +> tailscale 控制台里 1:1 对应;缺点:必须先装 Tailscale 客户端。 + +### 采集器 + +用 [`gopsutil`](https://github.com/shirou/gopsutil) 跨平台采集: + +| 指标 | 字段 | 备注 | +| ---------------- | -------------------------------------------- | ----------------------------- | +| CPU 占用 | `cpu.usage_percent`(0–100,整机平均) | 1 秒采样窗口 | +| 内存 | `mem.used_bytes` / `mem.total_bytes` | 物理内存 | +| 磁盘 | 每个挂载点:`mount`, `used`, `total` | 只列容量 ≥ 1 GiB 的 | +| 网络流量 | 每个 NIC:`rx_bps` / `tx_bps` | 与上次采样的差分 | +| 系统负载 | `load.1` / `load.5` / `load.15` | Win 上没有,留 null | +| Uptime | `uptime_seconds` | | +| 主机名 / OS / 架构 | `hostname` / `os` / `arch` / `kernel` | 在 `/info` 里一次性返回 | + +采集频率:默认每 1 秒滚动一次,HTTP 请求来时直接返回最近一帧。 + +### HTTP 端点 + +- `GET /healthz` — 200 OK,纯健康检查 +- `GET /info` — 静态信息(hostname / os / arch / agent version / 启动时间) +- `GET /metrics` — 当前快照(JSON,详见 [protocol.md](protocol.md)) + +### 部署形态 + +- **macOS** — `launchd` user agent +- **Linux** — `systemd` unit +- **Windows** — Windows Service(用 `kardianos/service` 之类) + +## sidecar-mac + +### 模块划分 + +``` +Sidecar (主 app target) +├── Models/ +│ ├── Device.swift tailnet peer + 是否带 agent +│ ├── Snapshot.swift /metrics 解码后的强类型 +│ └── Theme.swift +├── Services/ +│ ├── TailscaleCLI.swift 封装 `tailscale status --json` +│ ├── PeerDiscovery.swift tailnet peer + agent 探活 +│ ├── AgentClient.swift /info /metrics HTTP client +│ ├── Poller.swift 每 N 秒拉一次,写入 store +│ └── SharedStore.swift App Group 共享容器(widget 可读) +├── Views/ +│ ├── GridOverview.swift 所有设备的网格 +│ ├── DeviceCard.swift 详情卡片 +│ ├── Sparkline.swift 迷你火花线(自绘 Path) +│ └── ThemePicker.swift +└── Theme/ + ├── Tokens.swift 颜色 / 字体 / 间距 token + └── Themes/ 几套 theme JSON +``` + +``` +SidecarWidget (extension target) +├── Provider.swift TimelineProvider(读 SharedStore) +├── SmallWidget.swift 单设备 1 项指标 +├── MediumWidget.swift 4–6 设备状态条 +└── LargeWidget.swift 网格 + 火花线 +``` + +### 设备发现流程 + +1. 每隔 30 秒 `tailscale status --json`,得到 peer 列表 +2. 每个 peer 并发探测 `http://:8765/healthz` +3. 200 → 标记为 agent 设备,加入轮询队列 +4. peer 离线或 5 次连续探测失败 → 标记为 offline,但保留卡片 + +### 主 app ↔ widget 数据通道 + +- App Group:`group.dev.sidecar.shared` +- 在共享容器里维护 SQLite(或 JSON 文件 + atomic write),结构: + - `devices` 表:device_id, hostname, os, last_seen + - `snapshots` 环形:device_id, ts, cpu, mem, rx, tx +- widget `TimelineProvider` 每 ~10 分钟刷新读最新 N 条 + +### 视觉方向 + +- **Theme: Grid(默认)** — 深灰底 + 网格背景 + 青色 accent + JetBrains Mono / SF Mono +- **Theme: Phosphor** — 近黑底 + 单一品红/绿色 accent,CRT 微光感 +- **Theme: Glass** — macOS 原生 material + accent 描边,相对克制 + +公共 token: +- 数字一律等宽 +- 状态发光:online 用 1px 内描边 + 软阴影 +- 卡片圆角 12 / widget 圆角随系统 + +## 安全考量 + +- agent 不接受任何 mutation 请求(只读 GET) +- `/metrics` 不含敏感路径(不返回 `$HOME` 等) +- 不暴露 0.0.0.0 是硬性约束 —— bind 失败时直接拒绝启动并打印日志 +- 后续若加可写端点,必须用 Tailscale `whois` 校验调用方 + 加白名单 tag diff --git a/docs/protocol.md b/docs/protocol.md new file mode 100644 index 0000000..9c3150a --- /dev/null +++ b/docs/protocol.md @@ -0,0 +1,124 @@ +# agent ↔ app 协议草案 (v0) + +agent 暴露 HTTP,JSON over `application/json`,UTF-8。 +所有时间戳为 RFC 3339(含时区)。所有字节为 base-10(非二进制单位)。 + +> **版本协商** — 响应头 `X-Sidecar-Agent: v0.`。client 见到 major 不一致直接降级到只显示 `/info`。 + +## `GET /healthz` + +存活探测,body 任意(实现上返回 `ok\n`)。 + +``` +HTTP/1.1 200 OK +Content-Type: text/plain +``` + +## `GET /info` + +启动期间不变的静态信息。client 在发现阶段调用一次,缓存。 + +```json +{ + "agent": { + "version": "0.1.0", + "started_at": "2026-05-08T03:14:15Z" + }, + "host": { + "hostname": "hayabusa", + "os": "darwin", + "arch": "arm64", + "kernel": "24.4.0", + "platform": "macOS 15.4" + }, + "tailnet": { + "tailscale_ip4": "100.64.1.23", + "tailscale_ip6": "fd7a:115c:a1e0::1", + "magicdns_name": "hayabusa.tailnet-xxxx.ts.net" + }, + "capabilities": ["metrics.cpu", "metrics.mem", "metrics.disk", "metrics.net", "metrics.load"] +} +``` + +字段说明: + +- `agent.version` — semver +- `host.os` — Go 风格:`darwin` / `linux` / `windows` +- `capabilities` — 该 agent 实际暴露的指标族;client 据此决定渲染哪些卡片 + +## `GET /metrics` + +当前快照。agent 内部以 1 秒滚动采样,本端点直接返回最近一帧(不阻塞采样)。 + +```json +{ + "ts": "2026-05-08T03:15:42.812Z", + "uptime_seconds": 184213, + "cpu": { + "usage_percent": 12.4, + "cores": 10 + }, + "mem": { + "used_bytes": 12884901888, + "total_bytes": 34359738368, + "swap_used_bytes": 0, + "swap_total_bytes": 0 + }, + "load": { + "load1": 1.42, + "load5": 1.18, + "load15": 0.95 + }, + "disks": [ + { + "mount": "/", + "fstype": "apfs", + "used_bytes": 412316860416, + "total_bytes": 994662584320 + } + ], + "net": [ + { + "name": "en0", + "rx_bps": 1248391, + "tx_bps": 384712, + "rx_total_bytes": 9183746234, + "tx_total_bytes": 1827364512 + } + ] +} +``` + +可空字段: + +- `load` 整体为 `null`(Windows 没有 loadavg) +- `mem.swap_*` 为 `null`(不支持的平台) +- `disks` / `net` 可为空数组 + +字段约定: + +- `*_bps` —— 与上一帧的差分 / 时间间隔,floor 不为负 +- `usage_percent` —— `[0, 100]`,整机平均 +- `total_bytes >= used_bytes` 始终成立 + +## `GET /metrics/stream` *(预留, v0 不实现)* + +server-sent events,用于主 app 打开时的高频实时视图。v0 阶段 client 用 1–2 秒轮询替代。 + +## 错误响应 + +```json +{ "error": { "code": "internal", "message": "..." } } +``` + +`code` 取值: + +- `internal` — 采集出错 +- `not_supported` — 平台不支持该端点 +- `transient` — 暂时性错误,client 应重试 + +## 兼容策略 + +- v0 → v1:新增字段不算 break;删除/语义变更必须 bump major。 +- agent 见到未知 query 参数应忽略不报错。 +- client 见到未知字段应忽略不报错(forward compatibility)。 diff --git a/macapp/README.md b/macapp/README.md new file mode 100644 index 0000000..7b1acb2 --- /dev/null +++ b/macapp/README.md @@ -0,0 +1,77 @@ +# sidecar-mac + +SwiftUI dashboard for [sidecar-agent](../agent). macOS 14+, no third-party +runtime dependencies. + +> Status: **M2** — main app skeleton with peer discovery, polling, mock mode, +> and a grid of device cards. WidgetKit extension lands in M3. + +## Bootstrap (one-time on Mac) + +The `.xcodeproj` is **not** committed; it's generated from `project.yml` via +[XcodeGen](https://github.com/yonaskolb/XcodeGen). + +```sh +brew install xcodegen +cd macapp +xcodegen generate +open Sidecar.xcodeproj +``` + +Build & run from Xcode (`⌘R`). + +## How it discovers devices + +1. Shells out to `tailscale status --json` (looks for the binary in + `/usr/local/bin`, `/opt/homebrew/bin`, and inside `Tailscale.app`). +2. For each peer, probes `http://:8765/healthz` in parallel. +3. Peers that answer 200 get a CARD with live metrics; peers without an + agent stay visible with a "no agent" badge. + +Discovery refreshes every 30 s; metrics poll every 5 s. Both are configurable +on the model (`pollInterval`, `discoveryInterval`). + +## Mock mode + +The toolbar `Live | Mock` toggle flips to a deterministic mock dataset (5 +devices, drifting metrics). Useful when designing the UI without an active +tailnet — and as a safety net if discovery fails. + +## Sandbox + +App Sandbox is **disabled** in `Sidecar.entitlements` because we shell out to +the `tailscale` binary. Re-enabling sandbox would require either: + +- talking to Tailscale's LocalAPI socket directly (and shipping a helper), or +- a privileged helper tool installed via `SMAppService`. + +Both are tracked for a later milestone; for v0 the unsandboxed app is +acceptable since distribution is direct (not Mac App Store). + +## Project layout + +``` +macapp/ +├── project.yml XcodeGen config (source of truth) +├── Sidecar/ +│ ├── SidecarApp.swift @main, WindowGroup +│ ├── ContentView.swift toolbar + GridOverview host +│ ├── Info.plist +│ ├── Sidecar.entitlements +│ ├── Models/ +│ │ ├── Device.swift Device + DeviceState +│ │ └── AgentDTO.swift Codable types matching docs/protocol.md +│ ├── Services/ +│ │ ├── TailscaleCLI.swift Process wrapper + status JSON decode +│ │ ├── AgentClient.swift URLSession wrapper for /info /metrics +│ │ ├── PeerDiscovery.swift status + parallel /healthz probe +│ │ ├── Poller.swift actor; periodic /metrics fetch +│ │ └── MockData.swift deterministic mock devices + snapshots +│ ├── ViewModel/ +│ │ └── DashboardModel.swift @Observable; the screen's source of truth +│ ├── Views/ +│ │ ├── GridOverview.swift LazyVGrid + dotted-grid backdrop +│ │ └── DeviceCard.swift hostname / OS / CPU / MEM / NET +│ └── Theme/ +│ └── Theme.swift Grid / Phosphor / Glass + design tokens +``` diff --git a/macapp/Sidecar/ContentView.swift b/macapp/Sidecar/ContentView.swift new file mode 100644 index 0000000..0cf8f29 --- /dev/null +++ b/macapp/Sidecar/ContentView.swift @@ -0,0 +1,39 @@ +import SwiftUI + +struct ContentView: View { + @Environment(DashboardModel.self) private var model + + var body: some View { + @Bindable var model = model + + GridOverview() + .background(model.theme.background.ignoresSafeArea()) + .toolbar { + ToolbarItem(placement: .navigation) { + Picker("Mode", selection: $model.dataSource) { + Text("Live").tag(DataSource.live) + Text("Mock").tag(DataSource.mock) + } + .pickerStyle(.segmented) + .frame(width: 140) + } + ToolbarItem(placement: .primaryAction) { + Picker("Theme", selection: $model.theme) { + ForEach(Theme.allCases) { t in + Text(t.displayName).tag(t) + } + } + .pickerStyle(.menu) + .frame(width: 140) + } + ToolbarItem(placement: .primaryAction) { + Button { + Task { await model.refreshNow() } + } label: { + Image(systemName: "arrow.clockwise") + } + .help("Rediscover peers and refresh") + } + } + } +} diff --git a/macapp/Sidecar/Info.plist b/macapp/Sidecar/Info.plist new file mode 100644 index 0000000..e178f12 --- /dev/null +++ b/macapp/Sidecar/Info.plist @@ -0,0 +1,16 @@ + + + + + CFBundleName + $(PRODUCT_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleInfoDictionaryVersion + 6.0 + CFBundlePackageType + APPL + + diff --git a/macapp/Sidecar/Models/AgentDTO.swift b/macapp/Sidecar/Models/AgentDTO.swift new file mode 100644 index 0000000..72d64e0 --- /dev/null +++ b/macapp/Sidecar/Models/AgentDTO.swift @@ -0,0 +1,126 @@ +import Foundation + +// Mirrors docs/protocol.md v0.1. Field names use snake_case via CodingKeys. + +struct AgentInfo: Codable, Hashable { + struct Agent: Codable, Hashable { + let version: String + let startedAt: Date + enum CodingKeys: String, CodingKey { + case version + case startedAt = "started_at" + } + } + struct Host: Codable, Hashable { + let hostname: String + let os: String + let arch: String + let kernel: String + let platform: String + } + struct Tailnet: Codable, Hashable { + let tailscaleIP4: String + let tailscaleIP6: String + let magicDNSName: String + enum CodingKeys: String, CodingKey { + case tailscaleIP4 = "tailscale_ip4" + case tailscaleIP6 = "tailscale_ip6" + case magicDNSName = "magicdns_name" + } + } + let agent: Agent + let host: Host + let tailnet: Tailnet + let capabilities: [String] +} + +struct MetricsSnapshot: Codable, Hashable { + let ts: Date + let uptimeSeconds: UInt64 + let cpu: CPU + let mem: Mem + let load: Load? + let disks: [Disk] + let net: [NetIO] + + enum CodingKeys: String, CodingKey { + case ts + case uptimeSeconds = "uptime_seconds" + case cpu, mem, load, disks, net + } + + struct CPU: Codable, Hashable { + let usagePercent: Double + let cores: Int + enum CodingKeys: String, CodingKey { + case usagePercent = "usage_percent" + case cores + } + } + struct Mem: Codable, Hashable { + let usedBytes: UInt64 + let totalBytes: UInt64 + let swapUsedBytes: UInt64? + let swapTotalBytes: UInt64? + enum CodingKeys: String, CodingKey { + case usedBytes = "used_bytes" + case totalBytes = "total_bytes" + case swapUsedBytes = "swap_used_bytes" + case swapTotalBytes = "swap_total_bytes" + } + var usageFraction: Double { + totalBytes > 0 ? Double(usedBytes) / Double(totalBytes) : 0 + } + } + struct Load: Codable, Hashable { + let load1: Double + let load5: Double + let load15: Double + } + struct Disk: Codable, Hashable { + let mount: String + let fstype: String + let usedBytes: UInt64 + let totalBytes: UInt64 + enum CodingKeys: String, CodingKey { + case mount, fstype + case usedBytes = "used_bytes" + case totalBytes = "total_bytes" + } + } + struct NetIO: Codable, Hashable { + let name: String + let rxBps: UInt64 + let txBps: UInt64 + let rxTotalBytes: UInt64 + let txTotalBytes: UInt64 + enum CodingKeys: String, CodingKey { + case name + case rxBps = "rx_bps" + case txBps = "tx_bps" + case rxTotalBytes = "rx_total_bytes" + case txTotalBytes = "tx_total_bytes" + } + } +} + +// JSONDecoder configured for the agent's RFC 3339 timestamps with optional +// fractional seconds. +extension JSONDecoder { + static let sidecarAgent: JSONDecoder = { + let d = JSONDecoder() + let withFraction = ISO8601DateFormatter() + withFraction.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + let plain = ISO8601DateFormatter() + plain.formatOptions = [.withInternetDateTime] + d.dateDecodingStrategy = .custom { decoder in + let c = try decoder.singleValueContainer() + let s = try c.decode(String.self) + if let date = withFraction.date(from: s) { return date } + if let date = plain.date(from: s) { return date } + throw DecodingError.dataCorruptedError( + in: c, debugDescription: "unrecognized date \(s)") + } + return d + }() +} diff --git a/macapp/Sidecar/Models/Device.swift b/macapp/Sidecar/Models/Device.swift new file mode 100644 index 0000000..293aa4d --- /dev/null +++ b/macapp/Sidecar/Models/Device.swift @@ -0,0 +1,28 @@ +import Foundation + +struct Device: Identifiable, Hashable { + let id: String // Tailscale node ID (stable) + var hostname: String + var os: String // "darwin" / "linux" / "windows" / ... + var magicDNSName: String? // e.g., "hayabusa.tailnet-xxxx.ts.net" + var tailscaleIPv4: String? + var tailscaleIPv6: String? + + /// URL of the agent's HTTP endpoint, if discovery confirmed one is reachable. + var agentBaseURL: URL? + + var isOnline: Bool // tailnet reachability (from `tailscale status`) + var hasAgent: Bool // did /healthz respond? + + var displayName: String { hostname } +} + +struct DeviceState { + var lastInfo: AgentInfo? + var latest: MetricsSnapshot? + var lastPolledAt: Date? + var lastError: String? + var history: [MetricsSnapshot] = [] // ring buffer; cap enforced by caller + + static let empty = DeviceState() +} diff --git a/macapp/Sidecar/Services/AgentClient.swift b/macapp/Sidecar/Services/AgentClient.swift new file mode 100644 index 0000000..ba5fffa --- /dev/null +++ b/macapp/Sidecar/Services/AgentClient.swift @@ -0,0 +1,61 @@ +import Foundation + +struct AgentClient { + let baseURL: URL + let session: URLSession + + init(baseURL: URL, session: URLSession = .shared) { + self.baseURL = baseURL + self.session = session + } + + enum AgentError: Error, LocalizedError { + case unexpectedStatus(Int) + case majorMismatch(String) + + var errorDescription: String? { + switch self { + case .unexpectedStatus(let s): return "agent returned HTTP \(s)" + case .majorMismatch(let v): return "incompatible agent protocol \(v)" + } + } + } + + func healthz(timeout: TimeInterval = 1.5) async -> Bool { + var request = URLRequest(url: baseURL.appendingPathComponent("healthz")) + request.timeoutInterval = timeout + do { + let (_, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { return false } + return http.statusCode == 200 + } catch { + return false + } + } + + func info(timeout: TimeInterval = 5) async throws -> AgentInfo { + try await get(path: "info", timeout: timeout) + } + + func metrics(timeout: TimeInterval = 5) async throws -> MetricsSnapshot { + try await get(path: "metrics", timeout: timeout) + } + + private func get(path: String, timeout: TimeInterval) async throws -> T { + var request = URLRequest(url: baseURL.appendingPathComponent(path)) + request.timeoutInterval = timeout + + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw AgentError.unexpectedStatus(0) + } + guard http.statusCode == 200 else { + throw AgentError.unexpectedStatus(http.statusCode) + } + if let proto = http.value(forHTTPHeaderField: "X-Sidecar-Agent"), + !proto.hasPrefix("v0.") { + throw AgentError.majorMismatch(proto) + } + return try JSONDecoder.sidecarAgent.decode(T.self, from: data) + } +} diff --git a/macapp/Sidecar/Services/MockData.swift b/macapp/Sidecar/Services/MockData.swift new file mode 100644 index 0000000..41efc45 --- /dev/null +++ b/macapp/Sidecar/Services/MockData.swift @@ -0,0 +1,72 @@ +import Foundation + +enum MockData { + static func devices() -> [Device] { + [ + mock(id: "n1", host: "hayabusa", os: "darwin", v4: "100.64.1.10", agent: true), + mock(id: "n2", host: "konata", os: "linux", v4: "100.64.1.11", agent: true), + mock(id: "n3", host: "yotsuba", os: "windows", v4: "100.64.1.12", agent: true), + mock(id: "n4", host: "minato", os: "darwin", v4: "100.64.1.13", agent: false), + mock(id: "n5", host: "wakaba", os: "linux", v4: "100.64.1.14", agent: true, online: false), + ] + } + + static func snapshot(seed: Int, at date: Date = .now) -> MetricsSnapshot { + var rng = SeededRandom(seed: UInt64(seed) &* UInt64(Int(date.timeIntervalSince1970) / 5)) + let cpu = rng.double(in: 5...75) + let memTotal: UInt64 = 32 * 1024 * 1024 * 1024 + let memUsed = UInt64(Double(memTotal) * rng.double(in: 0.2...0.7)) + return MetricsSnapshot( + ts: date, + uptimeSeconds: UInt64(rng.double(in: 3600...604800)), + cpu: .init(usagePercent: (cpu * 10).rounded() / 10, cores: 10), + mem: .init(usedBytes: memUsed, totalBytes: memTotal, swapUsedBytes: 0, swapTotalBytes: 0), + load: .init(load1: rng.double(in: 0.1...3), load5: rng.double(in: 0.1...2.5), load15: rng.double(in: 0.1...2)), + disks: [ + .init(mount: "/", fstype: "apfs", usedBytes: 412 * 1024 * 1024 * 1024, totalBytes: 994 * 1024 * 1024 * 1024) + ], + net: [ + .init(name: "en0", + rxBps: UInt64(rng.double(in: 0...20_000_000)), + txBps: UInt64(rng.double(in: 0...8_000_000)), + rxTotalBytes: UInt64(rng.double(in: 1e9...2e10)), + txTotalBytes: UInt64(rng.double(in: 1e8...5e9))) + ] + ) + } + + private static func mock(id: String, host: String, os: String, v4: String, + agent: Bool, online: Bool = true) -> Device { + Device( + id: id, + hostname: host, + os: os, + magicDNSName: "\(host).tailnet-mock.ts.net", + tailscaleIPv4: v4, + tailscaleIPv6: nil, + agentBaseURL: agent ? URL(string: "http://\(host).tailnet-mock.ts.net:8765") : nil, + isOnline: online, + hasAgent: agent + ) + } +} + +// Deterministic RNG so the mock UI doesn't jitter every frame; reseeded from +// the current 5s bucket, so values still drift visibly during demo. +private struct SeededRandom { + var state: UInt64 + init(seed: UInt64) { self.state = seed | 1 } + + mutating func next() -> UInt64 { + state &+= 0x9E3779B97F4A7C15 + var z = state + z = (z ^ (z >> 30)) &* 0xBF58476D1CE4E5B9 + z = (z ^ (z >> 27)) &* 0x94D049BB133111EB + return z ^ (z >> 31) + } + + mutating func double(in range: ClosedRange) -> Double { + let unit = Double(next() >> 11) / Double(UInt64(1) << 53) + return range.lowerBound + unit * (range.upperBound - range.lowerBound) + } +} diff --git a/macapp/Sidecar/Services/PeerDiscovery.swift b/macapp/Sidecar/Services/PeerDiscovery.swift new file mode 100644 index 0000000..d16cb83 --- /dev/null +++ b/macapp/Sidecar/Services/PeerDiscovery.swift @@ -0,0 +1,50 @@ +import Foundation + +enum PeerDiscovery { + /// Default agent port (matches `agent` defaults). + static let agentPort = 8765 + + /// Resolves the tailnet roster, then probes each peer for an agent. + static func discover(port: Int = agentPort) async throws -> [Device] { + let status = try await TailscaleCLI.status() + var devices = status.allDevices() + + // Probe each device for an agent in parallel. + await withTaskGroup(of: (Int, Device).self) { group in + for (idx, device) in devices.enumerated() { + group.addTask { + var d = device + if let url = bestAgentURL(for: device, port: port) { + let client = AgentClient(baseURL: url) + if await client.healthz() { + d.agentBaseURL = url + d.hasAgent = true + } + } + return (idx, d) + } + } + for await (idx, d) in group { + devices[idx] = d + } + } + return devices + } + + /// Prefers MagicDNS name, falls back to v4 then v6. + private static func bestAgentURL(for device: Device, port: Int) -> URL? { + if let dns = device.magicDNSName, !dns.isEmpty, + let url = URL(string: "http://\(dns):\(port)") { + return url + } + if let v4 = device.tailscaleIPv4, + let url = URL(string: "http://\(v4):\(port)") { + return url + } + if let v6 = device.tailscaleIPv6, + let url = URL(string: "http://[\(v6)]:\(port)") { + return url + } + return nil + } +} diff --git a/macapp/Sidecar/Services/Poller.swift b/macapp/Sidecar/Services/Poller.swift new file mode 100644 index 0000000..bfa5b17 --- /dev/null +++ b/macapp/Sidecar/Services/Poller.swift @@ -0,0 +1,64 @@ +import Foundation + +/// Polls every device with an agent at a fixed cadence and reports results. +/// Runs as a single async loop; cancel by calling `stop()` or task cancellation. +actor Poller { + typealias OnResult = @Sendable (String, Result) async -> Void + + private(set) var interval: TimeInterval + private var task: Task? + private var devices: [Device] = [] + private let onResult: OnResult + + init(interval: TimeInterval = 5, onResult: @escaping OnResult) { + self.interval = interval + self.onResult = onResult + } + + func update(devices: [Device], interval: TimeInterval? = nil) { + self.devices = devices.filter { $0.hasAgent && $0.agentBaseURL != nil } + if let interval { self.interval = interval } + } + + func start() { + guard task == nil else { return } + let initial = self.interval + task = Task { [weak self] in + await self?.run(initial: initial) + } + } + + func stop() { + task?.cancel() + task = nil + } + + private func run(initial: TimeInterval) async { + await tick() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(self.interval)) + if Task.isCancelled { break } + await tick() + } + } + + private func tick() async { + let snapshot = devices + await withTaskGroup(of: Void.self) { group in + for d in snapshot { + guard let url = d.agentBaseURL else { continue } + let id = d.id + let cb = onResult + group.addTask { + let client = AgentClient(baseURL: url) + do { + let m = try await client.metrics() + await cb(id, .success(m)) + } catch { + await cb(id, .failure(error)) + } + } + } + } + } +} diff --git a/macapp/Sidecar/Services/TailscaleCLI.swift b/macapp/Sidecar/Services/TailscaleCLI.swift new file mode 100644 index 0000000..d4b86a2 --- /dev/null +++ b/macapp/Sidecar/Services/TailscaleCLI.swift @@ -0,0 +1,116 @@ +import Foundation + +enum TailscaleCLI { + /// Common locations the `tailscale` binary can live in. + private static let candidatePaths = [ + "/usr/local/bin/tailscale", + "/opt/homebrew/bin/tailscale", + "/Applications/Tailscale.app/Contents/MacOS/Tailscale", + ] + + enum CLIError: Error, LocalizedError { + case notFound + case invalidJSON(underlying: Error) + case nonZeroExit(code: Int32, stderr: String) + + var errorDescription: String? { + switch self { + case .notFound: + return "tailscale CLI not found in PATH or known locations." + case .invalidJSON(let e): + return "tailscale status output not parseable: \(e.localizedDescription)" + case .nonZeroExit(let code, let stderr): + return "tailscale exited \(code): \(stderr)" + } + } + } + + static func resolveBinary() -> URL? { + let fm = FileManager.default + for path in candidatePaths where fm.isExecutableFile(atPath: path) { + return URL(fileURLWithPath: path) + } + return nil + } + + /// Returns the parsed `tailscale status --json` output. + static func status() async throws -> TailscaleStatus { + guard let bin = resolveBinary() else { throw CLIError.notFound } + + let process = Process() + process.executableURL = bin + process.arguments = ["status", "--json"] + + let stdout = Pipe() + let stderr = Pipe() + process.standardOutput = stdout + process.standardError = stderr + + try process.run() + process.waitUntilExit() + + let outData = stdout.fileHandleForReading.readDataToEndOfFile() + let errData = stderr.fileHandleForReading.readDataToEndOfFile() + + if process.terminationStatus != 0 { + let msg = String(data: errData, encoding: .utf8) ?? "" + throw CLIError.nonZeroExit(code: process.terminationStatus, stderr: msg) + } + + do { + return try JSONDecoder().decode(TailscaleStatus.self, from: outData) + } catch { + throw CLIError.invalidJSON(underlying: error) + } + } +} + +// Minimal subset of `tailscale status --json`. Tailscale's full schema is large +// and changes; we intentionally only decode what we need. +struct TailscaleStatus: Codable { + struct Peer: Codable { + let ID: String + let HostName: String? + let DNSName: String? // FQDN with trailing dot, e.g. "host.tailnet.ts.net." + let OS: String? + let TailscaleIPs: [String]? + let Online: Bool? + } + let Self: Peer? + let Peer: [String: Peer]? +} + +extension TailscaleStatus { + /// Self + every peer, normalized into Device records. + func allDevices() -> [Device] { + var out: [Device] = [] + if let s = Self, let dev = Self.toDevice(s) { + out.append(dev) + } + for (_, p) in Peer ?? [:] { + if let dev = Self.toDevice(p) { + out.append(dev) + } + } + return out.sorted { $0.hostname.localizedCompare($1.hostname) == .orderedAscending } + } + + private static func toDevice(_ p: Peer) -> Device? { + guard let host = p.HostName, !host.isEmpty else { return nil } + let ips = p.TailscaleIPs ?? [] + let v4 = ips.first { $0.contains(".") } + let v6 = ips.first { $0.contains(":") } + let dns = p.DNSName.map { $0.hasSuffix(".") ? String($0.dropLast()) : $0 } + return Device( + id: p.ID, + hostname: host, + os: p.OS ?? "", + magicDNSName: dns, + tailscaleIPv4: v4, + tailscaleIPv6: v6, + agentBaseURL: nil, + isOnline: p.Online ?? false, + hasAgent: false + ) + } +} diff --git a/macapp/Sidecar/Sidecar.entitlements b/macapp/Sidecar/Sidecar.entitlements new file mode 100644 index 0000000..b1088de --- /dev/null +++ b/macapp/Sidecar/Sidecar.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/macapp/Sidecar/SidecarApp.swift b/macapp/Sidecar/SidecarApp.swift new file mode 100644 index 0000000..42c0c47 --- /dev/null +++ b/macapp/Sidecar/SidecarApp.swift @@ -0,0 +1,17 @@ +import SwiftUI + +@main +struct SidecarApp: App { + @State private var model = DashboardModel() + + var body: some Scene { + WindowGroup("Sidecar") { + ContentView() + .environment(model) + .frame(minWidth: 720, minHeight: 460) + .task { await model.start() } + } + .windowStyle(.titleBar) + .windowResizability(.contentSize) + } +} diff --git a/macapp/Sidecar/Theme/Theme.swift b/macapp/Sidecar/Theme/Theme.swift new file mode 100644 index 0000000..5186192 --- /dev/null +++ b/macapp/Sidecar/Theme/Theme.swift @@ -0,0 +1,56 @@ +import SwiftUI + +enum Theme: String, CaseIterable, Identifiable, Hashable { + case grid // 默认:深灰底 + 网格背景 + 青色 accent + case phosphor // 近黑底 + 单一品红/绿色 accent,CRT 微光 + case glass // macOS 原生 material + accent 描边 + + var id: String { rawValue } + var displayName: String { + switch self { + case .grid: return "Grid" + case .phosphor: return "Phosphor" + case .glass: return "Glass" + } + } + + var accent: Color { + switch self { + case .grid: return Color(red: 0.30, green: 0.85, blue: 0.95) // cyan + case .phosphor: return Color(red: 0.35, green: 0.95, blue: 0.55) // CRT green + case .glass: return Color.accentColor + } + } + + var background: Color { + switch self { + case .grid: return Color(red: 0.07, green: 0.08, blue: 0.10) + case .phosphor: return Color(red: 0.04, green: 0.04, blue: 0.05) + case .glass: return Color.clear + } + } + + var cardBackground: Color { + switch self { + case .grid: return Color(red: 0.10, green: 0.12, blue: 0.15).opacity(0.85) + case .phosphor: return Color(red: 0.06, green: 0.07, blue: 0.06).opacity(0.90) + case .glass: return Color(nsColor: .windowBackgroundColor).opacity(0.6) + } + } + + var cardBorder: Color { accent.opacity(0.35) } + var dimText: Color { Color.white.opacity(0.55) } + var brightText: Color { Color.white.opacity(0.92) } + + /// Whether to render the dotted-grid background overlay behind the content. + var showsGridBackdrop: Bool { self == .grid } +} + +// MARK: - Tokens + +enum Tokens { + static let cardCorner: CGFloat = 12 + static let gridSpacing: CGFloat = 12 + static let monoDigits = Font.system(.body, design: .monospaced).weight(.medium) + static let monoSmall = Font.system(.caption, design: .monospaced) +} diff --git a/macapp/Sidecar/ViewModel/DashboardModel.swift b/macapp/Sidecar/ViewModel/DashboardModel.swift new file mode 100644 index 0000000..8fb3da2 --- /dev/null +++ b/macapp/Sidecar/ViewModel/DashboardModel.swift @@ -0,0 +1,135 @@ +import Foundation +import Observation + +enum DataSource: Hashable { + case live + case mock +} + +@Observable +@MainActor +final class DashboardModel { + /// Cap on how many snapshots we retain per device, used for sparkline history later. + static let historyCapacity = 60 + + var devices: [Device] = [] + var states: [String: DeviceState] = [:] + var theme: Theme = .grid + var dataSource: DataSource = .live { + didSet { Task { await applyDataSourceChange() } } + } + + var pollInterval: TimeInterval = 5 + var discoveryInterval: TimeInterval = 30 + + var lastDiscoveryError: String? + + private var poller: Poller? + private var discoveryTask: Task? + private var mockTask: Task? + + func start() async { + await applyDataSourceChange() + } + + func refreshNow() async { + switch dataSource { + case .live: + await runDiscoveryOnce() + case .mock: + applyMockSnapshot() + } + } + + private func applyDataSourceChange() async { + // Reset everything when toggling. + await poller?.stop() + poller = nil + discoveryTask?.cancel() + discoveryTask = nil + mockTask?.cancel() + mockTask = nil + states.removeAll() + lastDiscoveryError = nil + + switch dataSource { + case .live: + await startLive() + case .mock: + startMock() + } + } + + private func startLive() async { + let p = Poller(interval: pollInterval) { [weak self] id, result in + await self?.recordResult(deviceID: id, result: result) + } + await p.start() + self.poller = p + + discoveryTask = Task { [weak self] in + guard let self else { return } + await self.runDiscoveryOnce() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(self.discoveryInterval)) + if Task.isCancelled { break } + await self.runDiscoveryOnce() + } + } + } + + private func runDiscoveryOnce() async { + do { + let found = try await PeerDiscovery.discover() + self.devices = found + self.lastDiscoveryError = nil + await poller?.update(devices: found, interval: pollInterval) + } catch { + self.lastDiscoveryError = error.localizedDescription + } + } + + private func startMock() { + devices = MockData.devices() + applyMockSnapshot() + mockTask = Task { [weak self] in + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(2)) + if Task.isCancelled { break } + self?.applyMockSnapshot() + } + } + } + + private func applyMockSnapshot() { + for (i, d) in devices.enumerated() { + guard d.hasAgent, d.isOnline else { continue } + let snap = MockData.snapshot(seed: i + 1) + recordSnapshot(deviceID: d.id, snapshot: snap) + } + } + + private func recordResult(deviceID: String, result: Result) { + switch result { + case .success(let snap): + recordSnapshot(deviceID: deviceID, snapshot: snap) + case .failure(let err): + var s = states[deviceID] ?? .empty + s.lastError = err.localizedDescription + s.lastPolledAt = .now + states[deviceID] = s + } + } + + private func recordSnapshot(deviceID: String, snapshot: MetricsSnapshot) { + var s = states[deviceID] ?? .empty + s.latest = snapshot + s.lastError = nil + s.lastPolledAt = .now + s.history.append(snapshot) + if s.history.count > Self.historyCapacity { + s.history.removeFirst(s.history.count - Self.historyCapacity) + } + states[deviceID] = s + } +} diff --git a/macapp/Sidecar/Views/DeviceCard.swift b/macapp/Sidecar/Views/DeviceCard.swift new file mode 100644 index 0000000..66263c9 --- /dev/null +++ b/macapp/Sidecar/Views/DeviceCard.swift @@ -0,0 +1,157 @@ +import SwiftUI + +struct DeviceCard: View { + @Environment(DashboardModel.self) private var model + let device: Device + let state: DeviceState + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + header + Divider().background(model.theme.cardBorder) + metrics + } + .padding(14) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: Tokens.cardCorner, style: .continuous) + .fill(model.theme.cardBackground) + ) + .overlay( + RoundedRectangle(cornerRadius: Tokens.cardCorner, style: .continuous) + .stroke(model.theme.cardBorder, lineWidth: 1) + ) + .shadow(color: model.theme.accent.opacity(0.08), radius: 6, x: 0, y: 1) + } + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: osSymbol(for: device.os)) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(model.theme.accent) + .frame(width: 18) + VStack(alignment: .leading, spacing: 2) { + Text(device.displayName) + .font(Tokens.monoDigits) + .foregroundStyle(model.theme.brightText) + Text(device.tailscaleIPv4 ?? device.magicDNSName ?? "—") + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + .lineLimit(1) + .truncationMode(.middle) + } + Spacer() + statusDot + } + } + + @ViewBuilder + private var metrics: some View { + if !device.hasAgent { + Text(device.isOnline ? "no agent" : "offline") + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + } else if let snap = state.latest { + VStack(alignment: .leading, spacing: 8) { + Bar(label: "CPU", value: snap.cpu.usagePercent / 100, trailing: percent(snap.cpu.usagePercent)) + Bar(label: "MEM", value: snap.mem.usageFraction, trailing: percent(snap.mem.usageFraction * 100)) + netRow(snap) + } + } else if let err = state.lastError { + Text(err) + .font(Tokens.monoSmall) + .foregroundStyle(.red.opacity(0.8)) + .lineLimit(2) + } else { + Text("polling…") + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + } + } + + private func netRow(_ snap: MetricsSnapshot) -> some View { + let primary = snap.net.first + return HStack(spacing: 14) { + Label(formatRate(primary?.rxBps ?? 0), systemImage: "arrow.down") + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.brightText) + Label(formatRate(primary?.txBps ?? 0), systemImage: "arrow.up") + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.brightText) + Spacer() + if let primary { + Text(primary.name) + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + } + } + } + + private var statusDot: some View { + let online = device.isOnline + let withAgent = device.hasAgent && state.lastError == nil && state.latest != nil + let color: Color = !online ? .gray + : (withAgent ? model.theme.accent : .yellow) + return Circle() + .fill(color) + .frame(width: 8, height: 8) + .shadow(color: color.opacity(0.7), radius: 4) + } + + private func osSymbol(for os: String) -> String { + switch os.lowercased() { + case let s where s.contains("darwin") || s.contains("macos"): return "apple.logo" + case let s where s.contains("linux"): return "terminal" + case let s where s.contains("windows"): return "pc" + default: return "questionmark.square.dashed" + } + } + + private func percent(_ v: Double) -> String { + String(format: "%.1f%%", v) + } + + private func formatRate(_ bps: UInt64) -> String { + let units: [(UInt64, String)] = [ + (1_000_000_000, "GB/s"), + (1_000_000, "MB/s"), + (1_000, "KB/s"), + ] + for (scale, unit) in units where bps >= scale { + return String(format: "%.1f %@", Double(bps) / Double(scale), unit) + } + return "\(bps) B/s" + } +} + +private struct Bar: View { + @Environment(DashboardModel.self) private var model + let label: String + /// Fill fraction in 0...1. + let value: Double + let trailing: String + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(label) + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + Spacer() + Text(trailing) + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.brightText) + } + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(model.theme.accent.opacity(0.15)) + RoundedRectangle(cornerRadius: 2, style: .continuous) + .fill(model.theme.accent) + .frame(width: max(0, min(1, value)) * geo.size.width) + } + } + .frame(height: 4) + } + } +} diff --git a/macapp/Sidecar/Views/GridOverview.swift b/macapp/Sidecar/Views/GridOverview.swift new file mode 100644 index 0000000..893d6ae --- /dev/null +++ b/macapp/Sidecar/Views/GridOverview.swift @@ -0,0 +1,90 @@ +import SwiftUI + +struct GridOverview: View { + @Environment(DashboardModel.self) private var model + + private let columns = [ + GridItem(.adaptive(minimum: 240, maximum: 320), spacing: Tokens.gridSpacing) + ] + + var body: some View { + ZStack { + if model.theme.showsGridBackdrop { + GridBackdrop(color: model.theme.accent.opacity(0.06)) + .ignoresSafeArea() + } + content + } + } + + @ViewBuilder + private var content: some View { + if model.devices.isEmpty { + EmptyStateView() + } else { + ScrollView { + LazyVGrid(columns: columns, spacing: Tokens.gridSpacing) { + ForEach(model.devices) { device in + DeviceCard( + device: device, + state: model.states[device.id] ?? .empty + ) + } + } + .padding(Tokens.gridSpacing) + } + } + } +} + +private struct EmptyStateView: View { + @Environment(DashboardModel.self) private var model + + var body: some View { + VStack(spacing: 12) { + Image(systemName: "antenna.radiowaves.left.and.right.slash") + .font(.system(size: 36, weight: .light)) + .foregroundStyle(model.theme.dimText) + if let err = model.lastDiscoveryError { + Text("discovery error") + .font(Tokens.monoDigits) + .foregroundStyle(model.theme.brightText) + Text(err) + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } else { + Text("no peers discovered") + .font(Tokens.monoDigits) + .foregroundStyle(model.theme.brightText) + Text("ensure tailscale CLI is installed and you're signed in") + .font(Tokens.monoSmall) + .foregroundStyle(model.theme.dimText) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} + +/// Subtle dotted-grid backdrop. Cheap to draw via Canvas. +private struct GridBackdrop: View { + let color: Color + var spacing: CGFloat = 24 + + var body: some View { + Canvas { ctx, size in + let dot: CGFloat = 1.5 + var y: CGFloat = spacing / 2 + while y < size.height { + var x: CGFloat = spacing / 2 + while x < size.width { + let rect = CGRect(x: x, y: y, width: dot, height: dot) + ctx.fill(Path(ellipseIn: rect), with: .color(color)) + x += spacing + } + y += spacing + } + } + } +} diff --git a/macapp/project.yml b/macapp/project.yml new file mode 100644 index 0000000..2aa3bef --- /dev/null +++ b/macapp/project.yml @@ -0,0 +1,47 @@ +name: Sidecar +options: + bundleIdPrefix: dev.sidecar + deploymentTarget: + macOS: "14.0" + createIntermediateGroups: true + groupSortPosition: top + +settings: + base: + SWIFT_VERSION: "5.9" + DEVELOPMENT_TEAM: "" + ENABLE_HARDENED_RUNTIME: YES + MACOSX_DEPLOYMENT_TARGET: "14.0" + SWIFT_TREAT_WARNINGS_AS_ERRORS: NO + +targets: + Sidecar: + type: application + platform: macOS + sources: + - path: Sidecar + excludes: + - "Info.plist" + - "Sidecar.entitlements" + info: + path: Sidecar/Info.plist + properties: + CFBundleDisplayName: Sidecar + CFBundleShortVersionString: "0.1.0" + CFBundleVersion: "1" + LSApplicationCategoryType: public.app-category.utilities + LSMinimumSystemVersion: "14.0" + NSHumanReadableCopyright: "" + NSPrincipalClass: NSApplication + entitlements: + path: Sidecar/Sidecar.entitlements + properties: + com.apple.security.app-sandbox: false + com.apple.security.network.client: true + com.apple.security.network.server: false + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.sidecar.app + CODE_SIGN_STYLE: Automatic + CODE_SIGN_IDENTITY: "-" + ENABLE_PREVIEWS: YES