在 macOS 上构建多用户服务管理 TUI

在 macOS 上构建多用户服务管理 TUI

前言

在很多桌面自动化或本地服务场景里,我们会遇到这样一个需求:在一台 macOS 上同时托管多组服务进程,并希望用一个友好的终端界面把复杂操作包起来。本文从架构到实现,整理一套常见的 Go + TUI 设计思路。

一、背景:为什么会诞生这样一类工具?

很多团队在做「桌面侧的自动化 / 私有服务」时,会碰到类似的需求:

  • 一台物理 Mac,要为多个人、多个租户或多个环境提供服务;
  • 每个服务需要:
    • 一个独立的系统用户(隔离权限和数据目录);
    • 若干本地服务进程(例如 Node 服务 + 反向代理客户端);
    • 若干 macOS 权限(信息、通讯录、自动化、辅助功能等);
  • 运维方希望:
    • 有一套可重复的「主机初始化」流程;
    • 能批量创建 / 增减「服务用户」;
    • 不想把运维同事扔进一堆 bash 脚本里祈祷;
    • 最好有个 TUI,把复杂操作抽象成「往下翻菜单、按回车」。

下面会以 Go + Bubble Tea + Lipgloss + macOS LaunchAgents/LaunchDaemons 为例,说明一种常见的实现路径。


二、架构总览:分层与入口设计

在这类工具里,一个常见的分层方式是:

  1. Core 层:只关心「业务流程」本身

    • 例如:
      • 预检主机环境
      • 初始化主机
      • 批量创建 / 增加 / 删除服务用户
      • 查询服务运行状态
    • 不直接碰 os/exec、不操作文件,只依赖一组抽象函数。
  2. Infra 层:和真实系统打交道

    • 对应的职责包括:
      • macOS 预检(SIP / boot-args / 安全策略)
      • 依赖安装(Homebrew、Node、反向代理客户端等)
      • 创建 / 删除系统用户
      • 下载并解压服务 bundle
      • 写配置文件、写 LaunchDaemon / LaunchAgent
      • 运行 launchctlsysadminctl 等命令
    • 核心思想:所有「危险操作」都收容在这里
  3. UI 层(TUI):提供面向运维方和面向使用方的终端界面

    • 管理视角:面向管理员
      • 初始化主机
      • 创建 / 查看 / 删除服务用户
      • 查看每个用户的服务状态
    • 使用视角:面向具体服务用户
      • 预热权限(触发系统弹窗)
      • 部署 / 启停本地服务
      • 修改友好名称
      • 向上游服务请求一次性密钥

入口程序大致长这样:

func main() {
    // 1. 可选:加载 .env,准备 token / 配置
    env.Load()

    // 2. 根据子命令决定模式
    if len(os.Args) > 1 {
        switch os.Args[1] {
        case "host-autoboot":
            runHostAutoboot()
            return
        case "user":
            runUserTUI()
            return
        }
    }

    // 3. 默认进入 Host TUI
    if err := runHostTUI(); err != nil {
        log.Printf("host TUI exited with error: %v", err)
        os.Exit(1)
    }
}

这里的三个入口模式值得单独强调:

  • Host TUI:给「这台机器的管理员」使用;
  • User TUI:给「每个服务用户」使用;
  • host-autoboot:给 LaunchDaemon 调用,在开机时把所有用户的 LaunchAgent 拉起来。

三、Core:一个「主机编排器」该长什么样?

我们可以定义一个 Orchestrator,它不关心命令细节,只关心要执行的步骤

type Orchestrator struct {
    ConfigPath string
    StatePath  string

    LoadConfig func(string) (Config, error)
    LoadState  func(string) (State, error)
    SaveState  func(string, State) error

    CheckEnvironment func(ctx context.Context) (EnvCheckResult, error)
    EnsureDeps       func(ctx context.Context) (DepsResult, error)

    SetupAccounts    func(ctx context.Context, cfg Config, st State, count int) (State, string, error)
    AppendAccounts   func(ctx context.Context, cfg Config, st State, count int) (State, string, error)
    DeleteAccount    func(ctx context.Context, cfg Config, st State, username string) (State, error)
    InspectServices  func(ctx context.Context, cfg Config, st State) ([]AccountStatus, error)
}

这样的好处是:

  • core 层不直接依赖任何具体命令,只通过函数指针调用;
  • 单元测试可以轻松注入假的 CheckEnvironment / EnsureDeps 等;
  • infra 变化时,只需要改注入的实现,不影响 core 逻辑。

3.1 只读预检:Inspect

通常会设计一个「只读模式」的预检,专门给 UI 用来展示环境状态:

type InspectResult struct {
    AlreadySetup bool
    EnvCheck     EnvCheckResult
    Deps         DepsResult
}

func (o *Orchestrator) Inspect(ctx context.Context) (InspectResult, error) {
    env, err := o.CheckEnvironment(ctx)
    if err != nil {
        return InspectResult{EnvCheck: env}, fmt.Errorf("env check: %w", err)
    }

    deps, err := o.EnsureDeps(ctx)
    if err != nil {
        return InspectResult{EnvCheck: env, Deps: deps}, fmt.Errorf("deps: %w", err)
    }

    st, err := o.LoadState(o.StatePath)
    if err != nil {
        return InspectResult{EnvCheck: env, Deps: deps}, fmt.Errorf("load state: %w", err)
    }

    if st.Initialized || len(st.Users) > 0 {
        return InspectResult{AlreadySetup: true, EnvCheck: env, Deps: deps}, nil
    }

    if _, err := o.LoadConfig(o.ConfigPath); err != nil {
        return InspectResult{EnvCheck: env, Deps: deps}, fmt.Errorf("load config: %w", err)
    }

    return InspectResult{AlreadySetup: false, EnvCheck: env, Deps: deps}, nil
}

关键点:

  • 不写任何状态,也不创建用户;
  • 把预检结果、依赖检查结果以及主机是否已初始化,一起丢给 UI 层展示;
  • UI 可以安全地让用户「多试几次」,而不用担心半途搞脏环境。

3.2 真正动手:Setup / Append / Delete

  • SetupAccounts:用于「第一次初始化」,批量创建一组系统用户;
  • AppendAccounts:在已有用户基础上追加若干新用户,避免编号冲突;
  • DeleteAccount:删除某个服务用户及其记录;
  • InspectServices:只读地检查每个用户的服务目录和本地端口监听情况,用来画一页「服务状态」。

在 core 层,它们只是流程拼接:

func (o *Orchestrator) Bootstrap(ctx context.Context, count int) (BootstrapResult, error) {
    cfg, err := o.LoadConfig(o.ConfigPath)
    if err != nil { /* ... */ }
    st,  err := o.LoadState(o.StatePath)
    if err != nil { /* ... */ }

    newState, secretsPath, err := o.SetupAccounts(ctx, cfg, st, count)
    if err != nil { /* ... */ }

    if err := o.SaveState(o.StatePath, newState); err != nil { /* ... */ }

    return BootstrapResult{State: newState, SecretsPath: secretsPath}, nil
}

所有显式的 sysadminctllaunchctl、文件写入都只存在于 infra 层。


四、Infra:和 macOS 打交道的「脏活累活」

4.1 创建系统用户与布置服务目录

常见的模式是:

  1. sysadminctl 创建用户:
func createAccount(ctx context.Context, username, password string) error {
    cmd := exec.CommandContext(ctx, "sysadminctl",
        "-addUser", username,
        "-fullName", username,
        "-password", password,
    )
    out, err := cmd.CombinedOutput()
    if err != nil {
        return fmt.Errorf("create account %s: %w (output=%s)", username, err, strings.TrimSpace(string(out)))
    }
    return nil
}
  1. /Users/<username>/services/<service> 下布置服务目录:
    • 拷贝解压后的服务 bundle;
    • 生成该用户专属的配置文件(端口、子域名、上游地址等);
    • 生成反向代理配置;
    • 生成一个便捷的 wrapper(例如 ./service user 这样的入口);
    • 写 LaunchAgent,用于在登录或开机后自动拉起;
    • 在 root 权限下执行 chown -R username,避免后续权限问题。

这里很值得注意的是:

  • 子域名的生成策略
    • 可以从旧配置里复用(避免频繁变更影响外部路由);
    • 如果没有,就随机生成一个短的前缀。
  • 写入 LaunchAgent / LaunchDaemon 时的错误处理
    • 权限不足时,往往选择「记录日志但不阻塞整体流程」;
    • 开发 / 测试环境经常没有 root 权限,完全禁止创建 LaunchDaemon 会让测试非常痛苦。

4.2 Host 自启动机制:主机重启后自动拉起所有用户服务

这是整个系统自动化的关键环节,分两层:

第一层:Host 级 LaunchDaemon

在初始化完成后,在 /Library/LaunchDaemons 下写入一个系统级 LaunchDaemon:

<plist version="1.0">
<dict>
    <key>Label</key>
    <string>com.host.autoboot</string>
    <key>ProgramArguments</key>
    <array>
        <string>/path/to/binary</string>
        <string>host-autoboot</string>
    </array>
    <key>RunAtLoad</key>
    <true/>
</dict>
</plist>

这个 LaunchDaemon 会在系统启动时自动运行 host-autoboot 子命令。

第二层:host-autoboot 逻辑

host-autoboot 子命令的职责是:

  1. 读取 state.json,获取所有已创建的用户列表
  2. 对每个用户,通过 launchctl bootstrap gui/<uid> 拉起其 LaunchAgents:
    • com.prism.autoboot.plist(用户级自启动脚本)
    • com.service.server.<username>.plist(如果已部署)
    • com.service.proxy.<username>.plist(如果已部署)
func runHostAutoboot(statePath string) {
    st, _ := loadState(statePath)
    for _, u := range st.Users {
        uid := lookupUID(u.Name)
        domain := fmt.Sprintf("gui/%s", uid)
        
        // 拉起用户级自启 LaunchAgent
        launchctl("bootstrap", domain, "/Users/"+u.Name+"/Library/LaunchAgents/com.prism.autoboot.plist")
        
        // 如果服务已部署,也拉起服务 LaunchAgents
        if fileExists(serverPlist) {
            launchctl("bootstrap", domain, serverPlist)
        }
        if fileExists(proxyPlist) {
            launchctl("bootstrap", domain, proxyPlist)
        }
    }
}

第三层:用户级 boot.sh

每个用户的 com.prism.autoboot.plist 会调用 boot.sh,这个脚本做了一个巧妙的设计:

#!/bin/zsh
# 挂起当前用户的图形会话(避免弹出登录窗口)
/System/Library/CoreServices/Menu\ Extras/User.menu/Contents/Resources/CGSession -suspend

# 立即关闭显示器(节省资源)
pmset displaysleepnow

cd /Users/<username>/services/app
./binary user  # 进入用户 TUI 模式
exit 0

这样设计的好处:

  • 系统重启后,所有用户的服务都会自动拉起
  • 不需要每个用户手动登录
  • 图形会话被挂起 + 显示器关闭,不会占用 GUI 资源
  • 即使某个用户的服务崩溃,也不影响其他用户

五、用户模式:让每个「小人」自己管理服务

当主机层面铺好所有服务目录后,每个具体服务用户只需要关注三件事:

  1. 权限预热
  2. 服务部署与运行
  3. 上游凭证与友好名

5.1 权限预热:以「试探」方式触发系统弹窗

macOS 的隐私权限系统相对严格,比如:

  • 访问消息数据库(Messages)
  • 控制「通讯录」「查找」「系统事件」等 App

一套比较温和的做法是:

  1. 使用 nvram / defaults 读取关键设置,看看是否已经按 Host 侧预检要求配置;
  2. osascript 调用 AppleScript,做一些「最小读取」动作:
func warmUpPermissions() string {
    ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
    defer cancel()

    // 尝试读取 boot-args
    _ = exec.CommandContext(ctx, "nvram", "boot-args").Run()

    // 触发 Messages / Contacts / System Events 等权限弹窗
    _ = exec.CommandContext(ctx, "osascript", "-e", `tell application "Messages" to get name of first chat`).Run()
    _ = exec.CommandContext(ctx, "osascript", "-e", `tell application "Contacts" to get name of first person`).Run()
    _ = exec.CommandContext(ctx, "osascript", "-e", `tell application "System Events" to get name of first process`).Run()

    // 汇总警告信息,返回一段给 UI 展示的字符串
    return "Permission warm-up completed, please grant pending prompts in System Settings if needed."
}

这种方式的特点是:

  • 不直接修改系统设置,只尝试访问,交给系统自己弹窗;
  • 即便在部分权限受限的环境中,也能给出尽可能多的「诊断性提示」。

5.2 部署与管理服务:LaunchAgent + Health Check

用户模式下的「部署」流程常常是下面这样:

  1. 从用户目录读取 config.json / *.toml 等配置,校验关键字段:
    • 本地端口
    • 服务可执行路径
    • 上游地址、域名等
  2. 自动检测友好名
    • 通过 defaults read com.apple.madrid IMD-IDS-Aliases 读取 iMessage 绑定的手机号 / 邮箱
    • 用正则提取第一个手机号(+123...)或邮箱
    • 写入反向代理配置的 metadatas.friendlyName 字段
    • 这样上游控制台就能直接看到「这是哪个手机号的节点」
  3. 确认主服务二进制、依赖(例如 Node)、反向代理二进制都就位且可执行;
  4. ~/Library/LaunchAgents 写两类 LaunchAgent:
    • 一个拉起业务服务;
    • 一个拉起反向代理;
  5. 使用 launchctl bootout + launchctl bootstrap 替换 / 重启对应的 label;
  6. 在本机做一个真实的 HTTP health check:
func pollHealthEndpoint(url string, timeout time.Duration) error {
    deadline := time.Now().Add(timeout)
    client := &http.Client{Timeout: 2 * time.Second}

    for {
        resp, err := client.Get(url)
        if err == nil {
            _ = resp.Body.Close()
            if resp.StatusCode >= 200 && resp.StatusCode < 300 {
                return nil
            }
        }

        if time.Now().After(deadline) {
            if err != nil {
                return err
            }
            return fmt.Errorf("health check %s did not succeed", url)
        }

        time.Sleep(500 * time.Millisecond)
    }
}

最终返回的是一段长文本,例如:

Deploy succeeded: services started and local health is OK at http://localhost:10001/health. Logs are located under ~/Library/Logs/…

UI 层只需要把这段话原样显示即可。


六、额外的工程细节

6.1 下载服务 Bundle:支持 GitHub 私有仓库

在配置文件里,服务 bundle 的下载地址可以是:

  • 直接 HTTP(S) URL
  • gh://owner/repo/asset-name 的简写格式

对于 gh:// 格式,会:

  1. 调用 GitHub API:GET /repos/{owner}/{repo}/releases/latest
  2. assets 里找到匹配的 asset-name
  3. 获取其 browser_download_url
  4. 如果是私有仓库,则在请求头里带上 Authorization: Bearer $GITHUB_TOKEN
  5. 下载并解压到缓存目录

这样做的好处是:

  • 不需要手动维护完整 URL
  • 自动拉取 latest release
  • 支持私有仓库(只需要在 .env 里配置 GITHUB_TOKEN

6.2 Per-User Binary Copy:避免跨用户权限问题

在为每个用户准备服务目录时,会把 host 侧的主程序二进制拷贝一份到用户家目录:

// 拷贝 host 侧二进制到用户目录
copyExecutable("/path/to/host/binary", "/Users/<username>/services/app/binary-host")

// 写一个 wrapper脚本
wrapper := `#!/bin/zsh
exec "./binary-host" user "$@"
`
writeFile("/Users/<username>/services/app/binary", wrapper, 0755)

这样用户只需要运行 ./binary user,而不用关心:

  • host 侧二进制的路径
  • 跨用户执行权限问题
  • 二进制更新后的同步

6.3 生成可读性较好的强口令(排除易混淆字符)

在批量创建系统用户或为服务生成访问口令时,建议默认排除容易混淆的字符,避免误读和输入错误:

  • 排除:0O1I/L
  • 同时去掉小写 i/l/o,进一步降低混淆
  • 使用 crypto/rand 生成高熵随机串,默认长度建议 ≥ 16

一个简单的 Go 实现示例:

package safe

import (
    "crypto/rand"
    "math/big"
)

// 仅包含不易混淆的字符:
//  - 大写:去掉 I/L/O
//  - 小写:去掉 i/l/o
//  - 数字:去掉 0/1
var alpha = []rune("ABCDEFGHJKMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789")

func GeneratePassword(n int) (string, error) {
    if n < 12 { // 最低兜底长度,可根据需要提高到 16
        n = 16
    }
    out := make([]rune, n)
    max := big.NewInt(int64(len(alpha)))
    for i := 0; i < n; i++ {
        idx, err := rand.Int(rand.Reader, max)
        if err != nil {
            return "", err
        }
        out[i] = alpha[idx.Int64()]
    }
    return string(out), nil
}

实际使用示例:

pw, err := GeneratePassword(16)
if err != nil { /* 处理错误 */ }
// 将 pw 传入 sysadminctl 或写入 secrets 存储

备注:如果场景允许,也可以在字符集里加入少量符号(避免需要额外转义的字符)。


七、TUI:用 Bubble Tea + Lipgloss 包一个「不吓人」的界面

7.1 消息驱动的状态机

Bubble Tea 的模式非常适合这类「菜单 + 状态文本」的工具。一个典型的管理视图模型大致是:

type RootViewModel struct {
    cursor int
    status string

    checkRunning bool
    checkResult  *InspectResult
    actionKind   actionKind
    // ... 其他状态字段
}

func (m RootViewModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        // 处理上下移动和回车
        // 根据当前 cursor 决定是 "初始化"、"增加用户" 还是 "查看状态"

    case checkDoneMsg:
        m.checkRunning = false
        m.checkResult = &msg.result
        if msg.err != nil {
            m.status = "Environment is not ready. Please follow the hints below."
        } else {
            m.status = "Environment checks completed."
        }

    case actionDoneMsg:
        // 更新用户列表和提示
    }
    return m, nil
}

核心经验是:

  • UI 层维护状态和文案,不做任何系统调用;
  • 所有「重操作」都封装成 tea.Cmd,在 goroutine 里执行;
  • 通过 xxxDoneMsg 将结果带回 UI。

7.2 色彩与信息密度

Lipgloss 可以让 TUI 看起来没那么「运维脚本味」,一些实用技巧:

  • 标题区域用一个浅色背景 + 深色文字的胶囊,配一个小统计(例如 3 items);
  • 左侧用彩色竖线高亮当前选项:
    • 未选中:前缀两个空格
    • 选中:前缀 ,颜色换成 accent color
  • 错误与成功分别用固定色:
    • 成功:亮绿 / 柔和蓝
    • 失败:玫瑰红 / 橙色
  • 对于长错误信息:
    • UI 不直接 dump stack trace;
    • 在 infra 里根据常见模式(权限、网络、已存在等)生成简短提示;
    • UI 只展示第一行或截断到 80 个字符。

八、错误处理策略:什么该「死」,什么可以「算了」

在真实环境下,这类工具难免要面对各种权限与环境限制。一个实用的经验是:

  • 把错误分成两类:
    1. 硬错误(必须失败)
      • 创建 / 删除系统用户失败;
      • 写 state / config / secrets 失败;
      • 下载 / 解压服务 bundle 失败;
      • 预检未通过;
    2. 软错误(尽力而为)
      • 写 LaunchDaemon / LaunchAgent 时权限不足;
      • launchctl 某些版本返回的泛用错误码(例如 exit status 5);
      • chown 在受限环境不能执行。

对「软错误」的处理通常是:

  • 记录日志;
  • 在返回值里追加一个 note;
  • 不影响主流程,例如仍视为「部署成功」,但提示「开机自动启动可能需要手动配置」。

九、小结:把「重运维」做成「轻体验」

这类 Go + TUI 的 macOS 多用户管理工具,本质是在做一件事:

把一整套复杂又略带危险的系统操作,抽象成几步可视化的菜单,并尽量给出人类能看懂的反馈。

几个关键设计点回顾一下:

  • Core / Infra / UI 三层架构,把业务流程、系统细节和交互彻底分开;
  • 在 Core 中通过函数注入,获得良好的可测试性和可替换性;
  • 在 Infra 中集中处理 macOS 预检、依赖安装、创建用户、写 LaunchAgent 等「脏活累活」;
  • 在 UI 中用 Bubble Tea + Lipgloss 打造一个「信息密度足够、但不吓人」的终端界面;
  • 在错误策略上,区分必须失败和可以降级的场景,保证在各种限制环境下都能「尽量干点有用的事情」。

如果你也在做类似的本地服务编排工具,希望这篇小结能给你一些架构和实现上的灵感。

#macOS#TUI#Go#多用户#系统架构