commit 2347e5ffa9c5f9db7d8cfdd2768dd8289c9aac54 Author: DHEnry Date: Sun Oct 25 15:32:00 2026 +0800 初始化仓库 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7dbcd50 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +release/ +dist/ +out/ +*.log diff --git a/README.md b/README.md new file mode 100644 index 0000000..99863d5 --- /dev/null +++ b/README.md @@ -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()` 调用桌面通知;普通浏览器中该桥接不存在,不影响网页使用。 +- 桌面通知只在当前没有停留在对应聊天窗口时触发。任务栏闪烁只在窗口未聚焦时执行。 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000..14018c5 Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/notification.wav b/assets/notification.wav new file mode 100644 index 0000000..f1cc8fc Binary files /dev/null and b/assets/notification.wav differ diff --git a/assets/tray.png b/assets/tray.png new file mode 100644 index 0000000..cdf670c Binary files /dev/null and b/assets/tray.png differ diff --git a/audio.html b/audio.html new file mode 100644 index 0000000..594c42d --- /dev/null +++ b/audio.html @@ -0,0 +1,23 @@ + + + + + FMCRAFT Chat notification sound + + + + + + diff --git a/config.ini b/config.ini new file mode 100644 index 0000000..bf7035a --- /dev/null +++ b/config.ini @@ -0,0 +1,7 @@ +[app] +url=https://chat.fmcraft.top + +[notifications] +windows=true +flash=true +sound=true diff --git a/main.js b/main.js new file mode 100644 index 0000000..11a3f6b --- /dev/null +++ b/main.js @@ -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() +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..b4790d9 --- /dev/null +++ b/package.json @@ -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}" + } + } +} diff --git a/preload.js b/preload.js new file mode 100644 index 0000000..a644191 --- /dev/null +++ b/preload.js @@ -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, + }) + }, +}) diff --git a/settings-preload.js b/settings-preload.js new file mode 100644 index 0000000..bc16277 --- /dev/null +++ b/settings-preload.js @@ -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'), +}) diff --git a/settings.html b/settings.html new file mode 100644 index 0000000..20ad963 --- /dev/null +++ b/settings.html @@ -0,0 +1,124 @@ + + + + + + FMCRAFT Chat 通知设置 + + + +
+

通知设置

+

+ 当窗口未停留在对应会话时,桌面端可以提醒新消息。所有开关默认开启,设置会保存到本地 + config.ini。 +

+ + + + + + + +
配置文件:加载中…
+ +
+ + + +
+

+
+ + + diff --git a/settings.js b/settings.js new file mode 100644 index 0000000..8496c72 --- /dev/null +++ b/settings.js @@ -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()