xbar 技术架构深度解析:用 Go + Wails + Svelte 重写 BitBar 的完整方案
【免费下载链接】xbarPut the output from any script or program into your macOS Menu Bar (the BitBar reboot)项目地址: https://gitcode.com/gh_mirrors/xb/xbar
本文基于 xbar 仓库中的技术演讲大纲(talk-overview.md)展开,逐层拆解这个"macOS 菜单栏脚本输出工具"的完整技术实现:从 monorepo 项目结构、Wails v2 桌面应用框架,到 Svelte + Tailwind CSS 前端、Go 后端服务与插件输出解析管线,再到由自定义工具生成的静态站点 xbarapp.com。读完本文,你将理解 xbar 是如何把任意脚本的输出变成 macOS 菜单栏交互菜单的,并能直接对照仓库源码验证每一个环节。
一、xbar 是什么
xbar(the BitBar reboot)是一个完全开源、用 Go 从零重写的 macOS 菜单栏应用,它允许用户把任何脚本或程序的输出直接放到 macOS 菜单栏中。与它的前身 BitBar 一样,xbar 遵循"插件即脚本"的极简理念:你只需要在插件目录里放一个可执行脚本,xbar 就会按脚本文件名中约定的刷新周期定时执行它,并把标准输出渲染成菜单栏下拉菜单。
从 README.md 可以看到项目的核心定位:
- 由 @matryer 与 @leaanthony 使用 Wails.app 全新重写(用 Go 与 HTML/CSS/JS 构建跨平台桌面应用);
- 完全开源;
- 需要 macOS Catalina 或更新版本(>= 10.15);
- 插件存放在
~/Library/Application Support/xbar/plugins目录下,从 BitBar 迁移的用户只需把插件移入该目录即可继续使用。
在 app/app.go 中可以看到这两个默认路径的源码级定义:
pluginDirectory = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "xbar", "plugins") cacheDirectory = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "xbar", "cache") configFilename = filepath.Join(os.Getenv("HOME"), "Library", "Application Support", "xbar", "xbar.config.json")二、项目结构:一个包含一切的 monorepo
按照演讲大纲(talk-overview.md)的描述,xbar 采用monorepo组织方式,"简单"(simple)是它的设计原则——单个仓库里同时容纳了桌面应用的前后端、工具链和官方网站。对照当前仓库的顶层目录,这一点可以得到直接印证:
| 目录/文件 | 职责 |
|---|---|
| app/ | 桌面应用主体:Go 后端(Wails)与 Svelte 前端 |
| pkg/ | 可复用的 Go 包:插件解析、元数据、更新等 |
| tools/ | 工具链:站点生成器 sitegen、插件检查工具 xbarmdcheck |
| xbarapp.com/ | 官方静态网站(插件浏览与文档) |
| archive/ | 归档的历史代码(BitBar 的 Objective-C 源码) |
值得注意的是 archive/bitbar/ 中保留了 BitBar 的完整 Objective-C 源码与 Xcode 工程,它既是 xbar 的历史参照,也印证了"从 BitBar 重启(reboot)"的定位——xbar 并没有沿袭 Objective-C 技术栈,而是完全用 Go 重写。
三、Wails:xbar 的桌面应用底座
xbar 依赖Wails(由 Lea Anthony 维护)构建桌面应用外壳。演讲大纲特别指出 xbar 使用的是正在重写的Wails v2。Wails 的职责是:
- 构建并打包面向多平台的桌面应用(Go 语言);
- 提供一个 WebView 用于承载前端界面;
- 处理操作系统调用;
- 提供客户端与"服务端"之间的 RPC 通信。
在 app/main.go 中,Wails v2 的接入方式一目了然:
err = wails.Run(&options.App{ Title: "xbar", Width: 1080, Height: 700, MinWidth: 800, MinHeight: 600, StartHidden: true, HideWindowOnClose: true, Mac: &mac.Options{ WebviewIsTransparent: true, WindowBackgroundIsTranslucent: true, TitleBar: mac.TitleBarHiddenInset(), Menu: app.appMenu, ActivationPolicy: mac.NSApplicationActivationPolicyAccessory, URLHandlers: map[string]func(string){ // xbar://... "xbar": app.handleIncomingURL, }, }, ContextMenus: app.contextMenus, LogLevel: wailsLogLevel, Startup: app.Start, Shutdown: app.Shutdown, Bind: []interface{}{ app.PersonService, app.CategoriesService, app.PluginsService, app.CommandService, }, })这段代码透露了几个关键设计:
- StartHidden + NSApplicationActivationPolicyAccessory:xbar 是菜单栏应用,窗口默认隐藏,不占用 Dock;
- URLHandlers:注册
xbar://协议处理(详见后文"Incoming URLs"); - Bind:把四个 Go 服务绑定到前端,前端通过 Wails 生成的 RPC 绑定直接调用。
Wails 的项目配置见 app/wails.json,它声明了前端构建入口(frontend/public/index.html)与前端构建/安装命令(npm run build、npm install)。
四、前端:Svelte + Tailwind CSS
演讲大纲用三个要点概括了 xbar 的前端选型:
- Svelte:组件内同时包含 markup、script 与 style;它在编译期完成大量工作(而非浏览器运行时),因此非常快;
- Tailwind CSS:底层(low-level)CSS 框架,提供精细的控制;
- 暗色模式(dark mode)支持。
前端源码位于 app/frontend/:
- 依赖清单见 app/frontend/package.json,核心依赖为
svelte@^3.32.2、tailwindcss@^3.3.1,并使用 Rollup(rollup-plugin-svelte、rollup-plugin-postcss)打包; - 构建配置见 app/frontend/rollup.config.js,将 src/main.js 打包为 IIFE 格式的
public/bundle.js; - 组件全部以
.svelte形式组织在 app/frontend/src/elements/:包括PluginCollection.svelte、PluginDetails.svelte、PluginSourceBrowser.svelte、Variables.svelte、VariableInput.svelte、Breadcrumbs.svelte、KeyboardShortcuts.svelte等,覆盖了插件浏览器的各类 UI 场景。
前端与 Go 后端的 RPC 绑定由 Wails 自动生成,位于 app/frontend/src/backend/index.js(文件头注释标明"automatically generated, DO NOT EDIT")。通过它可以看到前端可调用的完整服务面:CategoriesService.GetCategories、CommandService.ClearCache/OpenFile/OpenPath/OpenURL/RefreshAllPlugins、PersonService.GetPersonDetails、PluginsService.GetPlugins/GetPlugin/InstallPlugin/UninstallPlugin/SetEnabled/SetRefreshInterval/LoadVariableValues/SaveVariableValues等。
关于暗色模式,后端在 app/app.go 中通过setDarkMode监听系统主题变化,并向后端进程设置两组环境变量:BitBarDarkMode(向后兼容 BitBar)与XBARDarkMode。这意味着插件脚本可以读取环境变量感知系统外观,从而输出适配暗色模式的菜单内容。主题变化时还会触发app.RefreshAll()让所有插件重新渲染。
五、Go 后端:从启动到菜单渲染
5.1 main.go:只负责"点燃" Wails
演讲大纲特意强调:main.go 只是启动 Wails 应用。事实正是如此——app/main.go 中的main()打印版本号并调用run(),而run()的唯一职责就是构建app对象并交给wails.Run。所有业务逻辑都收敛在app结构体及其服务中(app/app.go)。
5.2 app.Start 回调与 *wails.Runtime
Wails 的Startup: app.Start回调在应用启动后执行(app/app.go),它做几件关键的事:
- 通过
runtime.System.IsDarkMode()初始化暗色模式状态,并注册runtime.Events.OnThemeChange监听主题切换; - 保存
runtime到app.runtime,并注入到各服务; - 确保插件目录存在(
os.MkdirAll(pluginDirectory, 0777)); - 调用
app.RefreshAll()首次加载全部插件; - 启动一个后台 goroutine,延迟 10 秒后首次检查更新,此后每 12 小时检查一次。
*wails.Runtime对象是后端与系统交互的统一入口:菜单管理(app.runtime.Menu.SetTrayMenu)、窗口控制(app.runtime.Window.Show)、事件分发(app.runtime.Events.Emit)、对话框(app.runtime.Dialog.Message)都经由它完成。
5.3 clearCache 与函数抽象
演讲大纲中提到的clearCache体现了"函数抽象"的编程风格。在 app/app.go 中,clearCache(passive bool)负责清空磁盘缓存目录cacheDirectory,并通过PluginsService.osLock防止并发执行;passive参数决定失败时是静默忽略还是弹出错误对话框。这个函数被赋值给CommandService.clearCache(app/app.go),使得前端可以通过CommandService.ClearCache()触发缓存清理——这正是"函数作为字段注入"的抽象用法。菜单栏中"Clear cache and refresh"(Cmd+Shift+R)与"Clear Cache"菜单项最终都回调到这里(app/app.go)。
5.4 解析插件输出:TDD 的理想候选
演讲大纲将"Parsing plugin output"标注为"Great candidate for TDD"(非常适合测试驱动开发),因为其本质是字符串处理,边界清晰、易于用例化,甚至考虑过fuzzing(模糊测试)。
插件输出解析的实现位于 pkg/plugins/parse.go,核心常量与逻辑:
const ( nesting = "--" // 层级前缀,两个连字符表示一级子菜单 separator = "---" // 分隔线/展开模式标记 )parseOutput逐行读取插件标准输出,用bufio.Reader按行处理,并根据行首--的数量推断菜单层级:没有--前缀的是顶级菜单项,--表示一级子菜单,---作为整行时是分隔线。遇到---时切换"展开模式"(expanded items),用于支持多行循环显示的插件。解析出错时返回带文件名与行号的结构化错误errParsing,便于定位问题插件。
仓库中配套了完整的测试集:pkg/plugins/parse_test.go 覆盖各种输出形态,测试插件样例位于 pkg/plugins/testdata/plugins/,例如001-multiple.1s.sh、002-multiple.1s.sh、expanded.1s.sh、params.3s.sh等,文件名中的.1s、.3s、.1m后缀即插件刷新周期约定。menu_parser_test.go(app 包)则进一步验证了解析结果与菜单结构的一致性。
5.5 把解析结果变成 Wails 菜单
解析出的Item树要转换成真正的 macOS 菜单,这一步由 app/menu_parser.go 的MenuParser完成。ParseItems遍历插件输出的 items,对每个 item 调用ParseMenuItem:
- 无动作且无子菜单的菜单项自动置为
Disabled; item.Params.Key通过keys.Parse解析为快捷键加速器;- 支持
Image、TemplateImage(模板图,MacTemplateImage兼容所有 macOS 版本)、字体名/字号/颜色(RGBA)等视觉参数; Alternate项渲染为 Alt 键变体菜单项;Dropdown=false时隐藏菜单项;- 含 ANSI 转义序列的文本通过
go-ansi-parser清洗后放入 Tooltip。
刷新路径的完整调用链是:插件周期性运行 →parseOutput产出Items→MenuParser.ParseItems产出*menu.Menu→app.runtime.Menu.SetTrayMenu更新托盘菜单(见 app/app.go 的onRefresh)。值得注意的细节:菜单打开期间不更新(menuIsOpen标志),以避免在菜单交互时重建菜单导致崩溃(app/app.go)。
5.6 The Plugins service
PluginsService(app/plugins_service.go)是前端访问远程插件信息的桥梁,它向https://xbarapp.com/docs/plugins/(在 app/app.go 中注入)发起 HTTP 请求,接口包括:
GetPlugins(categoryPath):按分类拉取plugins.json;GetPlugin(pluginPath):拉取单个插件元数据.json;GetFeaturedPlugins():精选插件;GetInstalledPlugins()/GetInstalledPluginMetadata():已安装插件;InstallPlugin/UninstallPlugin/SetEnabled/SetRefreshInterval:安装、卸载、启停与刷新周期管理;LoadVariableValues/SaveVariableValues:插件变量(.vars.json)读写。
服务内置了osLock sync.Mutex,用于串行化所有涉及文件系统变更的操作(如重命名文件),避免并发修改造成状态错乱(app/plugins_service.go)。HTTP 客户端使用httpcache磁盘缓存(cacheDirectory),并把 API 请求超时统一设为 30 秒(app/app.go)。
插件本体的生命周期管理在 pkg/plugins/plugin.go 与 pkg/plugins/installed_plugins.go 中:plugins.Dir(pluginDirectory)扫描插件目录(app/app.go),plugin.Run(ctx)在一个可取消的 context 下运行所有插件子进程——RefreshAll在刷新前调用stopPluginsFunc()取消 context,从而杀死所有正在运行的插件子进程(app/app.go)。
5.7 Incoming URLs:xbar:// 协议
xbar 支持通过xbar://URL 协议被外部触发。解析逻辑在 app/incoming_urls.go:
func parseIncomingURL(urlStr string) (incomingURL, error) { u, err := url.Parse(urlStr) // 只接受 xbar:// 协议或 app.xbarapp.com 主机 if u.Scheme != "xbar" && u.Host != "app.xbarapp.com" { return inURL, errors.New("not an xbar:// url") } inURL.Action = strings.Trim(u.Path, "/") inURL.Params = u.Query() switch inURL.Action { case "openPlugin": case "refreshPlugin": case "refreshAllPlugins": default: return inURL, errors.Errorf("unsupported action %q", inURL.Action) } return inURL, nil }处理入口是 app/app.go 的handleIncomingURL,它通过带缓冲 channel(容量为 1,concurrentIncomingURLs)保证同一时间只解析一个 URL,然后按 action 分发:
openPlugin:显示窗口并发出xbar.incomingURL.openPlugin事件,前端据此打开指定插件;refreshPlugin:按相对路径匹配插件并触发其刷新;refreshAllPlugins:刷新全部插件。
测试用例见 app/incoming_urls_test.go,它验证了合法 URL、非法 scheme 与不支持 action 等分支。
六、xbarapp.com:自定义工具生成的静态站点
演讲大纲的最后一个主题是官方网站 xbarapp.com,其技术方案概括为三点:
- 静态站点,由自定义工具生成;
- 从 GitHub 仓库提取插件元数据;
- 提供静态 API(即站点目录下的 JSON 文件)。
生成工具位于 tools/sitegen/,其 README.md 说明了它是"用于生成 xbarapp.com 静态站点的 Go 工具"。核心源码:
- main.go:入口,驱动整个生成流程;
- repo.go:从 GitHub 插件仓库抓取目录结构(含测试 repo_test.go);
- docs.go:生成插件文档页面;
- images.go:处理插件截屏图片。
站点模板位于 xbarapp.com/templates/:index.html(首页)、category.html(分类页)、plugin.html(插件详情页)、articles-index.html(文章索引)、_layout.html(布局)等。生成出的静态 JSON(如各分类下的plugins.json)正是前面PluginsService.GetPlugins所消费的"静态 API"数据源。该站点同时承载了插件元数据(pkg/metadata/plugin_metadata.go 定义了Plugin结构,测试见 pkg/metadata/plugin_metadata_test.go)与插件变量规范(.vars.json,pkg/plugins/variables.go)。
配套的还有 tools/xbarmdcheck/——一个校验插件元数据文件合法性的命令行工具,示例输入见 tools/xbarmdcheck/testdata/sample-plugin.sh,它保证了插件元数据在发布前符合规范。
七、把各环节串起来:一次完整的插件渲染
综合全部分析,xbar 的运行时全貌可以概括为一条管线:
- 启动:main.go 启动 Wails →
app.Start回调初始化服务、注册xbar://协议处理、创建插件目录(app/app.go); - 扫描:
plugins.Dir(pluginDirectory)读取插件目录,依据文件名后缀(如.1m.sh)解析刷新周期(pkg/plugins/refresh_interval.go); - 执行:插件子进程按周期运行,标准输出进入解析器;
- 解析:pkg/plugins/parse.go 把输出文本解析为带层级、参数、变量的
Items树,---分隔线切分循环项与展开项,--前缀表达子菜单层级; - 渲染:app/menu_parser.go 的
MenuParser把Items转换为 Wails 原生菜单(含快捷键、图标、颜色、ANSI tooltip),app.runtime.Menu.SetTrayMenu更新托盘; - 交互:用户点击菜单项触发插件定义的 action(shell 命令、URL 跳转、变量编辑等);
- 外部触发:
xbar://URL 通过handleIncomingURL打开插件、刷新单个或全部插件; - 前端管理:插件浏览器(Svelte 界面)通过 Wails RPC(app/frontend/src/backend/index.js)调用四个 Go 服务完成浏览、安装、卸载、变量配置与缓存清理;
- 更新:后台每 12 小时检查 GitHub 最新 release,支持自动更新与一键安装(app/app.go)。
八、结语:一份可复用的桌面应用架构蓝本
从 talk-overview.md 这份技术演讲大纲出发,我们看到了 xbar 的全部技术栈:Go + Wails v2构建桌面应用外壳,Svelte + Tailwind CSS实现快速编译的前端,TDD 驱动的插件输出解析器,函数抽象的缓存清理,基于事件与 RPC 的前后端协作,以及用自定义 Go 工具生成静态站点的官网方案。这套架构的价值不仅在于"把脚本输出放进菜单栏"这一产品形态,更在于它为"Go 后端 + WebView 前端 + 外部脚本生态"的组合提供了一个完整、清晰、可对照源码学习的工程范本。若你想深入了解某个环节,建议从 pkg/plugins/parse.go、app/menu_parser.go 与 app/app.go 三份核心文件开始阅读。
【免费下载链接】xbarPut the output from any script or program into your macOS Menu Bar (the BitBar reboot)项目地址: https://gitcode.com/gh_mirrors/xb/xbar
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考