初始化仓库

This commit is contained in:
DHEnry committed 2026-10-25 15:32:00 +08:00
commit 2347e5ffa9
13 files changed
+834

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
node_modules/
release/
dist/
out/
*.log
+65
View File
@@ -0,0 +1,65 @@
# FMCRAFT Chat 桌面版
Windows 7 及以上可用的 FMCRAFT Chat 桌面客户端。使用 Electron 22(最后一个支持 Windows 7/8/8.1 的 Electron 版本)直接嵌入网页。
## 功能
- 直接加载 `config.ini` 中配置的网页地址
- Windows 消息通知
- Windows 10/11:系统 Toast 通知
- Windows 7/8/8.1:托盘气泡通知
- 任务栏闪烁
- 本地音效播放(不依赖网页音频权限)
- 三项通知开关均可在设置中关闭,默认全部开启
- 配置保存在本地 `config.ini`
## 配置
默认 `config.ini`:
```ini
[app]
url=https://chat.fmcraft.top
[notifications]
windows=true
flash=true
sound=true
```
程序会优先读写可执行文件同目录下的 `config.ini`。如果该目录不可写(例如安装在 `Program Files`),会自动回退到 `%APPDATA%\FMCRAFT Chat\config.ini`,设置页会显示实际使用的路径。
## 开发运行
```bash
npm install
npm start
```
## 打包
```bash
# 64 位 Windows 7+
npm run dist:x64
# 32 位 Windows 7+
npm run dist:ia32
```
生成文件在 `release/` 目录。推荐分发 `FMCRAFT-Chat-1.0.0-x64.exe` 便携版。
## 通知开关
可以通过以下位置打开设置:
- 托盘图标右键 →「通知设置…」
- 菜单栏「文件」→「通知设置…」
- 快捷键 `Ctrl+,`
设置窗口内也可以直接测试当前开关组合。
## 说明
- Windows 7 建议安装 SP1 和系统证书更新。
- 网页端通过 `window.desktopBridge.notify()` 调用桌面通知;普通浏览器中该桥接不存在,不影响网页使用。
- 桌面通知只在当前没有停留在对应聊天窗口时触发。任务栏闪烁只在窗口未聚焦时执行。
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 245 KiB

Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

+23
View File
@@ -0,0 +1,23 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>FMCRAFT Chat notification sound</title>
</head>
<body>
<audio id="sound" src="assets/notification.wav" preload="auto"></audio>
<script>
window.playNotification = function () {
var sound = document.getElementById('sound')
if (!sound) return
try {
sound.currentTime = 0
var promise = sound.play()
if (promise && typeof promise.catch === 'function') promise.catch(function () {})
} catch (err) {
/* 忽略音频播放失败 */
}
}
</script>
</body>
</html>
+7
View File
@@ -0,0 +1,7 @@
[app]
url=https://chat.fmcraft.top
[notifications]
windows=true
flash=true
sound=true
+473
View File
@@ -0,0 +1,473 @@
const {
app,
BrowserWindow,
Tray,
Menu,
Notification,
ipcMain,
shell,
dialog,
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'
const DEFAULT_URL = 'https://chat.fmcraft.top'
const DEFAULT_CONFIG = {
url: DEFAULT_URL,
windows: true,
flash: true,
sound: true,
}
const iconPath = path.join(__dirname, 'assets', 'icon.png')
const trayIconPath = path.join(__dirname, 'assets', 'tray.png')
let config = { ...DEFAULT_CONFIG }
let configPath = ''
let mainWindow = null
let settingsWindow = null
let tray = null
let audioWindow = null
let audioReady = false
let pendingSound = false
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
app.setName('FMCRAFT Chat')
function defaultConfigText() {
return [
'[app]',
`url=${DEFAULT_URL}`,
'',
'[notifications]',
'windows=true',
'flash=true',
'sound=true',
'',
].join('\n')
}
function parseIni(text) {
const result = {}
let section = ''
for (const raw of text.split(/\r?\n/)) {
const line = raw.trim()
if (!line || line.startsWith(';') || line.startsWith('#')) continue
const sectionMatch = line.match(/^\[([^\]]+)\]$/)
if (sectionMatch) {
section = sectionMatch[1].trim()
if (!result[section]) result[section] = {}
continue
}
const index = line.indexOf('=')
if (index === -1) continue
const key = line.slice(0, index).trim()
const value = line
.slice(index + 1)
.trim()
.replace(/^"(.*)"$/, '$1')
if (!result[section]) result[section] = {}
result[section][key] = value
}
return result
}
function boolValue(value, fallback) {
if (value === undefined || value === null || value === '') return fallback
return ['1', 'true', 'yes', 'on'].includes(String(value).toLowerCase())
}
function resolveConfigPath() {
const portableDir = process.env.PORTABLE_EXECUTABLE_DIR
const localDir = portableDir || (app.isPackaged ? path.dirname(app.getPath('exe')) : __dirname)
const localPath = path.join(localDir, 'config.ini')
try {
if (!fs.existsSync(localPath)) fs.writeFileSync(localPath, defaultConfigText(), 'utf8')
fs.accessSync(localPath, fs.constants.W_OK)
return localPath
} catch {
// Program Files 等只读目录:回退到用户数据目录,设置页会显示实际路径。
const fallbackDir = app.getPath('userData')
fs.mkdirSync(fallbackDir, { recursive: true })
const fallbackPath = path.join(fallbackDir, 'config.ini')
if (!fs.existsSync(fallbackPath)) {
fs.writeFileSync(fallbackPath, defaultConfigText(), 'utf8')
}
return fallbackPath
}
}
function loadConfig() {
configPath = resolveConfigPath()
let raw = {}
try {
raw = parseIni(fs.readFileSync(configPath, 'utf8'))
} catch {
raw = {}
}
const appSection = raw.app || {}
const notifications = raw.notifications || {}
config = {
url: (appSection.url || DEFAULT_URL).trim() || DEFAULT_URL,
windows: boolValue(notifications.windows, true),
flash: boolValue(notifications.flash, true),
sound: boolValue(notifications.sound, true),
}
}
function saveConfig() {
const text = [
'[app]',
`url=${config.url}`,
'',
'[notifications]',
`windows=${config.windows ? 'true' : 'false'}`,
`flash=${config.flash ? 'true' : 'false'}`,
`sound=${config.sound ? 'true' : 'false'}`,
'',
].join('\n')
try {
fs.writeFileSync(configPath, text, 'utf8')
} catch (err) {
dialog.showErrorBox('保存配置失败', String(err && err.message ? err.message : err))
}
}
function isSameSite(url) {
try {
return new URL(url).origin === new URL(config.url).origin
} catch {
return false
}
}
function createMainWindow() {
mainWindow = new BrowserWindow({
width: 1280,
height: 820,
minWidth: 960,
minHeight: 600,
title: 'FMCRAFT Chat',
icon: iconPath,
backgroundColor: '#0b0e14',
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
spellcheck: true,
},
})
mainWindow.loadURL(config.url).catch((err) => {
dialog.showErrorBox(
'无法打开 FMCRAFT Chat',
`请检查 config.ini 中的页面地址是否可访问。\n\n${String(err && err.message ? err.message : err)}`
)
})
mainWindow.on('focus', () => {
if (mainWindow && !mainWindow.isDestroyed()) mainWindow.flashFrame(false)
})
mainWindow.on('closed', () => {
mainWindow = null
})
// 站内链接留在应用内,外部链接交给系统浏览器。
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url)
return { action: 'deny' }
})
mainWindow.webContents.on('will-navigate', (event, url) => {
if (url.startsWith('http') && !isSameSite(url)) {
event.preventDefault()
shell.openExternal(url)
}
})
}
function showMainWindow() {
if (!mainWindow || mainWindow.isDestroyed()) {
createMainWindow()
return
}
if (mainWindow.isMinimized()) mainWindow.restore()
mainWindow.show()
mainWindow.focus()
}
function createAudioWindow() {
audioWindow = new BrowserWindow({
show: false,
skipTaskbar: true,
webPreferences: {
contextIsolation: true,
nodeIntegration: false,
backgroundThrottling: false,
},
})
audioWindow.loadFile(path.join(__dirname, 'audio.html'))
audioWindow.webContents.on('did-finish-load', () => {
audioReady = true
if (pendingSound) {
pendingSound = false
playSound(true)
}
})
}
function playSound(enabled = config.sound) {
if (!enabled) return
if (!audioReady || !audioWindow || audioWindow.isDestroyed()) {
pendingSound = true
return
}
audioWindow.webContents
.executeJavaScript('window.playNotification && window.playNotification()')
.catch(() => {})
}
function isLegacyWindows() {
return process.platform === 'win32' && /^6\./.test(os.release())
}
function showWindowsNotification(title, body) {
if (isLegacyWindows() && tray) {
// Windows 7/8/8.1 没有 Win10 Toast,使用托盘气泡提醒。
tray.displayBalloon({
title,
content: body,
icon: iconPath,
noSound: true,
respectQuietTime: true,
})
return
}
if (!Notification.isSupported()) {
if (tray) {
tray.displayBalloon({ title, content: body, icon: iconPath, noSound: true })
}
return
}
const notification = new Notification({
title,
body,
icon: iconPath,
silent: true,
})
notification.on('click', showMainWindow)
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 body = String(payload && payload.body ? payload.body : '').slice(0, 500)
if (options.windows) showWindowsNotification(title, body)
if (options.flash && mainWindow && !mainWindow.isDestroyed() && !mainWindow.isFocused()) {
mainWindow.flashFrame(true)
}
if (options.sound) playSound(true)
}
function updateTrayMenu() {
if (!tray || tray.isDestroyed()) return
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示 FMCRAFT Chat', click: showMainWindow },
{ label: '通知设置…', click: openSettingsWindow },
{ type: 'separator' },
{
label: 'Windows 消息通知',
type: 'checkbox',
checked: config.windows,
click: (item) => {
config.windows = !!item.checked
saveConfig()
},
},
{
label: '任务栏闪烁',
type: 'checkbox',
checked: config.flash,
click: (item) => {
config.flash = !!item.checked
saveConfig()
},
},
{
label: '音效播放',
type: 'checkbox',
checked: config.sound,
click: (item) => {
config.sound = !!item.checked
saveConfig()
},
},
{ type: 'separator' },
{ label: '退出', click: () => app.quit() },
])
)
}
function createTray() {
try {
const image = nativeImage.createFromPath(trayIconPath)
tray = new Tray(image)
tray.setToolTip('FMCRAFT Chat')
tray.on('double-click', showMainWindow)
tray.on('click', showMainWindow)
tray.on('balloon-click', showMainWindow)
updateTrayMenu()
} catch (err) {
console.warn('无法创建托盘图标:', err)
}
}
function createMenu() {
const template = [
{
label: '文件',
submenu: [
{ label: '通知设置…', accelerator: 'CmdOrCtrl+,', click: openSettingsWindow },
{
label: '打开配置文件',
click: () => {
shell.openPath(configPath).then((error) => {
if (error) shell.showItemInFolder(configPath)
})
},
},
{ type: 'separator' },
{ role: 'quit', label: '退出' },
],
},
{
label: '查看',
submenu: [
{ role: 'reload', label: '刷新' },
{ role: 'forceReload', label: '强制刷新' },
{ role: 'toggleDevTools', label: '开发者工具' },
{ type: 'separator' },
{ role: 'resetZoom', label: '实际大小' },
{ role: 'zoomIn', label: '放大' },
{ role: 'zoomOut', label: '缩小' },
{ role: 'togglefullscreen', label: '全屏' },
],
},
{
label: '帮助',
submenu: [
{
label: '关于 FMCRAFT Chat',
click: () => {
dialog.showMessageBox({
type: 'info',
title: '关于 FMCRAFT Chat',
message: 'FMCRAFT Chat 桌面版',
detail: `版本 ${app.getVersion()}\n适用于 Windows 7 及以上。\n\n配置文件:${configPath}`,
buttons: ['确定'],
})
},
},
],
},
]
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
}
function openSettingsWindow() {
if (settingsWindow && !settingsWindow.isDestroyed()) {
settingsWindow.show()
settingsWindow.focus()
return
}
settingsWindow = new BrowserWindow({
width: 500,
height: 470,
minWidth: 460,
minHeight: 430,
resizable: true,
minimizable: false,
maximizable: false,
title: 'FMCRAFT Chat 通知设置',
icon: iconPath,
parent: mainWindow || undefined,
autoHideMenuBar: true,
webPreferences: {
preload: path.join(__dirname, 'settings-preload.js'),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
})
settingsWindow.setMenuBarVisibility(false)
settingsWindow.loadFile(path.join(__dirname, 'settings.html'))
settingsWindow.on('closed', () => {
settingsWindow = null
})
}
function registerIpc() {
ipcMain.on('desktop:notify', (_event, payload) => {
if (!payload || typeof payload !== 'object') return
handleDesktopNotification(payload)
})
ipcMain.handle('settings:get', () => ({ ...config, configPath }))
ipcMain.handle('settings:save', (_event, values) => {
if (values && typeof values === 'object') {
config.windows = !!values.windows
config.flash = !!values.flash
config.sound = !!values.sound
saveConfig()
updateTrayMenu()
}
return { ...config, configPath }
})
ipcMain.handle('settings:test', (_event, values) => {
const options = values && typeof values === 'object' ? values : config
handleDesktopNotification(
{
title: 'FMCRAFT Chat 通知测试',
body: '如果你看到这条通知,说明桌面通知工作正常。',
},
options
)
return { ok: true }
})
ipcMain.on('settings:open-config', () => {
shell.openPath(configPath).then((error) => {
if (error) shell.showItemInFolder(configPath)
})
})
}
app.whenReady().then(() => {
if (process.platform === 'win32') app.setAppUserModelId(APP_ID)
loadConfig()
registerIpc()
createMainWindow()
createMenu()
createTray()
createAudioWindow()
app.on('activate', () => {
if (!mainWindow) createMainWindow()
})
})
app.on('window-all-closed', () => {
if (process.platform !== 'darwin') app.quit()
})
app.on('before-quit', () => {
if (audioWindow && !audioWindow.isDestroyed()) audioWindow.destroy()
})
+50
View File
@@ -0,0 +1,50 @@
{
"name": "fmcraft-chat-desktop",
"version": "1.0.0",
"description": "FMCRAFT Chat desktop client for Windows 7 and above",
"main": "main.js",
"author": "FMCRAFT",
"license": "UNLICENSED",
"private": true,
"scripts": {
"start": "electron .",
"dist": "electron-builder --win portable --x64",
"dist:x64": "electron-builder --win portable --x64",
"dist:ia32": "electron-builder --win portable --ia32"
},
"devDependencies": {
"electron": "22.3.27",
"electron-builder": "24.13.3"
},
"build": {
"appId": "top.fmcraft.chat.desktop",
"productName": "FMCRAFT Chat",
"directories": {
"output": "release"
},
"files": [
"main.js",
"preload.js",
"settings.html",
"settings.js",
"settings-preload.js",
"audio.html",
"assets/**/*"
],
"extraFiles": [
{
"from": "config.ini",
"to": "config.ini"
}
],
"win": {
"target": [
"portable"
],
"icon": "assets/icon.png"
},
"portable": {
"artifactName": "FMCRAFT-Chat-${version}-${arch}.${ext}"
}
}
}
+14
View File
@@ -0,0 +1,14 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('desktopBridge', {
isDesktop: true,
notify(payload) {
if (!payload || typeof payload !== 'object') return
ipcRenderer.send('desktop:notify', {
title: typeof payload.title === 'string' ? payload.title : '',
body: typeof payload.body === 'string' ? payload.body : '',
conversationId:
typeof payload.conversationId === 'string' ? payload.conversationId : undefined,
})
},
})
+8
View File
@@ -0,0 +1,8 @@
const { contextBridge, ipcRenderer } = require('electron')
contextBridge.exposeInMainWorld('settingsAPI', {
get: () => ipcRenderer.invoke('settings:get'),
save: (values) => ipcRenderer.invoke('settings:save', values),
test: (values) => ipcRenderer.invoke('settings:test', values),
openConfig: () => ipcRenderer.send('settings:open-config'),
})
+124
View File
@@ -0,0 +1,124 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FMCRAFT Chat 通知设置</title>
<style>
:root {
color-scheme: dark;
--bg: #0b0e14;
--panel: #141922;
--panel2: #1b2230;
--line: #2a3444;
--fg: #e7edf7;
--muted: #8b98ad;
--accent: #3b82f6;
--danger: #ef4444;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
padding: 18px;
background: var(--bg);
color: var(--fg);
font-family: "Microsoft YaHei UI", "Segoe UI", sans-serif;
font-size: 13px;
}
.card {
max-width: 520px;
margin: 0 auto;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--panel);
padding: 20px;
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
}
h1 { margin: 0; font-size: 18px; }
.sub { margin: 6px 0 18px; color: var(--muted); font-size: 12px; line-height: 1.7; }
.row {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel2);
margin-bottom: 10px;
}
.row input { margin-top: 2px; width: 16px; height: 16px; accent-color: var(--accent); }
.row strong { display: block; font-size: 13px; font-weight: 600; }
.row span { display: block; margin-top: 3px; color: var(--muted); font-size: 11px; line-height: 1.6; }
.path {
margin-top: 14px;
padding: 10px 12px;
border-radius: 10px;
background: var(--panel2);
color: var(--muted);
font-size: 11px;
line-height: 1.6;
word-break: break-all;
}
.path code { color: var(--fg); font-family: Consolas, monospace; }
.actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 16px; }
button {
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel2);
color: var(--fg);
padding: 9px 14px;
font-size: 12px;
cursor: pointer;
}
button:hover { background: #222c3d; }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.primary:hover { filter: brightness(1.08); }
#status { min-height: 18px; margin: 12px 0 0; color: #34d399; font-size: 12px; }
#status.error { color: var(--danger); }
</style>
</head>
<body>
<div class="card">
<h1>通知设置</h1>
<p class="sub">
当窗口未停留在对应会话时,桌面端可以提醒新消息。所有开关默认开启,设置会保存到本地
config.ini。
</p>
<label class="row">
<input type="checkbox" id="windows" />
<span>
<strong>Windows 消息通知</strong>
<span>Windows 10/11 使用系统通知;Windows 7/8 使用托盘气泡提醒。</span>
</span>
</label>
<label class="row">
<input type="checkbox" id="flash" />
<span>
<strong>任务栏闪烁</strong>
<span>窗口未聚焦时闪烁任务栏,点击窗口后自动停止。</span>
</span>
</label>
<label class="row">
<input type="checkbox" id="sound" />
<span>
<strong>音效播放</strong>
<span>收到提醒时播放桌面端本地音效,不依赖网页音频权限。</span>
</span>
</label>
<div class="path">配置文件:<code id="configPath">加载中…</code></div>
<div class="actions">
<button id="test">测试通知</button>
<button id="openConfig">打开配置文件</button>
<button id="save" class="primary">保存设置</button>
</div>
<p id="status"></p>
</div>
<script src="settings.js"></script>
</body>
</html>
+65
View File
@@ -0,0 +1,65 @@
const api = window.settingsAPI
const windowsInput = document.getElementById('windows')
const flashInput = document.getElementById('flash')
const soundInput = document.getElementById('sound')
const configPathText = document.getElementById('configPath')
const statusText = document.getElementById('status')
const saveButton = document.getElementById('save')
const testButton = document.getElementById('test')
const openConfigButton = document.getElementById('openConfig')
function values() {
return {
windows: !!windowsInput.checked,
flash: !!flashInput.checked,
sound: !!soundInput.checked,
}
}
function setStatus(text, error = false) {
statusText.textContent = text
statusText.classList.toggle('error', error)
}
async function load() {
try {
const config = await api.get()
windowsInput.checked = !!config.windows
flashInput.checked = !!config.flash
soundInput.checked = !!config.sound
configPathText.textContent = config.configPath
} catch (err) {
setStatus(`读取配置失败:${err && err.message ? err.message : err}`, true)
}
}
saveButton.addEventListener('click', async () => {
saveButton.disabled = true
try {
const config = await api.save(values())
configPathText.textContent = config.configPath
setStatus('已保存,立即生效。')
} catch (err) {
setStatus(`保存失败:${err && err.message ? err.message : err}`, true)
} finally {
saveButton.disabled = false
}
})
testButton.addEventListener('click', async () => {
testButton.disabled = true
try {
await api.test(values())
setStatus('测试通知已触发。')
} catch (err) {
setStatus(`测试失败:${err && err.message ? err.message : err}`, true)
} finally {
testButton.disabled = false
}
})
openConfigButton.addEventListener('click', () => {
api.openConfig()
})
load()