起因

这个功能是在大佬 Innei 的博客上看到的,效果就是在网站logo处实时的显示博主在PC上使用的应用程序信息,虽然是个不太显眼的功能,但是效果还是挺好的,让我感觉整个网站因为这个变的不再死板,不断变动的程序图标让这个网站像博主的一个身外化身。

真的很想要呀!

遂翻阅了一下大佬的博客源码和一些相关的配套文件,自己大概有了一些思路,后来也将这些想法整理了一下后请教了 一个球 大佬,大佬说这个思路是没什么问题。

所以,喜欢就干!

遂根据自己的思路搭配Kimi和ChatGPT终于在昨天将这个效果实现了一个大概,今天大概修复了一下BUG,撰文总结一下这次折腾和分享实现过程。

根据PC激活的窗口动态切换

结构思路

对于这个功能的整个流程总结如下:

  1. 电脑上有一个监控程序,定时或在切换程序时向服务器发送当前激活的程序信息
  2. 服务器对发送来的信息进行鉴权、验证、过滤等处理后转发给所有客户端(既打开了我博客网站的浏览器)
  3. 网页接收到信息后根据需求进行展示。

根据这个结构我们需要一个可以上报数据的监控程序和一个架设API的VPS或可以架设API功能的云函数等,技术架构如下。

  1. VPS起个Web服务和WebSocket服务
  2. PC端定时向服务器的Web服务发起一次POST请求
  3. web服务收到后将数据通过WebSocket转发给前端。
  4. 前端根据需求展示数据

电脑监控程序。

能监控电脑应用并自动上报的程序我第一时间想到的是Tai,这个程序我用了好几年了,还写过一篇推荐文 在 Windows 上像智能手机那样统计软件使用情况 ,它可以记录电脑上每个应用程序的使用时间,肯定是可以做这张方面监控的,所以我还跑去作者的项目提了个 issue 咨询,作者 noberumotto 大佬说应该可以,并且给我说了要改那部分代码,同时给我推荐了他们另外一个开源项目 sentry

但是我研究了一下,发现如果要Debug这个程序需要安装几十个G的Visual Studio,遂暂时放弃了这个想法。

后来发现 Innei 大佬那个项目  Shiro 其实原本提供了一个Mac版的监控程序,并且有爱好者开发了支持Windows的 GoPythonC# 版本。

我最后选用了 C# 版本做测试,这个版本不是定时按频率发送,而是在切换窗口时发送,能减少无用发送,不过这个版本的媒体信息发送还不完善(所以当前在听什么歌的功能我还没实现)。

服务端的实现

我看了一下这个程序不一定非要搭配  Shiro ,这个程序只是向一个api发送了当前程序的数据,只要接受这个数据并有相关的处理流程就好了。

遂找 Kimi 要了一段启动API服务和WebSocket服务的demo代码,自己在本地测试一次性启动成功:

const express = require('express');
const { WebSocketServer,WebSocket } = require('ws');
const app = express();

// 允许解析JSON格式的请求体
app.use(express.json());

// 存放所有WebSocket客户端的数组
let clients = [];

// 启动WebSocket服务器
const wss = new WebSocketServer({ port: 8081 });
wss.on('connection', function connection(ws) {
  // 将新客户端添加到数组中
  clients.push(ws);

  // 当有消息到达时,将其广播给所有客户端
  ws.on('message', function incoming(message) {
    console.log('received: %s', message);
  });

  // 当客户端断开连接时,从数组中移除
  ws.on('close', (e) => {
    clients = clients.filter(client => client !== ws);
    console.log(e+'断开连接了,从列表中移除')
  });
});

// 定义一个POST路由,它将接收数据并打印到控制台,然后通过WebSocket广播
app.post('/update', (req, res) => {
  console.log('Received Data:', req.body);

  // 将数据作为消息发送给所有WebSocket客户端
  clients.forEach(client => {
    if (client.readyState === WebSocket.OPEN) {
      client.send(JSON.stringify(req.body));
    }
  });

  res.send('Data received and broadcasted via WebSocket');
});

// 启动HTTP服务器,监听3000端口
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
  console.log(`HTTP Server is running on port ${PORT}`);
});

// 同时启动WebSocket服务器,监听8080端口
console.log(`WebSocket Server is running on port 8081`);

打开安装好的 Shiro C# 版本程序,并设置好API接口,发现果然程序开始正常运作,而且在运行日志里能看到程序向搭建好的本地api发了一个结构如下的json数据:

{
  "timestamp": 1715344006,
  "process": "Chrome",
  "key": "apikey"
}

网页端实现

服务端能接受到数据就基本上没什么问题了,接下来处理网页端的显示。

网页端同步显示无非两种办法:轮询和WebSocket,前者设置定时器定时去请求服务就好了,后者则可以主动接收服务下发的信息,之前在咨询 一个球 大佬的时候证实了后者是可行的,所以肯定选WebSocket的方案。

我以前也从来没用过WebSocket,大概查询了一下概念:

WebSocket 是一种在单个TCP连接上进行全双工通信的协议,它允许客户端和服务器之间进行实时、连续的数据交换。以下是其主要特点的详细解释:

1. 全双工通信:与传统的HTTP请求/响应模式不同,WebSocket 允许服务器主动向客户端发送消息,而不需要客户端的请求。这意味着数据可以在任何一方发起,实现真正的双向通信。

2. 持久连接:一旦WebSocket连接建立,它将保持开放状态,直到客户端或服务器决定关闭它。这减少了因频繁建立和关闭连接而产生的开销。

3. 低延迟:由于不需要为每个数据传输重新建立连接,WebSocket 可以减少通信的延迟,这对于需要快速响应的应用(如在线游戏或实时数据更新)非常重要。

4. 较少的控制开销:与HTTP相比,WebSocket 在数据传输时的控制信息更少,这使得它在传输大量数据时更加高效。

5. 应用层协议:WebSocket 是一个独立的、基于TCP的应用层协议,它通过一个HTTP请求升级到WebSocket连接。一旦升级完成,HTTP协议就不再参与通信过程。

6. 端口使用:WebSocket 默认使用端口80和443,与HTTP和HTTPS相同,这使得它能够通过大多数防火墙和代理服务器

为了减少学习成本,我直接向Kimi要了一段如下要求的代码:

使用javascript创建websocket,如果连接失败则继续尝试。
// 定义WebSocket的URL,设置成前面搭建的本地websocket地址
const wsUrl = 'ws://localhost:8080';

// 保存WebSocket实例的变量
let ws;

// 定义重连的函数
function reconnect() {
    // 尝试重新连接
    ws = new WebSocket(wsUrl);
    // 绑定事件处理函数
    ws.onopen = onOpen;
    ws.onmessage = onMessage;
    ws.onclose = onClose;
    ws.onerror = onError;
}

// 初始化WebSocket连接
function initWebSocket() {
    ws = new WebSocket(wsUrl);
    // 绑定事件处理函数
    ws.onopen = onOpen;
    ws.onmessage = onMessage;
    ws.onclose = onClose;
    ws.onerror = onError;
}

// 连接成功的处理函数
function onOpen(event) {
    console.log('WebSocket connection opened:', event);
    // 可以在这里发送消息等操作
}

// 接收到消息的处理函数
function onMessage(event) {
    console.log('WebSocket message received:', event.data);
    // 处理接收到的消息
}

// 连接关闭的处理函数
function onClose(event) {
    console.log('WebSocket connection closed:', event);
}

// 连接错误的处理函数
function onError(event) {
    console.error('WebSocket error occurred:', event);
}

// 初始化WebSocket连接
initWebSocket();

测试WebSocket的代码

直接在控制台执行,一次成功。

动画效果

网页端能接受到数据后基本上整个数据的流转已经完全没有问题了,现在要做的就是以何种方式对数据进行渲染、展示,我目前实现了近似 Innei 大佬博客的淡出淡入效果,这里大概说一下我对动画效果的处理。

<div class="actives">
  <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" data-tippy-content="" data-tippy-placement="bottom-start" />
</div>

页面上的actives元素

/* 定义出场动画,元素向x轴方向负距离移动,并提高透明度
   实现一个往左渐变消失的动画
 */
@keyframes activesOutToLeft {
    from {
        transform: translateX(0);
        opacity: 1;
      }
      to {
        transform: translateX(-100%);
        opacity: 0;
      }
}
/* 定义进场动画,元素向x轴方向正距离移动,并降低透明度
   实现一个往左渐显的入场动画
 */
@keyframes activesInFromRight {
    from {
        transform: translateX(100%);
        opacity: 0;
      }
      to {
        transform: translateX(0);
        opacity: 1;
      }
}

.book-page{  
  .actives {
        position: absolute;
        right: 3px;
        bottom: -5px;
        width: 20px;
        height: 20px;
        display: none;
        /* 使用定义的动画,执行时间0.5s */
        animation: activesInFromRight 0.5s ease-in-out forwards;

        img {
            width: 20px !important;
            height: 20px !important;
            margin-inline-end: 0px;
        }
        /* 单独增加一个exit类,设置后元素执行出场动画
           在js可以通过添加和删除这个类达到一个循环动画的效果
        */
        &.exit{
            animation: activesOutToLeft 0.5s ease-in-out forwards;
        }
    }

}

css动画部分处理

// ...其他代码

// 处理接收到的消息,如果是新程序,并且在名单内的时候执行
if (activs.dataset.app != data.process && processName in app) {
    fetch(cdn + app[processName].url + "!20w").then(function () {
        activs.style.display = "block";
        // 先给原有元素添加出场动画
        activs.classList.add("exit");
        // 等待0.5s后
        setTimeout(function() {
            document.querySelector(".actives img").src =
                cdn + app[processName].url + "!20w";
            // 删除exit类,删除后会自动执行进场动画
            activs.classList.remove("exit");
            activs.dataset.app = processName;
            // 同步鼠标悬浮的提示内容
            activeTippy.forEach(function (e) {
                e.setContent(
                    "@1900 在使用 " +
                        app[processName].title +
                        " " +
                        app[processName].action
                );
            });
            counter = 0;
        }, 500);
    });
} else if (!(processName in app)) {
    activs.classList.add("exit");
}

// ...其他代码

javascript中对元素动画的处理

其他处理

一、 APP白名单

我这里用了一个独立的 app.json 文件维护一个白名单,方便之后在不更新代码的情况下更新名单数据。

{
    "wechat": {
        "title": "微信",
        "url": "wechat.png",
        "action": "摸鱼"
    },
    "chrome": {
        "title": "Chrome",
        "url": "chrome.png",
        "action": "冲浪"
    },
}

二、 VPS的服务搭建

我这里用的是express的docker镜像,将程序代码上传到绑定的目录中即可。

另外直接启动项目可能会提示npm包没安装,所以项目的 package.json 需要加上 "start": "npm install && node index.js"

# Copyright VMware, Inc.
# SPDX-License-Identifier: APACHE-2.0

services:
  express:
    container_name: express-api
    image: docker.io/bitnami/express:4
    ports:
      - '3000:3000'
      - '8080:8080'
    volumes:
      - '/home/rebron1900/data/express-data:/app'
    
    logging:
      driver: "json-file"
      options:
       max-file: "5"
       max-size: "10m"

express的docker-compose配置

然后使用nginx进行了反代。

server {
    listen 443;
    server_name api.yourdomain.com; # 换成你绑定的域名
    
    include snippets/yourssl.conf; # 换成你的ssl配置
    
    location /actives {
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:3000/update; # docker上websocket公开的 3000 端口
    }

    location /actives_ws {
        proxy_pass http://127.0.0.1:8080; # docker上websocket公开的 8080 端口
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;

    	proxy_buffering off;
    }

    location /update {
    	proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:3000/listUpdate;
    }


    location ~ /.well-known {
        allow all;
    }

    client_max_body_size 50m;
}


反代配置

完整代码

服务端

GitHub - rebron1900/ProcessReporterServer
Contribute to rebron1900/ProcessReporterServer development by creating an account on GitHub.

前端js

修改 wsUrlappListUrlcdn 为你自己的服务地址。

// 定义测试用的URL
const wsUrl = "ws://localhost:8081/update";
const appListUrl = "http://localhost:8080/assets/app.json";

// 定义正式环境的url
const cdn = "https://cdn.1900.live/apps/";


// app白名单
let appList = {};
// 保存WebSocket实例的变量
let ws2;

// 初始化WebSocket连接
export default function initWebSocket() {
    // 获取远端的app清单
    fetch(appListUrl).then((rep) => {
        rep.json().then((data) => {
            ws2 = new WebSocket(wsUrl);
            // 初始化app列表
            appList = data;
            // 绑定事件处理函数
            ws2.onopen = onOpen;
            ws2.onmessage = onMessage;
            ws2.onclose = onClose;
            ws2.onerror = onError;
        });
    });
}

// 连接成功的处理函数
function onOpen(event) {
    // console.log("WebSocket connection opened:", event);
    // 可以在这里发送消息等操作
}

// 接收到消息的处理函数
function onMessage(event) {
    // 接受服务端下发的程序数据
    var data = JSON.parse(event.data);
    // 获取页面上actives dom元素
    var activs = document.querySelector(".actives");
    // 之后用来判断的进程名称统一小写,方便比对
    const processName = data.process.toLowerCase();

    // 处理接收到的消息
    // 条件为:当前页面显示的app和服务器下发的app要不一样(说明是新程序),并且程序在app清单中。
    if (activs.dataset.app != data.process && processName in appList) {
        // 提前缓存图片(我发现大佬博客图片加载有颜值,不过不知道这个有用没有)
        fetch(cdn + appList[processName].url + "!20w").then(function () {
            // 先将旧的actives执行退场动画
            activs.style.display = "block";
            activs.classList.add("exit");
            // 0.5s后执行更新操作
            setTimeout(function() {
                // 重新设置icon
                document.querySelector(".actives img").src =
                    cdn + appList[processName].url + "!20w";
                // 执行进场动画
                activs.classList.remove("exit");
                // 更新dom上app的信息
                activs.dataset.app = processName;
                // 这里我用Tippy.js做鼠标悬浮提示,更新悬浮提示内容
                activeTippy.forEach(function (e) {
                    e.setContent(
                        "@1900 在使用 " +
                            appList[processName].title +
                            " " +
                            appList[processName].action
                    );
                });
            }, 500);
        });
    // 如果是不在白名单里的应用就不显示actives元素了
    } else if (!(processName in appList)) {
        activs.classList.add("exit");
    }
}

// 连接关闭的处理函数
function onClose(event) {
    // 尝试重新连接
    document.querySelector(".actives").classList.add("exit");
    setTimeout(initWebSocket, 5000); // 5秒后尝试重新连接
}

// 连接错误的处理函数
function onError(event) {
    // 尝试重新连接
    document.querySelector(".actives").classList.add("exit");
}

HTML和CSS部分

<div class="actives">
  <img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=" data-tippy-content="" data-tippy-placement="bottom-start" />
</div>

页面结构

/* 定义出场动画,元素向x轴方向负距离移动,并提高透明度
   实现一个往左渐变消失的动画
 */
@keyframes activesOutToLeft {
    from {
        transform: translateX(0);
        opacity: 1;
      }
      to {
        transform: translateX(-100%);
        opacity: 0;
      }
}
/* 定义进场动画,元素向x轴方向正距离移动,并降低透明度
   实现一个往左渐显的入场动画
 */
@keyframes activesInFromRight {
    from {
        transform: translateX(100%);
        opacity: 0;
      }
      to {
        transform: translateX(0);
        opacity: 1;
      }
}

.actives {
    position: absolute;
    right: 3px;
    bottom: -5px;
    width: 20px;
    height: 20px;
    display: none;
    /* 使用定义的动画,执行时间0.5s */
    animation: activesInFromRight 0.5s ease-in-out forwards;
}
.actives img {
    width: 20px !important;
    height: 20px !important;
    margin-inline-end: 0px;
}
/* 单独增加一个exit类,设置后元素执行出场动画
   在js可以通过添加和删除这个类达到一个循环动画的效果
*/
.actives.exit{
    animation: activesOutToLeft 0.5s ease-in-out forwards;
}

css部分

Enjoy~