From 90a48fba39673e6ed2e8851b84b750bedbb506ac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 12:33:34 +0000 Subject: [PATCH 1/3] docs: scaffold Sidecar repo with architecture and protocol drafts Sidecar is a lightweight Tailscale-network device dashboard for macOS, built around desktop widgets and a Grafana-lite system metrics view. This first commit lands only the design docs so the agent and macOS app can be built against a stable shape. - README explains the project's motivation, structure, and roadmap. - docs/architecture.md covers the Go agent (binds tailscale0, depends on host tailscaled), the SwiftUI + WidgetKit Mac app (peer discovery, poller, App Group shared store, theme system), and security boundaries. - docs/protocol.md drafts the v0 JSON contract for /healthz, /info, and /metrics, with field semantics, nullability rules, and a forward- compatibility policy. --- .gitignore | 43 +++++++++++++++ README.md | 87 ++++++++++++++++++++++++++++++ docs/architecture.md | 117 ++++++++++++++++++++++++++++++++++++++++ docs/protocol.md | 124 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 371 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 docs/architecture.md create mode 100644 docs/protocol.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a0e6f42 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# macOS +.DS_Store +.AppleDouble +.LSOverride +Icon? + +# Xcode +build/ +DerivedData/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcuserstate +*.xcscmblueprint +*.xccheckout +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# 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/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)。 From 4000528f3a638ba9f356ff85e6893cedd05fb114 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 12:47:16 +0000 Subject: [PATCH 2/3] feat(agent): scaffold Go agent with /healthz /info /metrics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First working cut of sidecar-agent (M1). Single Go binary, no CGO, builds clean for darwin/arm64, darwin/amd64, linux/amd64, windows/amd64. Identifies the Tailscale interface by IP range (CGNAT 100.64/10 for v4, fd7a:115c:a1e0::/48 for v6) rather than name, so it works regardless of whether the OS named it tailscale0, utun*, or "Tailscale". Binds only to those addresses; refuses to fall back to 0.0.0.0. A --bind override is provided for local dev without Tailscale. Endpoints match docs/protocol.md v0.1: - GET /healthz → plain "ok" - GET /info → agent / host / tailnet / capabilities - GET /metrics → 1s-rolling snapshot of CPU, mem, load (POSIX only), disks (≥1 GiB), and per-NIC rx/tx bps + totals. A background goroutine samples once per second, holds the latest snapshot in an atomic pointer, and HTTP handlers read it without blocking the sampler. Net rates are deltas computed from the previous tick. X-Sidecar-Agent: v0.1 protocol header is emitted on every response so the Mac client can refuse to talk to a major-bumped agent. --- agent/README.md | 77 ++++++++++ agent/cmd/sidecar-agent/main.go | 89 +++++++++++ agent/go.mod | 16 ++ agent/go.sum | 36 +++++ agent/internal/collector/collector.go | 145 ++++++++++++++++++ agent/internal/collector/snapshot.go | 72 +++++++++ agent/internal/netif/tailscale.go | 84 ++++++++++ agent/internal/server/server.go | 211 ++++++++++++++++++++++++++ 8 files changed, 730 insertions(+) create mode 100644 agent/README.md create mode 100644 agent/cmd/sidecar-agent/main.go create mode 100644 agent/go.mod create mode 100644 agent/go.sum create mode 100644 agent/internal/collector/collector.go create mode 100644 agent/internal/collector/snapshot.go create mode 100644 agent/internal/netif/tailscale.go create mode 100644 agent/internal/server/server.go 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}, + }) +} From b4eeb78bacfa205a7eadc4f4c3a17b40e72329ab Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 8 May 2026 12:53:32 +0000 Subject: [PATCH 3/3] feat(macapp): scaffold SwiftUI dashboard with grid + mock mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First cut of sidecar-mac (M2). Targets macOS 14, no third-party runtime deps. The .xcodeproj is generated from project.yml via XcodeGen — run `xcodegen generate` on first checkout and the file is gitignored. Architecture mirrors docs/architecture.md: - Models/ — Device + DeviceState (runtime) and AgentDTO (Codable types matching docs/protocol.md exactly, with snake_case CodingKeys and a custom JSONDecoder that handles RFC 3339 with optional fractional seconds, since the agent emits both forms). - Services/TailscaleCLI — `Process` wrapper; resolves the binary from /usr/local/bin, /opt/homebrew/bin, and inside Tailscale.app. Decodes only the small subset of `tailscale status --json` we actually need (Self + Peer.{ID, HostName, DNSName, OS, TailscaleIPs, Online}) so Tailscale schema drift mostly doesn't break us. - Services/PeerDiscovery — combines tailscale CLI with parallel /healthz probes (TaskGroup) at the agent's default 8765. - Services/AgentClient — async URLSession wrapper for /info /metrics; rejects responses whose X-Sidecar-Agent header isn't on the v0 line. - Services/Poller — actor running a periodic /metrics fan-out and reporting back via an async callback. - Services/MockData — deterministic mock devices and snapshots for designing the UI without a tailnet (and for screenshots). - ViewModel/DashboardModel — @Observable @MainActor source of truth. Switching DataSource.live <-> .mock tears down and rebuilds discovery / poller / mock task cleanly. - Views/GridOverview — adaptive LazyVGrid with a Canvas-drawn dotted backdrop (only when theme.showsGridBackdrop is true). - Views/DeviceCard — hostname, OS glyph, online dot with glow, CPU/MEM bars, mini RX/TX rate readout. Shows "no agent" / error states. - Theme/Theme.swift — three themes (Grid / Phosphor / Glass) plus shared Tokens for corner radius, spacing, and monospaced fonts. App Sandbox is intentionally disabled because we shell out to the `tailscale` binary; macapp/README documents the trade-off and possible future paths (LocalAPI socket or privileged helper). Suitable for v0 since distribution is direct, not Mac App Store. Code is written but unverified — I'm developing in a Linux sandbox and have no Swift toolchain available, so the commit may fail to compile on first try and need small fixes once opened in Xcode. --- .gitignore | 4 + macapp/README.md | 77 +++++++++ macapp/Sidecar/ContentView.swift | 39 +++++ macapp/Sidecar/Info.plist | 16 ++ macapp/Sidecar/Models/AgentDTO.swift | 126 ++++++++++++++ macapp/Sidecar/Models/Device.swift | 28 ++++ macapp/Sidecar/Services/AgentClient.swift | 61 +++++++ macapp/Sidecar/Services/MockData.swift | 72 ++++++++ macapp/Sidecar/Services/PeerDiscovery.swift | 50 ++++++ macapp/Sidecar/Services/Poller.swift | 64 +++++++ macapp/Sidecar/Services/TailscaleCLI.swift | 116 +++++++++++++ macapp/Sidecar/Sidecar.entitlements | 10 ++ macapp/Sidecar/SidecarApp.swift | 17 ++ macapp/Sidecar/Theme/Theme.swift | 56 +++++++ macapp/Sidecar/ViewModel/DashboardModel.swift | 135 +++++++++++++++ macapp/Sidecar/Views/DeviceCard.swift | 157 ++++++++++++++++++ macapp/Sidecar/Views/GridOverview.swift | 90 ++++++++++ macapp/project.yml | 47 ++++++ 18 files changed, 1165 insertions(+) create mode 100644 macapp/README.md create mode 100644 macapp/Sidecar/ContentView.swift create mode 100644 macapp/Sidecar/Info.plist create mode 100644 macapp/Sidecar/Models/AgentDTO.swift create mode 100644 macapp/Sidecar/Models/Device.swift create mode 100644 macapp/Sidecar/Services/AgentClient.swift create mode 100644 macapp/Sidecar/Services/MockData.swift create mode 100644 macapp/Sidecar/Services/PeerDiscovery.swift create mode 100644 macapp/Sidecar/Services/Poller.swift create mode 100644 macapp/Sidecar/Services/TailscaleCLI.swift create mode 100644 macapp/Sidecar/Sidecar.entitlements create mode 100644 macapp/Sidecar/SidecarApp.swift create mode 100644 macapp/Sidecar/Theme/Theme.swift create mode 100644 macapp/Sidecar/ViewModel/DashboardModel.swift create mode 100644 macapp/Sidecar/Views/DeviceCard.swift create mode 100644 macapp/Sidecar/Views/GridOverview.swift create mode 100644 macapp/project.yml diff --git a/.gitignore b/.gitignore index a0e6f42..f99849a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,10 @@ DerivedData/ *.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/ 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