mirror of
https://github.com/hicccc77/WeFlow.git
synced 2026-04-06 23:15:58 +00:00
345 lines
10 KiB
TypeScript
345 lines
10 KiB
TypeScript
import dbus from "dbus-native";
|
||
import https from "https";
|
||
import http, { IncomingMessage } from "http";
|
||
import { promises as fs } from "fs";
|
||
import { join } from "path";
|
||
import { app } from "electron";
|
||
|
||
const BUS_NAME = "org.freedesktop.Notifications";
|
||
const OBJECT_PATH = "/org/freedesktop/Notifications";
|
||
|
||
export interface LinuxNotificationData {
|
||
sessionId?: string;
|
||
title: string;
|
||
content: string;
|
||
avatarUrl?: string;
|
||
expireTimeout?: number;
|
||
}
|
||
|
||
type NotificationCallback = (sessionId: string) => void;
|
||
|
||
let sessionBus: dbus.DBusConnection | null = null;
|
||
let notificationCallbacks: NotificationCallback[] = [];
|
||
let pendingNotifications: Map<number, LinuxNotificationData> = new Map();
|
||
|
||
// 头像缓存:url->localFilePath
|
||
const avatarCache: Map<string, string> = new Map();
|
||
// 缓存目录
|
||
let avatarCacheDir: string | null = null;
|
||
|
||
async function getSessionBus(): Promise<dbus.DBusConnection> {
|
||
if (!sessionBus) {
|
||
sessionBus = dbus.sessionBus();
|
||
|
||
// 挂载底层socket的error事件,防止掉线即可
|
||
sessionBus.connection.on("error", (err: Error) => {
|
||
console.error("[LinuxNotification] D-Bus connection error:", err);
|
||
sessionBus = null; // 报错清理死对象
|
||
});
|
||
}
|
||
return sessionBus;
|
||
}
|
||
|
||
// 确保缓存目录存在
|
||
async function ensureCacheDir(): Promise<string> {
|
||
if (!avatarCacheDir) {
|
||
avatarCacheDir = join(app.getPath("temp"), "weflow-avatars");
|
||
try {
|
||
await fs.mkdir(avatarCacheDir, { recursive: true });
|
||
} catch (error) {
|
||
console.error(
|
||
"[LinuxNotification] Failed to create avatar cache dir:",
|
||
error,
|
||
);
|
||
}
|
||
}
|
||
return avatarCacheDir;
|
||
}
|
||
|
||
// 下载头像到本地临时文件
|
||
async function downloadAvatarToLocal(url: string): Promise<string | null> {
|
||
// 检查缓存
|
||
if (avatarCache.has(url)) {
|
||
return avatarCache.get(url) || null;
|
||
}
|
||
|
||
try {
|
||
const cacheDir = await ensureCacheDir();
|
||
// 生成唯一文件名
|
||
const fileName = `avatar_${Date.now()}_${Math.random().toString(36).substring(2, 8)}.png`;
|
||
const localPath = join(cacheDir, fileName);
|
||
|
||
await new Promise<void>((resolve, reject) => {
|
||
// 微信 CDN 需要特殊的请求头才能下载图片
|
||
const options = {
|
||
headers: {
|
||
"User-Agent":
|
||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 MicroMessenger/7.0.20.1781(0x6700143B) WindowsWechat(0x63090719) XWEB/8351",
|
||
Referer: "https://servicewechat.com/",
|
||
Accept:
|
||
"image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8",
|
||
"Accept-Encoding": "gzip, deflate, br",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
Connection: "keep-alive",
|
||
},
|
||
};
|
||
|
||
const callback = (res: IncomingMessage) => {
|
||
if (res.statusCode !== 200) {
|
||
reject(new Error(`HTTP ${res.statusCode}`));
|
||
return;
|
||
}
|
||
const chunks: Buffer[] = [];
|
||
res.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||
res.on("end", async () => {
|
||
try {
|
||
const buffer = Buffer.concat(chunks);
|
||
await fs.writeFile(localPath, buffer);
|
||
avatarCache.set(url, localPath);
|
||
resolve();
|
||
} catch (err) {
|
||
reject(err);
|
||
}
|
||
});
|
||
res.on("error", reject);
|
||
};
|
||
|
||
const req = url.startsWith("https")
|
||
? https.get(url, options, callback)
|
||
: http.get(url, options, callback);
|
||
|
||
req.on("error", reject);
|
||
req.setTimeout(10000, () => {
|
||
req.destroy();
|
||
reject(new Error("Download timeout"));
|
||
});
|
||
});
|
||
|
||
console.log(
|
||
`[LinuxNotification] Avatar downloaded: ${url} -> ${localPath}`,
|
||
);
|
||
return localPath;
|
||
} catch (error) {
|
||
console.error("[LinuxNotification] Failed to download avatar:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function showLinuxNotification(
|
||
data: LinuxNotificationData,
|
||
): Promise<number | null> {
|
||
try {
|
||
const bus = await getSessionBus();
|
||
|
||
const appName = "WeFlow";
|
||
const replaceId = 0;
|
||
const expireTimeout = data.expireTimeout ?? 5000;
|
||
|
||
// 处理头像:下载到本地或使用URL
|
||
let appIcon = "";
|
||
let hints: any[] = [];
|
||
if (data.avatarUrl) {
|
||
// 优先尝试下载到本地
|
||
const localPath = await downloadAvatarToLocal(data.avatarUrl);
|
||
if (localPath) {
|
||
hints = [["image-path", ["s", localPath]]];
|
||
}
|
||
}
|
||
|
||
return new Promise((resolve, reject) => {
|
||
bus.invoke(
|
||
{
|
||
destination: BUS_NAME,
|
||
path: OBJECT_PATH,
|
||
interface: "org.freedesktop.Notifications",
|
||
member: "Notify",
|
||
signature: "susssasa{sv}i",
|
||
body: [
|
||
appName,
|
||
replaceId,
|
||
appIcon,
|
||
data.title,
|
||
data.content,
|
||
["default", "打开"], // 提供default action,否则系统不会抛出点击事件
|
||
hints,
|
||
// [], // 传空数组以避开a{sv}变体的序列化崩溃,有pendingNotifications映射维护保证不出错
|
||
expireTimeout,
|
||
],
|
||
},
|
||
(err: Error | null, result: any) => {
|
||
if (err) {
|
||
console.error("[LinuxNotification] Notify error:", err);
|
||
reject(err);
|
||
return;
|
||
}
|
||
const notificationId =
|
||
typeof result === "number" ? result : result[0];
|
||
if (data.sessionId) {
|
||
// 依赖Map实现点击追踪,没有使用D-Bus hints
|
||
pendingNotifications.set(notificationId, data);
|
||
}
|
||
console.log(
|
||
`[LinuxNotification] Shown notification ${notificationId}: ${data.title}, icon: ${appIcon || "none"}`,
|
||
);
|
||
resolve(notificationId);
|
||
},
|
||
);
|
||
});
|
||
} catch (error) {
|
||
console.error("[LinuxNotification] Failed to show notification:", error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export async function closeLinuxNotification(
|
||
notificationId: number,
|
||
): Promise<void> {
|
||
try {
|
||
const bus = await getSessionBus();
|
||
return new Promise((resolve, reject) => {
|
||
bus.invoke(
|
||
{
|
||
destination: BUS_NAME,
|
||
path: OBJECT_PATH,
|
||
interface: "org.freedesktop.Notifications",
|
||
member: "CloseNotification",
|
||
signature: "u",
|
||
body: [notificationId],
|
||
},
|
||
(err: Error | null) => {
|
||
if (err) {
|
||
console.error("[LinuxNotification] CloseNotification error:", err);
|
||
reject(err);
|
||
return;
|
||
}
|
||
pendingNotifications.delete(notificationId);
|
||
resolve();
|
||
},
|
||
);
|
||
});
|
||
} catch (error) {
|
||
console.error("[LinuxNotification] Failed to close notification:", error);
|
||
}
|
||
}
|
||
|
||
export async function getCapabilities(): Promise<string[]> {
|
||
try {
|
||
const bus = await getSessionBus();
|
||
return new Promise((resolve, reject) => {
|
||
bus.invoke(
|
||
{
|
||
destination: BUS_NAME,
|
||
path: OBJECT_PATH,
|
||
interface: "org.freedesktop.Notifications",
|
||
member: "GetCapabilities",
|
||
},
|
||
(err: Error | null, result: any) => {
|
||
if (err) {
|
||
console.error("[LinuxNotification] GetCapabilities error:", err);
|
||
reject(err);
|
||
return;
|
||
}
|
||
resolve(result as string[]);
|
||
},
|
||
);
|
||
});
|
||
} catch (error) {
|
||
console.error("[LinuxNotification] Failed to get capabilities:", error);
|
||
return [];
|
||
}
|
||
}
|
||
|
||
export function onNotificationAction(callback: NotificationCallback): void {
|
||
notificationCallbacks.push(callback);
|
||
}
|
||
|
||
export function removeNotificationCallback(
|
||
callback: NotificationCallback,
|
||
): void {
|
||
const index = notificationCallbacks.indexOf(callback);
|
||
if (index > -1) {
|
||
notificationCallbacks.splice(index, 1);
|
||
}
|
||
}
|
||
|
||
function triggerNotificationCallback(sessionId: string): void {
|
||
for (const callback of notificationCallbacks) {
|
||
try {
|
||
callback(sessionId);
|
||
} catch (error) {
|
||
console.error("[LinuxNotification] Callback error:", error);
|
||
}
|
||
}
|
||
}
|
||
|
||
export async function initLinuxNotificationService(): Promise<void> {
|
||
if (process.platform !== "linux") {
|
||
console.log("[LinuxNotification] Not on Linux, skipping init");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const bus = await getSessionBus();
|
||
|
||
// 监听底层connection的message事件
|
||
bus.connection.on("message", (msg: any) => {
|
||
// type 4表示SIGNAL
|
||
if (
|
||
msg.type === 4 &&
|
||
msg.path === OBJECT_PATH &&
|
||
msg.interface === "org.freedesktop.Notifications"
|
||
) {
|
||
if (msg.member === "ActionInvoked") {
|
||
const [notificationId, actionId] = msg.body;
|
||
console.log(
|
||
`[LinuxNotification] Action invoked: ${notificationId}, ${actionId}`,
|
||
);
|
||
|
||
// 如果用户点击了通知本体,actionId会是'default'
|
||
if (actionId === "default") {
|
||
const data = pendingNotifications.get(notificationId);
|
||
if (data?.sessionId) {
|
||
triggerNotificationCallback(data.sessionId);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (msg.member === "NotificationClosed") {
|
||
const [notificationId] = msg.body;
|
||
pendingNotifications.delete(notificationId);
|
||
}
|
||
}
|
||
});
|
||
|
||
// AddMatch用来接收信号
|
||
await new Promise<void>((resolve, reject) => {
|
||
bus.invoke(
|
||
{
|
||
destination: "org.freedesktop.DBus",
|
||
path: "/org/freedesktop/DBus",
|
||
interface: "org.freedesktop.DBus",
|
||
member: "AddMatch",
|
||
signature: "s",
|
||
body: ["type='signal',interface='org.freedesktop.Notifications'"],
|
||
},
|
||
(err: Error | null) => {
|
||
if (err) {
|
||
console.error("[LinuxNotification] AddMatch error:", err);
|
||
reject(err);
|
||
return;
|
||
}
|
||
resolve();
|
||
},
|
||
);
|
||
});
|
||
|
||
console.log("[LinuxNotification] Service initialized");
|
||
|
||
// 打印相关日志
|
||
const caps = await getCapabilities();
|
||
console.log("[LinuxNotification] Server capabilities:", caps);
|
||
} catch (error) {
|
||
console.error("[LinuxNotification] Failed to initialize:", error);
|
||
}
|
||
}
|