const { app, BrowserWindow, Tray, Menu, Notification, ipcMain, shell, dialog, nativeImage, } = require('electron') const fs = require('node:fs') 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 let appIcon = null 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: 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', `请检查 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 showWindowsNotification(title, body) { // Windows 7/8 没有 Toast;便携版在 Windows 10/11 上也不一定有开始菜单快捷方式。 // 统一使用托盘气泡,保证 Windows 7 及以上都能收到提醒。 if (process.platform === 'win32' && tray) { tray.displayBalloon({ title, content: body, icon: appIcon || iconPath, noSound: true, respectQuietTime: true, }) return } if (!Notification.isSupported()) return const notification = new Notification({ title, body, icon: appIcon || 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: 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 通知测试', 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() })