Compare commits

..
2 Commits
Author SHA1 Message Date
DHEnry 7a36930411 docs: describe rich notification layout 2026-10-25 16:10:18 +08:00
DHEnry b124e57cdb feat: rich message notifications and conversation jump 2026-10-25 16:09:40 +08:00
10 changed files with 256 additions and 35 deletions

No files matched your search

+17 -5
View File
@@ -1,12 +1,17 @@
# FMCRAFT Chat 桌面版
# FMCRAFT Chat Desktop
Windows 7 及以上可用的 FMCRAFT Chat 桌面客户端(当前提供 x64 便携版)。使用 Electron 22(最后一个支持 Windows 7/8/8.1 的 Electron 版本)直接嵌入网页。
Windows 7 及以上可用的 FMCRAFT Chat Desktop 桌面客户端,提供 x64 / ia32 便携版。使用 Electron 22(最后一个支持 Windows 7/8/8.1 的 Electron 版本)直接嵌入网页。
## 功能
- 直接加载 `config.ini` 中配置的网页地址
- Windows 消息通知
- 通过 Windows 托盘气泡发送,兼容 Windows 7/8/8.1/10/11
- Windows 10/11 使用系统 Toast,Windows 7/8/8.1 使用托盘气泡
- 通知顶部应用名显示为 FMCRAFT Chat Desktop
- 通知左侧显示发送人头像
- 通知标题显示会话名称,正文显示消息内容
- 群聊消息正文显示发送人,例如 `DHEnry:测试`
- 点击通知会打开窗口并跳转到对应会话
- 任务栏闪烁
- 本地音效播放(不依赖网页音频权限)
- 三项通知开关均可在设置中关闭,默认全部开启
@@ -26,7 +31,7 @@ flash=true
sound=true
```
程序会优先读写可执行文件同目录下的 `config.ini`。如果该目录不可写(例如安装在 `Program Files`),会自动回退到 `%APPDATA%\FMCRAFT Chat\config.ini`,设置页会显示实际使用的路径。
程序会优先读写可执行文件同目录下的 `config.ini`。如果该目录不可写(例如安装在 `Program Files`),会自动回退到 `%APPDATA%\FMCRAFT Chat Desktop\config.ini`,设置页会显示实际使用的路径。
## 开发运行
@@ -38,10 +43,17 @@ npm start
## 打包
```bash
# 64 位
npm run dist:x64
# 32 位(会自动下载并准备 ia32 Electron)
npm run dist:ia32
```
生成文件在 `release/` 目录,可直接分发 `FMCRAFT-Chat-1.0.0-x64.exe` 便携版。
生成文件在 `release/` 目录:
- `FMCRAFT-Chat-1.0.0-x64.exe`
- `FMCRAFT-Chat-1.0.0-ia32.exe`
## 通知开关
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

+75 -23
View File
@@ -10,9 +10,12 @@ const {
nativeImage,
} = require('electron')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const APP_ID = 'top.fmcraft.chat.desktop'
// 没有开始菜单快捷方式时,Windows Toast 会直接显示 AUMID。
// 使用可读名称作为 AUMID,确保通知顶部显示 FMCRAFT Chat Desktop。
const APP_ID = 'FMCRAFT Chat Desktop'
const DEFAULT_URL = 'https://chat.fmcraft.top'
const DEFAULT_CONFIG = {
url: DEFAULT_URL,
@@ -35,7 +38,7 @@ let pendingSound = false
let appIcon = null
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
app.setName('FMCRAFT Chat')
app.setName('FMCRAFT Chat Desktop')
function defaultConfigText() {
return [
@@ -150,7 +153,7 @@ function createMainWindow() {
height: 820,
minWidth: 960,
minHeight: 600,
title: 'FMCRAFT Chat',
title: 'FMCRAFT Chat Desktop',
icon: appIcon || iconPath,
backgroundColor: '#0b0e14',
webPreferences: {
@@ -164,7 +167,7 @@ function createMainWindow() {
mainWindow.loadURL(config.url).catch((err) => {
dialog.showErrorBox(
'无法打开 FMCRAFT Chat',
'无法打开 FMCRAFT Chat Desktop',
`请检查 config.ini 中的页面地址是否可访问。\n\n${String(err && err.message ? err.message : err)}`
)
})
@@ -230,36 +233,85 @@ function playSound(enabled = config.sound) {
.catch(() => {})
}
function showWindowsNotification(title, body) {
// Windows 7/8 没有 Toast;便携版在 Windows 10/11 上也不一定有开始菜单快捷方式。
// 统一使用托盘气泡,保证 Windows 7 及以上都能收到提醒。
if (process.platform === 'win32' && tray) {
const activeNotifications = new Set()
let lastBalloonConversationId = null
function isLegacyWindows() {
return process.platform === 'win32' && /^6\./.test(os.release())
}
function useTrayBalloon() {
return process.platform === 'win32' && (process.env.FMCRAFT_FORCE_BALLOON === '1' || isLegacyWindows())
}
function openConversationFromNotification(conversationId) {
showMainWindow()
if (!conversationId || !mainWindow || mainWindow.isDestroyed()) return
const run = () => {
if (!mainWindow || mainWindow.isDestroyed()) return
const id = JSON.stringify(String(conversationId))
mainWindow.webContents
.executeJavaScript(`window.desktopOpenConversation && window.desktopOpenConversation(${id})`)
.catch(() => {})
}
if (mainWindow.webContents.isLoading()) {
mainWindow.webContents.once('did-finish-load', run)
} else {
setTimeout(run, 80)
}
}
function showWindowsNotification({ title, body, icon, conversationId }) {
const iconImage = icon || appIcon || iconPath
if (useTrayBalloon() && tray) {
lastBalloonConversationId = conversationId || null
tray.displayBalloon({
title,
content: body,
icon: appIcon || iconPath,
icon: iconImage,
noSound: true,
respectQuietTime: true,
})
return
}
if (!Notification.isSupported()) return
if (!Notification.isSupported()) {
if (tray) {
lastBalloonConversationId = conversationId || null
tray.displayBalloon({
title,
content: body,
icon: iconImage,
noSound: true,
})
}
return
}
const notification = new Notification({
title,
body,
icon: appIcon || iconPath,
icon: iconImage,
silent: true,
})
notification.on('click', showMainWindow)
activeNotifications.add(notification)
notification.on('click', () => openConversationFromNotification(conversationId))
notification.on('close', () => activeNotifications.delete(notification))
notification.show()
}
function handleDesktopNotification(payload, override) {
const options = override ? { ...config, ...override } : config
const title = String(payload && payload.title ? payload.title : 'FMCRAFT Chat').slice(0, 120)
const title = String(payload && payload.title ? payload.title : 'FMCRAFT Chat Desktop').slice(0, 120)
const body = String(payload && payload.body ? payload.body : '').slice(0, 500)
const conversationId =
payload && typeof payload.conversationId === 'string' ? payload.conversationId : null
if (options.windows) showWindowsNotification(title, body)
let icon = null
if (payload && typeof payload.avatarUrl === 'string' && payload.avatarUrl.startsWith('data:image/')) {
icon = nativeImage.createFromDataURL(payload.avatarUrl)
if (icon.isEmpty()) icon = null
}
if (options.windows) showWindowsNotification({ title, body, icon, conversationId })
if (options.flash && mainWindow && !mainWindow.isDestroyed() && !mainWindow.isFocused()) {
mainWindow.flashFrame(true)
}
@@ -270,7 +322,7 @@ function updateTrayMenu() {
if (!tray || tray.isDestroyed()) return
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示 FMCRAFT Chat', click: showMainWindow },
{ label: '显示 FMCRAFT Chat Desktop', click: showMainWindow },
{ label: '通知设置…', click: openSettingsWindow },
{ type: 'separator' },
{
@@ -310,10 +362,10 @@ function createTray() {
try {
const image = nativeImage.createFromPath(trayIconPath)
tray = new Tray(image)
tray.setToolTip('FMCRAFT Chat')
tray.setToolTip('FMCRAFT Chat Desktop')
tray.on('double-click', showMainWindow)
tray.on('click', showMainWindow)
tray.on('balloon-click', showMainWindow)
tray.on('balloon-click', () => openConversationFromNotification(lastBalloonConversationId))
updateTrayMenu()
} catch (err) {
console.warn('无法创建托盘图标:', err)
@@ -355,12 +407,12 @@ function createMenu() {
label: '帮助',
submenu: [
{
label: '关于 FMCRAFT Chat',
label: '关于 FMCRAFT Chat Desktop',
click: () => {
dialog.showMessageBox({
type: 'info',
title: '关于 FMCRAFT Chat',
message: 'FMCRAFT Chat 桌面版',
title: '关于 FMCRAFT Chat Desktop',
message: 'FMCRAFT Chat Desktop',
detail: `版本 ${app.getVersion()}\n适用于 Windows 7 及以上。\n\n配置文件:${configPath}`,
buttons: ['确定'],
})
@@ -386,7 +438,7 @@ function openSettingsWindow() {
resizable: true,
minimizable: false,
maximizable: false,
title: 'FMCRAFT Chat 通知设置',
title: 'FMCRAFT Chat Desktop 通知设置',
icon: appIcon || iconPath,
parent: mainWindow || undefined,
autoHideMenuBar: true,
@@ -427,8 +479,8 @@ function registerIpc() {
const options = values && typeof values === 'object' ? values : config
handleDesktopNotification(
{
title: 'FMCRAFT Chat 通知测试',
body: '如果你看到这条通知,说明桌面通知工作正常。',
title: 'FMCRAFT Chat Desktop',
body: '通知测试:如果你看到这条消息,说明桌面通知工作正常。',
},
options
)
+70 -1
View File
@@ -10,7 +10,8 @@
"license": "UNLICENSED",
"devDependencies": {
"electron": "22.3.27",
"electron-builder": "24.13.3"
"electron-builder": "24.13.3",
"rcedit": "^5.0.2"
}
},
"node_modules/@develar/schema-utils": {
@@ -1291,6 +1292,31 @@
"node": ">= 8"
}
},
"node_modules/cross-spawn-windows-exe": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/cross-spawn-windows-exe/-/cross-spawn-windows-exe-1.2.0.tgz",
"integrity": "sha512-mkLtJJcYbDCxEG7Js6eUnUNndWjyUZwJ3H7bErmmtOYU/Zb99DyUkpamuIZE0b3bhmJyZ7D90uS6f+CGxRRjOw==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/malept"
},
{
"type": "tidelift",
"url": "https://tidelift.com/subscription/pkg/npm-cross-spawn-windows-exe?utm_medium=referral&utm_source=npm_fund"
}
],
"license": "Apache-2.0",
"dependencies": {
"@malept/cross-spawn-promise": "^1.1.0",
"is-wsl": "^2.2.0",
"which": "^2.0.2"
},
"engines": {
"node": ">= 10"
}
},
"node_modules/debug": {
"version": "4.4.3",
"dev": true,
@@ -2300,6 +2326,22 @@
"is-ci": "bin.js"
}
},
"node_modules/is-docker": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz",
"integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==",
"dev": true,
"license": "MIT",
"bin": {
"is-docker": "cli.js"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"dev": true,
@@ -2308,6 +2350,19 @@
"node": ">=8"
}
},
"node_modules/is-wsl": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz",
"integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-docker": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"dev": true,
@@ -2820,6 +2875,20 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/rcedit": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/rcedit/-/rcedit-5.0.2.tgz",
"integrity": "sha512-dgysxaeXZ4snLpPjn8aVtHvZDCx+aRcvZbaWBgl1poU6OPustMvOkj9a9ZqASQ6i5Y5szJ13LSvglEOwrmgUxA==",
"deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.",
"dev": true,
"license": "MIT",
"dependencies": {
"cross-spawn-windows-exe": "^1.1.0"
},
"engines": {
"node": ">= 22.12.0"
}
},
"node_modules/read-config-file": {
"version": "6.3.2",
"dev": true,
+7 -4
View File
@@ -8,16 +8,19 @@
"private": true,
"scripts": {
"start": "electron .",
"dist": "electron-builder --win portable --x64",
"dist:x64": "electron-builder --win portable --x64"
"dist": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-win.ps1 -Arch x64",
"dist:x64": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-win.ps1 -Arch x64",
"prepare:ia32": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/prepare-ia32.ps1",
"dist:ia32": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/build-win.ps1 -Arch ia32"
},
"devDependencies": {
"electron": "22.3.27",
"electron-builder": "24.13.3"
"electron-builder": "24.13.3",
"rcedit": "^5.0.2"
},
"build": {
"appId": "top.fmcraft.chat.desktop",
"productName": "FMCRAFT Chat",
"productName": "FMCRAFT Chat Desktop",
"electronDist": "node_modules/electron/dist",
"npmRebuild": false,
"compression": "normal",
+1
View File
@@ -9,6 +9,7 @@ contextBridge.exposeInMainWorld('desktopBridge', {
body: typeof payload.body === 'string' ? payload.body : '',
conversationId:
typeof payload.conversationId === 'string' ? payload.conversationId : undefined,
avatarUrl: typeof payload.avatarUrl === 'string' ? payload.avatarUrl : undefined,
})
},
})
+23
View File
@@ -0,0 +1,23 @@
import path from 'node:path'
import { rcedit } from 'rcedit'
const exePath = process.argv[2]
if (!exePath) {
console.error('Usage: node scripts/brand-exe.mjs <path-to-exe>')
process.exit(1)
}
await rcedit(exePath, {
icon: path.resolve('assets/icon.ico'),
'version-string': {
CompanyName: 'FMCRAFT',
FileDescription: 'FMCRAFT Chat Desktop',
InternalName: 'FMCRAFT Chat Desktop',
OriginalFilename: path.basename(exePath),
ProductName: 'FMCRAFT Chat Desktop',
LegalCopyright: 'Copyright © FMCRAFT',
},
'file-version': '1.0.0',
'product-version': '1.0.0',
})
console.log(`branded ${exePath}`)
+39
View File
@@ -0,0 +1,39 @@
param(
[ValidateSet('x64', 'ia32')]
[string]$Arch = 'x64'
)
$ErrorActionPreference = 'Stop'
$desktop = Split-Path -Parent $PSScriptRoot
Set-Location $desktop
if (-not $env:ELECTRON_MIRROR) {
$env:ELECTRON_MIRROR = 'https://npmmirror.com/mirrors/electron/'
}
if (-not $env:ELECTRON_BUILDER_BINARIES_MIRROR) {
$env:ELECTRON_BUILDER_BINARIES_MIRROR = 'https://npmmirror.com/mirrors/electron-builder-binaries/'
}
$env:CSC_IDENTITY_AUTO_DISCOVERY = 'false'
if ($Arch -eq 'ia32') {
& (Join-Path $PSScriptRoot 'prepare-ia32.ps1')
$electronDist = 'node_modules/electron-ia32/dist'
$outDir = 'release/win-ia32-unpacked'
} else {
$electronDist = 'node_modules/electron/dist'
$outDir = 'release/win-unpacked'
}
Write-Host "Building unpacked app ($Arch)..."
& .\node_modules\.bin\electron-builder.cmd --win --$Arch --dir --config.electronDist=$electronDist
$exe = Join-Path $outDir 'FMCRAFT Chat Desktop.exe'
if (!(Test-Path $exe)) {
throw "未找到打包后的 exe:$exe"
}
node scripts\brand-exe.mjs $exe
Write-Host "Building portable exe ($Arch)..."
& .\node_modules\.bin\electron-builder.cmd --win portable --$Arch --prepackaged $outDir
Write-Host "Done: release\FMCRAFT-Chat-1.0.0-$Arch.exe"
+22
View File
@@ -0,0 +1,22 @@
$ErrorActionPreference = 'Stop'
$version = '22.3.27'
$url = "https://npmmirror.com/mirrors/electron/$version/electron-v$version-win32-ia32.zip"
$zip = Join-Path $env:TEMP "electron-v$version-win32-ia32.zip"
$desktop = Split-Path -Parent $PSScriptRoot
$target = Join-Path $desktop 'node_modules\electron-ia32'
$dist = Join-Path $target 'dist'
if (!(Test-Path $zip) -or (Get-Item $zip).Length -lt 90000000) {
Write-Host "Downloading Electron $version ia32..."
curl.exe -L --fail --retry 3 -o $zip $url
}
if (Test-Path $dist) { Remove-Item $dist -Recurse -Force }
New-Item -ItemType Directory -Path $dist -Force | Out-Null
Write-Host "Extracting Electron ia32..."
Expand-Archive -Path $zip -DestinationPath $dist -Force
Set-Content -Path (Join-Path $target 'path.txt') -Value 'electron.exe' -NoNewline -Encoding ascii
Set-Content -Path (Join-Path $dist 'version') -Value $version -NoNewline -Encoding ascii
Write-Host "Electron ia32 ready: $dist"
+2 -2
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FMCRAFT Chat 通知设置</title>
<title>FMCRAFT Chat Desktop 通知设置</title>
<style>
:root {
color-scheme: dark;
@@ -90,7 +90,7 @@
<input type="checkbox" id="windows" />
<span>
<strong>Windows 消息通知</strong>
<span>通过 Windows 托盘气泡提醒新消息,兼容 Windows 7 及以上。</span>
<span>Windows 10/11 使用系统通知,Windows 7/8 使用托盘气泡;显示发送人头像和会话名称。</span>
</span>
</label>