diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..5350aeb
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,114 @@
+name: Build and Release
+
+on:
+ push:
+ tags:
+ - 'v*'
+ workflow_dispatch:
+
+jobs:
+ build-windows:
+ runs-on: windows-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build Windows
+ run: npx electron-builder --win
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload Windows artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: windows-build
+ path: dist/*.exe
+
+ build-macos:
+ runs-on: macos-latest
+ strategy:
+ matrix:
+ arch: [x64, arm64]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build macOS (${{ matrix.arch }})
+ run: npx electron-builder --mac --${{ matrix.arch }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload macOS artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: macos-build-${{ matrix.arch }}
+ path: dist/*-${{ matrix.arch }}.dmg
+
+ build-linux:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Setup Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: '20'
+ cache: 'npm'
+
+ - name: Install dependencies
+ run: npm ci
+
+ - name: Build Linux
+ run: npx electron-builder --linux deb
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Upload Linux artifact
+ uses: actions/upload-artifact@v4
+ with:
+ name: linux-build
+ path: dist/*.deb
+
+ release:
+ needs: [build-windows, build-macos, build-linux]
+ runs-on: ubuntu-latest
+ if: startsWith(github.ref, 'refs/tags/')
+ permissions:
+ contents: write
+ steps:
+ - name: Download all artifacts
+ uses: actions/download-artifact@v4
+ with:
+ path: artifacts
+
+ - name: Create Release
+ uses: softprops/action-gh-release@v2
+ with:
+ files: |
+ artifacts/windows-build/*.exe
+ artifacts/macos-build-x64/*.dmg
+ artifacts/macos-build-arm64/*.dmg
+ artifacts/linux-build/*.deb
+ generate_release_notes: true
+ fail_on_unmatched_files: false
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/build/chatecnu.ico b/build/chatecnu.ico
index 35e26b6..9f5205b 100644
Binary files a/build/chatecnu.ico and b/build/chatecnu.ico differ
diff --git a/build/chatecnu.svg b/build/chatecnu.svg
index f5ae42e..9455286 100644
--- a/build/chatecnu.svg
+++ b/build/chatecnu.svg
@@ -1,6 +1,23 @@
-
-
+
UAT
@@ -300,11 +309,22 @@
let isUpdateAvailable = false;
let isUpdateDownloaded = false;
+ const refreshBadge = document.getElementById('refresh-badge');
+
// 刷新按钮
btnRefresh.onclick = () => {
+ // 点击刷新后隐藏红点
+ refreshBadge.style.display = 'none';
ipcRenderer.send('reload-page');
};
+ // Web 版本有更新
+ ipcRenderer.on('web-update-available', (event, info) => {
+ console.log(`[web-update] 有新版本: ${info.current} -> ${info.latest}`);
+ refreshBadge.style.display = 'block';
+ btnRefresh.title = `刷新页面 (新版本 ${info.latest} 可用)`;
+ });
+
// 精灵模式按钮
const btnSprite = document.getElementById('btn-sprite');
btnSprite.onclick = () => {
diff --git a/main.js b/main.js
index 008c7cc..27a080c 100644
--- a/main.js
+++ b/main.js
@@ -21,6 +21,9 @@ const APP_TITLE = `ChatECNU Desktop v${version}`;
// 判断是否为打包后的应用
const isPackaged = app.isPackaged;
+// 命令行参数:--uat 强制启用 UAT 模式
+const forceUat = process.argv.includes('--uat');
+
// ========== 窗口尺寸常量 ==========
const MAIN_WINDOW_WIDTH = 1280;
const MAIN_WINDOW_HEIGHT = 768;
@@ -67,9 +70,9 @@ let settingsWindow = null; // 设置窗口
function createWindow() {
// 图标路径
const iconPath = getIconPath('chatecnu.ico');
- const isUatModeEnabled = store.get('uatMode'); // UAT 功能是否启用
+ const isUatModeEnabled = store.get('uatMode') || forceUat; // UAT 功能是否启用
const isUatActive = store.get('uatActive'); // UAT 环境是否激活
- const shouldUseUat = isUatModeEnabled && isUatActive; // 启动时是否使用 UAT
+ const shouldUseUat = forceUat || (isUatModeEnabled && isUatActive); // 启动时是否使用 UAT
// 创建主窗口(App Shell)
mainWindow = new BrowserWindow({
@@ -214,6 +217,77 @@ function createWindow() {
});
}
+// ========== JSBridge ==========
+// 当前 web 版本(由前端上报)
+let currentWebVersion = null;
+
+// 比较版本号,返回 1 (a > b), -1 (a < b), 0 (a == b)
+function compareVersions(a, b) {
+ const partsA = a.replace(/^v/, '').split('.').map(Number);
+ const partsB = b.replace(/^v/, '').split('.').map(Number);
+ for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) {
+ const numA = partsA[i] || 0;
+ const numB = partsB[i] || 0;
+ if (numA > numB) return 1;
+ if (numA < numB) return -1;
+ }
+ return 0;
+}
+
+// 检查 web 版本更新
+async function checkWebVersionUpdate(reportedVersion) {
+ try {
+ // 根据当前环境确定 API 地址(与 createWindow 逻辑保持一致)
+ const shouldUseUat = forceUat || (store.get('uatMode') && store.get('uatActive'));
+ const baseUrl = shouldUseUat ? URL_UAT : URL_PRODUCTION;
+ const url = `${baseUrl}/api/version/latest?_t=${Date.now()}`;
+
+ const response = await fetch(url);
+ const result = await response.json();
+
+ if (result.code === 0 && result.data && result.data.version) {
+ const serverVersion = result.data.version;
+ // 比较:服务器版本 > 当前加载版本
+ if (compareVersions(serverVersion, reportedVersion) > 0) {
+ console.log(`[web-update] new version available: ${reportedVersion} -> ${serverVersion}`);
+ // 通知主窗口显示提示
+ if (mainWindow) {
+ mainWindow.webContents.send('web-update-available', {
+ current: reportedVersion,
+ latest: serverVersion
+ });
+ }
+ } else {
+ console.log(`[web-update] up to date: ${reportedVersion}`);
+ }
+ }
+ } catch (err) {
+ console.error('[web-update] check failed:', err.message);
+ }
+}
+
+// 网页通过 _tx.status(key, value) 告知客户端状态
+ipcMain.on('tx:status', (event, key, value) => {
+ console.log(`[tx:status] ${key}:`, value);
+
+ // 处理 version 上报
+ if (key === 'version' && value && value.frontend) {
+ const reportedVersion = value.frontend.replace(/^v/, '');
+ // 避免重复检查同一版本
+ if (currentWebVersion !== reportedVersion) {
+ currentWebVersion = reportedVersion;
+ checkWebVersionUpdate(reportedVersion);
+ }
+ }
+});
+
+// 向网页发送事件(供主进程其他模块调用)
+function emitToWeb(eventName, data) {
+ if (view && view.webContents) {
+ view.webContents.send('tx:event', eventName, data);
+ }
+}
+
// IPC: 检查更新
ipcMain.on('check-for-updates', () => checkForUpdates(true));
diff --git a/package-lock.json b/package-lock.json
index 38850cf..033f2bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "chatecnu-desktop-app",
- "version": "0.1.31",
+ "version": "0.1.41",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "chatecnu-desktop-app",
- "version": "0.1.31",
+ "version": "0.1.41",
"license": "ECNU",
"dependencies": {
"electron-store": "^8.1.0",
diff --git a/package.json b/package.json
index 39e1cee..41e4c8c 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,12 @@
{
"name": "chatecnu-desktop-app",
- "version": "0.1.31",
+ "version": "0.1.41",
"description": "Desktop Application for chat.ecnu.edu.cn",
"main": "main.js",
"scripts": {
"start": "electron .",
"dev": "electron .",
+ "dev:uat": "electron . --uat",
"build": "dotenv -- electron-builder",
"build:win": "dotenv -- electron-builder --win && node post-build.js win",
"build:mac": "dotenv -- electron-builder --mac && node post-build.js mac",
@@ -19,7 +20,10 @@
"desktop",
"chat"
],
- "author": "ChatECNU Team @ ECNU",
+ "author": {
+ "name": "ChatECNU Team",
+ "email": "20180208@ecnu.edu.cn"
+ },
"license": "ECNU",
"repository": {
"type": "git",
@@ -72,7 +76,7 @@
}
],
"artifactName": "${productName}-${version}-${arch}.${ext}",
- "icon": "build/chatecnu.ico",
+ "icon": "build/icon.png",
"category": "public.app-category.productivity"
},
"dmg": {
@@ -89,6 +93,19 @@
}
]
},
+ "linux": {
+ "target": [
+ {
+ "target": "deb",
+ "arch": [
+ "x64"
+ ]
+ }
+ ],
+ "artifactName": "${productName}-${version}-${arch}.${ext}",
+ "icon": "build/icons",
+ "category": "Utility"
+ },
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
diff --git a/preload.js b/preload.js
index 3debbe7..8fdfe2f 100644
--- a/preload.js
+++ b/preload.js
@@ -1 +1,40 @@
-// preload.js - 预留,暂无功能
+// preload.js - JSBridge 实现
+const { contextBridge, ipcRenderer } = require('electron');
+
+// 事件监听器存储
+const listeners = new Map();
+
+// 接收主进程发来的事件,触发对应回调
+ipcRenderer.on('tx:event', (_, eventName, data) => {
+ const callbacks = listeners.get(eventName);
+ if (callbacks) {
+ callbacks.forEach(cb => cb(data));
+ }
+});
+
+// 暴露 _tx 对象给网页
+contextBridge.exposeInMainWorld('_tx', {
+ // 网页 → 客户端:告知客户端某个 key 的当前值
+ status: (key, value) => {
+ ipcRenderer.send('tx:status', key, value);
+ },
+
+ // 客户端 → 网页:注册事件监听
+ on: (event, callback) => {
+ if (!listeners.has(event)) {
+ listeners.set(event, []);
+ }
+ listeners.get(event).push(callback);
+ },
+
+ // 移除事件监听(防止内存泄漏)
+ off: (event, callback) => {
+ const callbacks = listeners.get(event);
+ if (callbacks) {
+ const index = callbacks.indexOf(callback);
+ if (index > -1) {
+ callbacks.splice(index, 1);
+ }
+ }
+ }
+});
diff --git a/tools/generate-linux-icons.js b/tools/generate-linux-icons.js
new file mode 100644
index 0000000..eedfe28
--- /dev/null
+++ b/tools/generate-linux-icons.js
@@ -0,0 +1,27 @@
+const sharp = require('sharp');
+const path = require('path');
+const fs = require('fs');
+
+const sizes = [16, 32, 48, 64, 128, 256, 512];
+const sourceIcon = path.join(__dirname, '../build/icon.png');
+const outputDir = path.join(__dirname, '../build/icons');
+
+async function generateIcons() {
+ // 创建输出目录
+ if (!fs.existsSync(outputDir)) {
+ fs.mkdirSync(outputDir, { recursive: true });
+ }
+
+ for (const size of sizes) {
+ const outputPath = path.join(outputDir, `${size}x${size}.png`);
+ await sharp(sourceIcon)
+ .resize(size, size)
+ .png()
+ .toFile(outputPath);
+ console.log(`Generated: ${size}x${size}.png`);
+ }
+
+ console.log('All Linux icons generated successfully!');
+}
+
+generateIcons().catch(console.error);
diff --git a/tools/svg-to-ico.js b/tools/svg-to-ico.js
index c3180d7..bfe6e38 100644
--- a/tools/svg-to-ico.js
+++ b/tools/svg-to-ico.js
@@ -54,7 +54,7 @@ async function svgToIco(inputSvg, outputIco) {
// 命令行参数
const inputSvg = process.argv[2] || path.join(__dirname, '../build/chatecnu.svg');
-const outputIco = process.argv[3] || path.join(__dirname, '../build/favicon.ico');
+const outputIco = process.argv[3] || path.join(__dirname, '../build/chatecnu.ico');
svgToIco(inputSvg, outputIco).catch(err => {
console.error('错误:', err.message);
diff --git a/tools/svg-to-png.js b/tools/svg-to-png.js
new file mode 100644
index 0000000..ede95a8
--- /dev/null
+++ b/tools/svg-to-png.js
@@ -0,0 +1,29 @@
+/**
+ * SVG 转 PNG (用于 Mac/Linux 图标)
+ * 用法: node svg-to-png.js [input.svg] [output.png] [size]
+ */
+const sharp = require('sharp');
+const path = require('path');
+
+async function svgToPng(inputSvg, outputPng, size = 512) {
+ console.log(`转换 ${inputSvg} -> ${outputPng} (${size}x${size})`);
+
+ await sharp(inputSvg)
+ .resize(size, size, {
+ fit: 'contain',
+ background: { r: 0, g: 0, b: 0, alpha: 0 }
+ })
+ .png()
+ .toFile(outputPng);
+
+ console.log(`✓ 完成: ${outputPng}`);
+}
+
+const inputSvg = process.argv[2] || path.join(__dirname, '../build/chatecnu.svg');
+const outputPng = process.argv[3] || path.join(__dirname, '../build/icon.png');
+const size = parseInt(process.argv[4]) || 512;
+
+svgToPng(inputSvg, outputPng, size).catch(err => {
+ console.error('错误:', err.message);
+ process.exit(1);
+});