Archived
519 lines
14 KiB
JavaScript
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()
|
|
})
|