- Published on
让我看看你在干嘛——利用 SSE 实现博客展示个人动态
- Authors
- Name
- Roitium
缘由
之前在了解博客系统时,就被 的 实时活动状态显示
功能吸引到了,这简直太酷炫了好嘛!虽然没什么人看,但还是决定给自己的博客加一个。
但问题就出现了,我的博客是在 Vercel 上部署的,Serverless Function 有最大运行时间限制,不支持 Websocket。于是想到了轮询:但 Vercel 的 Serverless Function 同样也有最大执行次数限制,用来干这个多少是有点浪费了,而且我的状态数据也没地方存,总不能开一个数据库就为了放一个状态数据吧?
再三思索,决定单独开一个项目,服务端就跑在我 vps 上,给客户端(浏览器)推送实时状态信息。这是一个很典型的单工通信,于是立刻就想到了 Server Send Event。
NOTE
什么是 Server Send Event? 通常来说,一个网页获取新的数据通常需要发送一个请求到服务器,也就是向服务器请求的页面。使用服务器发送事件,服务器可以随时向我们的 Web 页面推送数据和信息。这些被推送进来的信息可以在这个页面上被处理 —— MDN Docs
很多 ai 工具(如 chatgpt、claude)实现流式传输就是利用了 SSE
MyStatus,启动!
Golang 简直就是为了写这种小脚本而生的,写完直接编译为单文件,丢上就能跑,太爽!
客户端(For Gnome)
代码在 ,虽然写的不怎么样就是了。
工作流程
来画个图整体看看它是怎么工作的:
获取前台应用
在 Linux 下,要知道当前正在使用的软件其实很简单。我用了两个命令行工具:
xdotool
:用来获取当前激活窗口的 IDxprop
:根据窗口 ID 获取窗口的详细信息
比如当我打开 VS Code 的时候,系统是这样工作的:
- 首先用
xdotool getactivewindow
获取窗口 ID - 然后用
xprop -id {窗口ID}
获取窗口信息,会得到一大串形如NAME = VALUE
的结果,但咱们只需要关注这两个即可:
WM_NAME(UTF8_STRING) = "让我看看你在干嘛——利用 SSE 实现博客展示个人动态 - roitium-personal-vault - Obsidian v1.8.4"
WM_CLASS(STRING) = "obsidian", "obsidian"
其中,WM_CLASS
由两个字符串组成,前者是 Instance
,后者是 Class
,说实话有点抽象,搜了一下也没看明白具体有什么作用。有一种说法是,Instance
用于指定同一个应用程序的不同窗口,而 Class
用于明确不同的应用程序。我在下面匹配规则的函数中就直接使用了 Class
参数。
而 WM_NAME
就是当前窗口的名称,由于较为详细,隐私暴露太多,所以我其实也没用。
随后我写了个简单的匹配规则,根据这些信息判断正在使用的软件,并可以灵活替换一些消息文字:
func matchApplicationPatterns(wmName, wmClass string) (newAppName string, message string) {
switch wmClass {
case "Code":
return "VSCode", "正在使用 VSCode 写代码👨💻"
case "CherryStudio":
return "CherryStudio", "正在与 AI 激情对线🤖"
}
return wmClass, ""
}
处理状态事件(关机/休眠)
我采用了一个最简单的方式——监听 DBus 消息。通过监听 org.freedesktop.login1.Manager
的信号,我们可以知道系统什么时候要休眠或关机:
func listenSystemEvents(ctx context.Context, notifyChan chan<- SystemEvent) {
conn, err := dbus.SystemBus()
if err != nil {
log.Printf("无法连接系统总线: %v\n", err)
return
}
defer conn.Close()
// 匹配系统事件信号
matchRules := []dbus.MatchOption{
dbus.WithMatchInterface("org.freedesktop.login1.Manager"),
}
if err := conn.AddMatchSignal(matchRules...); err != nil {
log.Printf("添加信号匹配失败: %v\n", err)
return
}
sigChan := make(chan *dbus.Signal, 10)
conn.Signal(sigChan)
for {
select {
case sig := <-sigChan:
log.Printf("捕获到信号: %v\n", sig)
switch sig.Name {
case "org.freedesktop.login1.Manager.PrepareForSleep":
if entering, ok := sig.Body[0].(bool); ok {
if entering {
log.Println("捕获到休眠开始信号")
notifyChan <- EventSuspend
} else {
log.Println("捕获到休眠恢复信号")
notifyChan <- EventResume
}
}
case "org.freedesktop.login1.Manager.PrepareForShutdown":
if entering, ok := sig.Body[0].(bool); ok && entering {
log.Println("捕获到关机信号")
notifyChan <- EventShutdown
}
}
case <-ctx.Done():
log.Println("停止系统事件监听...")
return
}
}
}
实现确实很简单,这就导致了一个问题:在休眠/关机时可能来不及发送请求网络服务就已经停止了,或许可以给 service 文件里加一个:
[Unit]
+ After=network-online.target
+ Wants=network-online.target
因为我的理解是关机时的顺序与开机时是相反的,这样也最符合逻辑。
一个小坑
如果你通过 systemd 来运行它的话,xdotool 就会以非零状态码退出(就是出错了),搜索了一番,找到了解决方法,需要在 service 文件里加上(其实是网上搜到了好几种方法,我懒得一个一个试了,所以都加进来,总之最后确实好了):
[Service]
+ Environment="DISPLAY=:1" // 通过 echo $DISPLAY 获取
+ User=your-user-name // 你当前用户的用户名和组(不是 root 的)
+ Group=your-user-group
+ Environment="XAUTHORITY=/home/your-user-name/.Xauthority"
服务端
服务端就很简单了,直接用 net/http
包创建一个 server 就行:
func main() {
// ...略
// 设置路由
mux := http.NewServeMux()
mux.HandleFunc("/events", sseHandler)
mux.HandleFunc("/update-status", authMiddleware(updateStatusHandler))
mux.HandleFunc("/update-software", authMiddleware(updateSoftwareHandler))
// 添加CORS中间件
handler := corsMiddleware(mux)
log.Fatal(http.ListenAndServe(":8080", handler))
}
func sseHandler(w http.ResponseWriter, r *http.Request) {
// 设置请求头,告知客户端保持连接
w.Header (). Set ("Content-Type", "text/event-stream")
w.Header (). Set ("Cache-Control", "no-cache")
w.Header (). Set ("Connection", "keep-alive")
Ticker := time.NewTicker (1 * time. Second)
Defer ticker.Stop ()
log.Printf ("新的 SSE 客户端连接来自: %s", r.RemoteAddr)
For {
Select {
Case <-ticker. C:
Mu.Lock ()
Data, _ := currentState.MarshalJSON ()
Mu.Unlock ()
Fmt.Fprintf (w, "data: %s\n\n", data)
w.(http. Flusher). Flush ()
case <-r.Context (). Done ():
log.Printf ("SSE 客户端断开连接来自: %s", r.RemoteAddr)
Return
}
}
}
手机端?
原本是想写的,但想了想没啥必要。
我的思路是写一个 Magisk 模块用来跑个循环脚本获取前台应用并发送请求,具体的命令就是(需要 root):
Dumpsys activity activities | grep -E 'mCurrentFocus|mFocusedApp'
返回的内容大概长这个样子:
MCurrentFocus=Window{a 2 e 2 c 21 u 0 tw. Nekomimi. Nekogram/org. Telegram. Messenger. DefaultIcon} mFocusedApp=ActivityRecord{ee 3 aa 1 f u 0 tw. Nekomimi. Nekogram/org. Telegram. Messenger. DefaultIcon t 9712}
用正则再处理一下,只要包名就行。
博客接入
主要就是加了个退避重试:
Let reconnectFrequencySeconds = 1
Let evtSource: EventSource
Function reconnectFunc (url: string, setStatus: SetStatus) {
SetTimeout (() => {
SetupEventSource (url, setStatus)
}, reconnectFrequencySeconds * 1000)
ReconnectFrequencySeconds *= 2
If (reconnectFrequencySeconds >= 64) {
ReconnectFrequencySeconds = 64
}
}
Function setupEventSource (url: string, setStatus: SetStatus) {
EvtSource = new EventSource (url)
If (evtSource. ReadyState === evtSource. CLOSED) return
EvtSource. Onmessage = (e: MessageEvent) => {
setStatus (JSON.Parse (e.data))
}
EvtSource. Onopen = () => {
ReconnectFrequencySeconds = 1
}
EvtSource. Onerror = () => {
EvtSource.Close ()
ReconnectFunc (url, setStatus)
}
}
然后具体组件里直接用 useEffect
包装一下就可以了。
结语
一个简陋的个人动态展示功能就写好了!能跑起来就是大成功!就先说这么多,我要去玩游戏了,886!