feat(desktop-client): 阶段4-6 Electron EXE 客户端(登录/课表/作业/通知/成绩/设置 + Socket.IO 实时 + IndexedDB 离线缓存与自动同步 + electron-builder 打包)
This commit is contained in:
1 parent
4566d7cd5e
commit
6e4c7c1050
42 files changed
+5767
-60
No files matched your search
@@ -0,0 +1,110 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, safeStorage } from 'electron';
|
||||
import { DEFAULT_SERVER_URL } from '@classhelper/shared';
|
||||
import type { DesktopStoredConfig } from '../types/desktop.js';
|
||||
|
||||
interface PersistedConfig {
|
||||
serverUrl: string;
|
||||
username: string;
|
||||
/** 加密后的 token(base64)或明文(当系统不支持加密时) */
|
||||
token: string | null;
|
||||
tokenEncrypted: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PersistedConfig = {
|
||||
serverUrl: DEFAULT_SERVER_URL,
|
||||
username: '',
|
||||
token: null,
|
||||
tokenEncrypted: false,
|
||||
};
|
||||
|
||||
function configFilePath(): string {
|
||||
return path.join(app.getPath('userData'), 'config.json');
|
||||
}
|
||||
|
||||
function encryptToken(token: string | null): { token: string | null; tokenEncrypted: boolean } {
|
||||
if (!token) return { token: null, tokenEncrypted: false };
|
||||
try {
|
||||
if (safeStorage.isEncryptionAvailable()) {
|
||||
return { token: safeStorage.encryptString(token).toString('base64'), tokenEncrypted: true };
|
||||
}
|
||||
} catch {
|
||||
// 某些环境下 DPAPI 不可用,降级为明文存储(仅本地开发场景)
|
||||
}
|
||||
return { token, tokenEncrypted: false };
|
||||
}
|
||||
|
||||
function decryptToken(raw: PersistedConfig): string | null {
|
||||
if (!raw.token) return null;
|
||||
if (!raw.tokenEncrypted) return raw.token;
|
||||
try {
|
||||
return safeStorage.decryptString(Buffer.from(raw.token, 'base64'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readPersisted(): PersistedConfig {
|
||||
try {
|
||||
const content = fs.readFileSync(configFilePath(), 'utf8');
|
||||
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
|
||||
return {
|
||||
serverUrl:
|
||||
typeof parsed.serverUrl === 'string' && parsed.serverUrl
|
||||
? parsed.serverUrl
|
||||
: DEFAULT_CONFIG.serverUrl,
|
||||
username: typeof parsed.username === 'string' ? parsed.username : '',
|
||||
token: typeof parsed.token === 'string' ? parsed.token : null,
|
||||
tokenEncrypted: parsed.tokenEncrypted === true,
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
}
|
||||
}
|
||||
|
||||
function writePersisted(config: PersistedConfig): void {
|
||||
const target = configFilePath();
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
/** 读取配置(token 已解密),供渲染进程使用 */
|
||||
export function getConfig(): DesktopStoredConfig {
|
||||
const persisted = readPersisted();
|
||||
return {
|
||||
serverUrl: persisted.serverUrl,
|
||||
username: persisted.username,
|
||||
token: decryptToken(persisted),
|
||||
};
|
||||
}
|
||||
|
||||
/** 局部更新配置:只覆盖传入的字段 */
|
||||
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
|
||||
const persisted = readPersisted();
|
||||
|
||||
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
|
||||
persisted.serverUrl = patch.serverUrl.trim().replace(/\/+$/, '');
|
||||
}
|
||||
if (typeof patch.username === 'string') {
|
||||
persisted.username = patch.username;
|
||||
}
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
persisted.tokenEncrypted = encrypted.tokenEncrypted;
|
||||
}
|
||||
|
||||
writePersisted(persisted);
|
||||
return getConfig();
|
||||
}
|
||||
|
||||
/** 清空为默认配置(退出登录 / 重置) */
|
||||
export function clearConfig(): DesktopStoredConfig {
|
||||
writePersisted({ ...DEFAULT_CONFIG });
|
||||
return getConfig();
|
||||
}
|
||||
|
||||
export function getConfigPath(): string {
|
||||
return configFilePath();
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import path from 'node:path';
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import { registerIpcHandlers } from './ipc.js';
|
||||
import { runSmokeTest } from './smoke.js';
|
||||
|
||||
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
|
||||
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
|
||||
const currentDir = __dirname;
|
||||
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
|
||||
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
const win = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
minWidth: 980,
|
||||
minHeight: 640,
|
||||
title: '班级小助手',
|
||||
// 冒烟验证时不弹窗,避免打扰使用者
|
||||
show: !isSmokeTest,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: '#f5f7fa',
|
||||
webPreferences: {
|
||||
preload: path.join(currentDir, '../preload/index.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
webSecurity: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 外部链接交给系统浏览器,禁止在应用内新开窗口
|
||||
win.webContents.setWindowOpenHandler(({ url }) => {
|
||||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||
return { action: 'deny' };
|
||||
});
|
||||
win.webContents.on('will-navigate', (event, url) => {
|
||||
const allowed = devServerUrl ? url.startsWith(devServerUrl) : url.startsWith('file://');
|
||||
if (!allowed) {
|
||||
event.preventDefault();
|
||||
if (/^https?:\/\//i.test(url)) void shell.openExternal(url);
|
||||
}
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
mainWindow = null;
|
||||
});
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
async function loadRenderer(win: BrowserWindow): Promise<void> {
|
||||
if (devServerUrl) {
|
||||
await win.loadURL(devServerUrl);
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
return;
|
||||
}
|
||||
// 生产:加载打包后的本地 HTML(vite base 为 './',使用 file:// 相对路径)
|
||||
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
// 单实例:重复启动时聚焦已有窗口
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
registerIpcHandlers();
|
||||
|
||||
mainWindow = createWindow();
|
||||
await loadRenderer(mainWindow);
|
||||
|
||||
if (isSmokeTest) {
|
||||
try {
|
||||
await runSmokeTest(mainWindow);
|
||||
} catch (error) {
|
||||
console.error('[SMOKE] 冒烟验证异常', error);
|
||||
app.exit(1);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
await loadRenderer(mainWindow);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { app, ipcMain, shell } from 'electron';
|
||||
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
|
||||
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
||||
|
||||
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
|
||||
export const IPC_CHANNELS = {
|
||||
getConfig: 'classhelper:config:get',
|
||||
saveConfig: 'classhelper:config:save',
|
||||
clearConfig: 'classhelper:config:clear',
|
||||
appInfo: 'classhelper:app:info',
|
||||
openExternal: 'classhelper:shell:open-external',
|
||||
} as const;
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
||||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.saveConfig,
|
||||
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
|
||||
return saveConfig(patch ?? {});
|
||||
},
|
||||
);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.clearConfig, (): DesktopStoredConfig => clearConfig());
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.appInfo, (): DesktopAppInfo => ({
|
||||
appVersion: app.getVersion(),
|
||||
electron: process.versions.electron ?? '',
|
||||
chrome: process.versions.chrome ?? '',
|
||||
node: process.versions.node ?? '',
|
||||
platform: `${process.platform} ${process.arch}`,
|
||||
userDataPath: app.getPath('userData'),
|
||||
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
|
||||
}));
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.openExternal, async (_event, url: string): Promise<boolean> => {
|
||||
if (!/^https?:\/\//i.test(url)) return false;
|
||||
await shell.openExternal(url);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
|
||||
return { configPath: getConfigPath(), config: getConfig() };
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron';
|
||||
import type { BrowserWindow } from 'electron';
|
||||
import { getDiagnostics } from './ipc.js';
|
||||
|
||||
interface SmokeResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
|
||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||
* 以及可选的联网集成(ELECTRON_SMOKE_ONLINE=1)。
|
||||
*
|
||||
* 结果同时输出到 stdout,并在设置 ELECTRON_SMOKE_RESULT 时写入 JSON 文件 ——
|
||||
* 打包后的 Windows GUI 进程没有 stdout,用结果文件才能在 CI/脚本里验证安装包。
|
||||
*/
|
||||
export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
const results: SmokeResult[] = [];
|
||||
const record = (name: string, ok: boolean, detail = ''): void => {
|
||||
results.push({ name, ok, detail });
|
||||
console.log(`${ok ? '[PASS]' : '[FAIL]'} ${name}${detail ? ` - ${detail}` : ''}`);
|
||||
};
|
||||
|
||||
// 等待 Vue 挂载并完成启动流程(离线时最多等 14 秒)
|
||||
let dom: {
|
||||
title: string;
|
||||
appChildren: number;
|
||||
hasLoginPage: boolean;
|
||||
hasInput: boolean;
|
||||
text: string;
|
||||
} | null = null;
|
||||
for (let attempt = 0; attempt < 56; attempt += 1) {
|
||||
dom = await win.webContents.executeJavaScript(
|
||||
`(() => {
|
||||
const app = document.querySelector('#app');
|
||||
return {
|
||||
title: document.title,
|
||||
appChildren: app ? app.children.length : 0,
|
||||
hasLoginPage: Boolean(document.querySelector('.login-page')),
|
||||
hasInput: Boolean(document.querySelector('input')),
|
||||
text: (document.body.innerText || '').replace(/\\s+/g, ' ').slice(0, 80),
|
||||
};
|
||||
})()`,
|
||||
);
|
||||
// 等到登录页真正渲染出来(而不是停在启动过渡页)
|
||||
if (dom?.hasLoginPage && dom?.hasInput) break;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
|
||||
record(
|
||||
'窗口标题正确',
|
||||
dom?.title === '班级小助手' || (dom?.title ?? '').includes('班级小助手'),
|
||||
`title=${dom?.title}`,
|
||||
);
|
||||
record(
|
||||
'登录页渲染(服务器地址/用户名/密码输入框)',
|
||||
Boolean(dom?.hasLoginPage && dom?.hasInput),
|
||||
`text=${dom?.text ?? ''}`,
|
||||
);
|
||||
|
||||
// preload 桥接
|
||||
const bridge = await win.webContents.executeJavaScript(
|
||||
`(() => ({
|
||||
hasBridge: typeof window.desktop === 'object' && window.desktop !== null,
|
||||
methods: Object.keys(window.desktop ?? {}).sort(),
|
||||
}))()`,
|
||||
);
|
||||
record(
|
||||
'preload contextBridge 注入',
|
||||
Boolean(bridge?.hasBridge) && Array.isArray(bridge?.methods) && bridge.methods.includes('getConfig'),
|
||||
`methods=${(bridge?.methods ?? []).join(',')}`,
|
||||
);
|
||||
|
||||
// IPC 往返(渲染进程 <-> 主进程)
|
||||
const ipcRoundTrip = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
const info = await window.desktop.getAppInfo();
|
||||
const saved = await window.desktop.saveConfig({ serverUrl: 'http://127.0.0.1:4000' });
|
||||
return { electron: info.electron, hasSmokeFlag: info.smokeTest === true, serverUrl: saved.serverUrl, userData: info.userDataPath };
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'IPC 调用主进程(getAppInfo / saveConfig)',
|
||||
Boolean(ipcRoundTrip?.electron) && ipcRoundTrip?.serverUrl === 'http://127.0.0.1:4000',
|
||||
`electron=${ipcRoundTrip?.electron} serverUrl=${ipcRoundTrip?.serverUrl}`,
|
||||
);
|
||||
|
||||
const diagnostics = getDiagnostics();
|
||||
record('配置文件已写入用户目录', diagnostics.config.serverUrl.length > 0, diagnostics.configPath);
|
||||
|
||||
// IndexedDB 缓存自检
|
||||
const cacheSelfTest = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.cacheSelfTest();
|
||||
})()`,
|
||||
);
|
||||
record('IndexedDB 缓存读写', Boolean(cacheSelfTest?.ok), String(cacheSelfTest?.detail ?? ''));
|
||||
|
||||
// 离线回退场景
|
||||
const offline = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.offlineScenario();
|
||||
})()`,
|
||||
);
|
||||
record('断网时回退到本地缓存', Boolean(offline?.ok), String(offline?.detail ?? ''));
|
||||
|
||||
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
|
||||
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
|
||||
const online = await win.webContents.executeJavaScript(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.onlineScenario();
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
'联网集成(登录 + 四类数据 + Socket.IO + 缓存写入)',
|
||||
Boolean(online?.ok),
|
||||
String(online?.detail ?? ''),
|
||||
);
|
||||
}
|
||||
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
const passed = results.length - failed.length;
|
||||
console.log(`\n[SMOKE] ${passed}/${results.length} 项通过`);
|
||||
|
||||
const resultFile = process.env.ELECTRON_SMOKE_RESULT;
|
||||
if (resultFile) {
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(resultFile), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
resultFile,
|
||||
JSON.stringify(
|
||||
{
|
||||
passed,
|
||||
total: results.length,
|
||||
ok: failed.length === 0,
|
||||
results,
|
||||
finishedAt: new Date().toISOString(),
|
||||
appVersion: app.getVersion(),
|
||||
packaged: app.isPackaged,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
console.log(`[SMOKE] 结果已写入 ${resultFile}`);
|
||||
} catch (error) {
|
||||
console.error('[SMOKE] 写入结果文件失败', error);
|
||||
}
|
||||
}
|
||||
|
||||
app.exit(failed.length === 0 ? 0 : 1);
|
||||
}
|
||||
Reference in new issue
Block a user