This repository has been archived on 2026-11-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files

519 lines
14 KiB
JavaScript

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')
// 没有开始菜单快捷方式时,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,
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
let appIcon = null
app.commandLine.appendSwitch('autoplay-policy', 'no-user-gesture-required')
app.setName('FMCRAFT Chat Desktop')
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 Desktop',
icon: appIcon || 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 Desktop',
`请检查 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(() => {})
}
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: iconImage,
noSound: true,
respectQuietTime: true,
})
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: iconImage,
silent: true,
})
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 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
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)
}
if (options.sound) playSound(true)
}
function updateTrayMenu() {
if (!tray || tray.isDestroyed()) return
tray.setContextMenu(
Menu.buildFromTemplate([
{ label: '显示 FMCRAFT Chat Desktop', 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 Desktop')
tray.on('double-click', showMainWindow)
tray.on('click', showMainWindow)
tray.on('balloon-click', () => openConversationFromNotification(lastBalloonConversationId))
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 Desktop',
click: () => {
dialog.showMessageBox({
type: 'info',
title: '关于 FMCRAFT Chat Desktop',
message: 'FMCRAFT Chat Desktop',
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 Desktop 通知设置',
icon: appIcon || 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 Desktop',
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()
appIcon = nativeImage.createFromPath(iconPath)
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()
})