Published on

让我看看你在干嘛——利用 SSE 实现博客展示个人动态

预计阅读时长:9分钟
Authors
  • avatar
    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:用来获取当前激活窗口的 ID
  • xprop:根据窗口 ID 获取窗口的详细信息

比如当我打开 VS Code 的时候,系统是这样工作的:

  1. 首先用 xdotool getactivewindow 获取窗口 ID
  2. 然后用 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!