diff --git a/electron/main.ts b/electron/main.ts index 16d26ba..6f1baea 100644 --- a/electron/main.ts +++ b/electron/main.ts @@ -20,6 +20,7 @@ import { voiceTranscribeService } from './services/voiceTranscribeService' import { videoService } from './services/videoService' import { snsService } from './services/snsService' import { contactExportService } from './services/contactExportService' +import { windowsHelloService } from './services/windowsHelloService' // 配置自动更新 @@ -798,6 +799,17 @@ function registerIpcHandlers() { return true }) + // Windows Hello + ipcMain.handle('auth:hello', async (event, message?: string) => { + // 无论哪个窗口调用,都尝试强制附着到主窗口,确保体验一致 + // 如果主窗口不存在(极其罕见),则回退到调用者窗口 + const targetWin = (mainWindow && !mainWindow.isDestroyed()) + ? mainWindow + : (BrowserWindow.fromWebContents(event.sender) || undefined) + + return windowsHelloService.verify(message, targetWin) + }) + // 导出相关 ipcMain.handle('export:exportSessions', async (event, sessionIds: string[], outputDir: string, options: ExportOptions) => { const onProgress = (progress: ExportProgress) => { diff --git a/electron/nodert.d.ts b/electron/nodert.d.ts new file mode 100644 index 0000000..0c20623 --- /dev/null +++ b/electron/nodert.d.ts @@ -0,0 +1,24 @@ +declare module '@nodert-win10-rs4/windows.security.credentials.ui' { + export enum UserConsentVerificationResult { + Verified = 0, + DeviceNotPresent = 1, + NotConfiguredForUser = 2, + DisabledByPolicy = 3, + DeviceBusy = 4, + RetriesExhausted = 5, + Canceled = 6 + } + + export enum UserConsentVerifierAvailability { + Available = 0, + DeviceNotPresent = 1, + NotConfiguredForUser = 2, + DisabledByPolicy = 3, + DeviceBusy = 4 + } + + export class UserConsentVerifier { + static checkAvailabilityAsync(callback: (err: Error | null, availability: UserConsentVerifierAvailability) => void): void; + static requestVerificationAsync(message: string, callback: (err: Error | null, result: UserConsentVerificationResult) => void): void; + } +} diff --git a/electron/preload.ts b/electron/preload.ts index 4e37c02..1698bf5 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -9,6 +9,11 @@ contextBridge.exposeInMainWorld('electronAPI', { clear: () => ipcRenderer.invoke('config:clear') }, + // 认证 + auth: { + hello: (message?: string) => ipcRenderer.invoke('auth:hello', message) + }, + // 对话框 dialog: { diff --git a/electron/services/wcdbCore.ts b/electron/services/wcdbCore.ts index b7ba5ba..14bf1ff 100644 --- a/electron/services/wcdbCore.ts +++ b/electron/services/wcdbCore.ts @@ -57,6 +57,7 @@ export class WcdbCore { private wcdbGetDbStatus: any = null private wcdbGetVoiceData: any = null private wcdbGetSnsTimeline: any = null + private wcdbVerifyUser: any = null private avatarUrlCache: Map = new Map() private readonly avatarCacheTtlMs = 10 * 60 * 1000 private logTimer: NodeJS.Timeout | null = null @@ -247,7 +248,7 @@ export class WcdbCore { // InitProtection (Added for security) try { this.wcdbInitProtection = this.lib.func('bool InitProtection(const char* resourcePath)') - + // 尝试多个可能的资源路径 const resourcePaths = [ dllDir, // DLL 所在目录 @@ -255,28 +256,28 @@ export class WcdbCore { this.resourcesPath, // 配置的资源路径 join(process.cwd(), 'resources') // 开发环境 ].filter(Boolean) - + let protectionOk = false for (const resPath of resourcePaths) { try { - console.log(`[WCDB] 尝试 InitProtection: ${resPath}`) + // console.log(`[WCDB] 尝试 InitProtection: ${resPath}`) protectionOk = this.wcdbInitProtection(resPath) if (protectionOk) { - console.log(`[WCDB] InitProtection 成功: ${resPath}`) + // console.log(`[WCDB] InitProtection 成功: ${resPath}`) break } } catch (e) { - console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) + // console.warn(`[WCDB] InitProtection 失败 (${resPath}):`, e) } } - + if (!protectionOk) { - console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') - this.writeLog('InitProtection 失败,继续运行') + // console.warn('[WCDB] Core security check failed - 继续运行但可能不稳定') + // this.writeLog('InitProtection 失败,继续运行') // 不返回 false,允许继续运行 } } catch (e) { - console.warn('InitProtection symbol not found:', e) + // console.warn('InitProtection symbol not found:', e) } // 定义类型 @@ -430,6 +431,13 @@ export class WcdbCore { this.wcdbGetSnsTimeline = null } + // void VerifyUser(int64_t hwnd_ptr, const char* message, char* out_result, int max_len) + try { + this.wcdbVerifyUser = this.lib.func('void VerifyUser(int64 hwnd, const char* message, _Out_ char* outResult, int maxLen)') + } catch { + this.wcdbVerifyUser = null + } + // 初始化 const initResult = this.wcdbInit() if (initResult !== 0) { @@ -1434,6 +1442,39 @@ export class WcdbCore { } } + /** + * 验证 Windows Hello + */ + async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> { + if (!this.initialized) { + const initOk = await this.initialize() + if (!initOk) return { success: false, error: 'WCDB 初始化失败' } + } + + if (!this.wcdbVerifyUser) { + return { success: false, error: 'Binding not found: VerifyUser' } + } + + return new Promise((resolve) => { + try { + // Allocate buffer for result JSON + const maxLen = 1024 + const outBuf = Buffer.alloc(maxLen) + + // Call native function + const hwndVal = hwnd ? BigInt(hwnd) : BigInt(0) + this.wcdbVerifyUser(hwndVal, message || '', outBuf, maxLen) + + // Parse result + const jsonStr = this.koffi.decode(outBuf, 'char', -1) + const result = JSON.parse(jsonStr) + resolve(result) + } catch (e) { + resolve({ success: false, error: String(e) }) + } + }) + } + async getSnsTimeline(limit: number, offset: number, usernames?: string[], keyword?: string, startTime?: number, endTime?: number): Promise<{ success: boolean; timeline?: any[]; error?: string }> { if (!this.ensureReady()) return { success: false, error: 'WCDB 未连接' } if (!this.wcdbGetSnsTimeline) return { success: false, error: '当前 DLL 版本不支持获取朋友圈' } diff --git a/electron/services/wcdbService.ts b/electron/services/wcdbService.ts index 4a1d76c..b885088 100644 --- a/electron/services/wcdbService.ts +++ b/electron/services/wcdbService.ts @@ -369,6 +369,13 @@ export class WcdbService { return this.callWorker('getSnsTimeline', { limit, offset, usernames, keyword, startTime, endTime }) } + /** + * 验证 Windows Hello + */ + async verifyUser(message: string, hwnd?: string): Promise<{ success: boolean; error?: string }> { + return this.callWorker('verifyUser', { message, hwnd }) + } + } export const wcdbService = new WcdbService() diff --git a/electron/services/windowsHelloService.ts b/electron/services/windowsHelloService.ts new file mode 100644 index 0000000..00d44e7 --- /dev/null +++ b/electron/services/windowsHelloService.ts @@ -0,0 +1,32 @@ +import { wcdbService } from './wcdbService' +import { BrowserWindow } from 'electron' + +export class WindowsHelloService { + private verificationPromise: Promise<{ success: boolean; error?: string }> | null = null + + /** + * 验证 Windows Hello + * @param message 提示信息 + */ + async verify(message: string = '请验证您的身份以解锁 WeFlow', targetWindow?: BrowserWindow): Promise<{ success: boolean; error?: string }> { + // Prevent concurrent verification requests + if (this.verificationPromise) { + return this.verificationPromise + } + + // 获取窗口句柄: 优先使用传入的窗口,否则尝试获取焦点窗口,最后兜底主窗口 + const window = targetWindow || BrowserWindow.getFocusedWindow() || BrowserWindow.getAllWindows()[0] + const hwndBuffer = window?.getNativeWindowHandle() + // Convert buffer to int string for transport + const hwndStr = hwndBuffer ? BigInt('0x' + hwndBuffer.toString('hex')).toString() : undefined + + this.verificationPromise = wcdbService.verifyUser(message, hwndStr) + .finally(() => { + this.verificationPromise = null + }) + + return this.verificationPromise + } +} + +export const windowsHelloService = new WindowsHelloService() diff --git a/electron/wcdbWorker.ts b/electron/wcdbWorker.ts index 64f001d..b03d49a 100644 --- a/electron/wcdbWorker.ts +++ b/electron/wcdbWorker.ts @@ -119,6 +119,9 @@ if (parentPort) { case 'getSnsTimeline': result = await core.getSnsTimeline(payload.limit, payload.offset, payload.usernames, payload.keyword, payload.startTime, payload.endTime) break + case 'verifyUser': + result = await core.verifyUser(payload.message, payload.hwnd) + break default: result = { success: false, error: `Unknown method: ${type}` } } diff --git a/package-lock.json b/package-lock.json index abbf9b6..6a7f552 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6,9 +6,10 @@ "packages": { "": { "name": "weflow", - "version": "1.4.1", + "version": "1.4.2", "hasInstallScript": true, "dependencies": { + "@nodert-win10-rs4/windows.security.credentials.ui": "^0.4.4", "better-sqlite3": "^12.5.0", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", @@ -1948,6 +1949,16 @@ "node": ">= 10.0.0" } }, + "node_modules/@nodert-win10-rs4/windows.security.credentials.ui": { + "version": "0.4.4", + "resolved": "https://registry.npmmirror.com/@nodert-win10-rs4/windows.security.credentials.ui/-/windows.security.credentials.ui-0.4.4.tgz", + "integrity": "sha512-P+EsJw5MCQXTxp7mwXfNDvIzIYsB6ple+HNg01QjPWg/PJfAodPuxL6XM7l0sPtYHsDYnfnvoefZMdZRa2Z1ig==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "nan": "latest" + } + }, "node_modules/@npmcli/agent": { "version": "3.0.0", "resolved": "https://registry.npmmirror.com/@npmcli/agent/-/agent-3.0.0.tgz", @@ -7380,6 +7391,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/nan": { + "version": "2.25.0", + "resolved": "https://registry.npmmirror.com/nan/-/nan-2.25.0.tgz", + "integrity": "sha512-0M90Ag7Xn5KMLLZ7zliPWP3rT90P6PN+IzVFS0VqmnPktBk3700xUVv8Ikm9EUaUE5SDWdp/BIxdENzVznpm1g==", + "license": "MIT" + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz", diff --git a/package.json b/package.json index 3163e57..ae8d645 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "//": "二改不应改变此处的作者与应用信息", "scripts": { "postinstall": "echo 'No native modules to rebuild'", - "rebuild": "echo 'No native modules to rebuild'", + "rebuild": "electron-rebuild", "dev": "vite", "build": "tsc && vite build && electron-builder", "preview": "vite preview", @@ -15,6 +15,7 @@ "electron:build": "npm run build" }, "dependencies": { + "@nodert-win10-rs4/windows.security.credentials.ui": "^0.4.4", "better-sqlite3": "^12.5.0", "echarts": "^5.5.1", "echarts-for-react": "^3.0.2", diff --git a/resources/wcdb_api.dll b/resources/wcdb_api.dll index 3b3614f..c4233c0 100644 Binary files a/resources/wcdb_api.dll and b/resources/wcdb_api.dll differ diff --git a/src/App.tsx b/src/App.tsx index 90f8562..df67e50 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,7 +12,6 @@ import AnnualReportPage from './pages/AnnualReportPage' import AnnualReportWindow from './pages/AnnualReportWindow' import AgreementPage from './pages/AgreementPage' import GroupAnalyticsPage from './pages/GroupAnalyticsPage' -import DataManagementPage from './pages/DataManagementPage' import SettingsPage from './pages/SettingsPage' import ExportPage from './pages/ExportPage' import VideoWindow from './pages/VideoWindow' @@ -43,7 +42,9 @@ function App() { setDownloadProgress, showUpdateDialog, setShowUpdateDialog, - setUpdateError + setUpdateError, + isLocked, + setLocked } = useAppStore() const { currentTheme, themeMode, setTheme, setThemeMode } = useThemeStore() @@ -54,8 +55,10 @@ function App() { const [themeHydrated, setThemeHydrated] = useState(false) // 锁定状态 - const [isLocked, setIsLocked] = useState(false) - const [lockAvatar, setLockAvatar] = useState(undefined) + // const [isLocked, setIsLocked] = useState(false) // Moved to store + const [lockAvatar, setLockAvatar] = useState( + localStorage.getItem('app_lock_avatar') || undefined + ) const [lockUseHello, setLockUseHello] = useState(false) // 协议同意状态 @@ -174,7 +177,7 @@ function App() { setShowUpdateDialog(true) } }) - const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress) => { + const removeProgressListener = window.electronAPI.app.onDownloadProgress?.((progress: any) => { setDownloadProgress(progress) }) return () => { @@ -271,12 +274,13 @@ function App() { if (enabled) { setLockUseHello(useHello) - setIsLocked(true) + setLocked(true) // 尝试获取头像 try { const result = await window.electronAPI.chat.getMyAvatarUrl() if (result && result.success && result.avatarUrl) { setLockAvatar(result.avatarUrl) + localStorage.setItem('app_lock_avatar', result.avatarUrl) } } catch (e) { console.error('获取锁屏头像失败', e) @@ -310,7 +314,7 @@ function App() {
{isLocked && ( setIsLocked(false)} + onUnlock={() => setLocked(false)} avatar={lockAvatar} useHello={lockUseHello} /> @@ -394,7 +398,7 @@ function App() { } /> } /> } /> - } /> + } /> } /> } /> diff --git a/src/components/LockScreen.tsx b/src/components/LockScreen.tsx index 88b74fb..94d5701 100644 --- a/src/components/LockScreen.tsx +++ b/src/components/LockScreen.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from 'react' import * as configService from '../services/config' -import { ArrowRight, Fingerprint, Lock, ShieldCheck } from 'lucide-react' +import { ArrowRight, Fingerprint, Lock, ScanFace, ShieldCheck } from 'lucide-react' import './LockScreen.scss' interface LockScreenProps { @@ -63,18 +63,6 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS setShowHello(true) // 立即执行验证 (0延迟) verifyHello() - - // 后台再次确认可用性,如果其实不可用,再隐藏? - // 或者信任用户的配置。为了速度,我们优先信任配置。 - if (window.PublicKeyCredential) { - PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable() - .then(available => { - if (!available) { - // 如果系统报告不支持,但配置开了,我们可能需要提示? - // 暂时保持开启状态,反正 verifyHello 会报错 - } - }) - } } } catch (e) { console.error('Quick start hello failed', e) @@ -84,51 +72,23 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS const verifyHello = async () => { if (isVerifying || isUnlocked) return - // 取消之前的请求(如果有) - if (abortControllerRef.current) { - abortControllerRef.current.abort() - } - - const abortController = new AbortController() - abortControllerRef.current = abortController - setIsVerifying(true) setError('') + try { - const challenge = new Uint8Array(32) - window.crypto.getRandomValues(challenge) + const result = await window.electronAPI.auth.hello() - const rpId = 'localhost' - const credential = await navigator.credentials.get({ - publicKey: { - challenge, - rpId, - userVerification: 'required', - }, - signal: abortController.signal - }) - - if (credential) { + if (result.success) { handleUnlock() + } else { + console.error('Hello verification failed:', result.error) + setError(result.error || '验证失败') } } catch (e: any) { - if (e.name === 'AbortError') { - console.log('Hello verification aborted') - return - } - if (e.name === 'NotAllowedError') { - console.log('User cancelled Hello verification') - } else { - console.error('Hello verification error:', e) - // 仅在非手动取消时显示错误 - if (e.name !== 'AbortError') { - setError(`验证失败: ${e.message || e.name}`) - } - } + console.error('Hello verification error:', e) + setError(`验证失败: ${e.message || String(e)}`) } finally { - if (!abortController.signal.aborted) { - setIsVerifying(false) - } + setIsVerifying(false) } } @@ -136,11 +96,8 @@ export default function LockScreen({ onUnlock, avatar, useHello = false }: LockS e?.preventDefault() if (!password || isUnlocked) return - // 如果正在进行 Hello 验证,取消它 - if (abortControllerRef.current) { - abortControllerRef.current.abort() - abortControllerRef.current = null - } + // 如果正在进行 Hello 验证,它会自动失败或被取代,UI上不用特意取消 + // 因为 native 调用是模态的或者独立的,我们只要让 JS 状态不对锁住即可 // 不再检查 isVerifying,因为我们允许打断 Hello setIsVerifying(true) diff --git a/src/components/RouteGuard.tsx b/src/components/RouteGuard.tsx index cd775c5..d3f8a05 100644 --- a/src/components/RouteGuard.tsx +++ b/src/components/RouteGuard.tsx @@ -6,8 +6,7 @@ interface RouteGuardProps { children: React.ReactNode } -// 不需要数据库连接的页面 -const PUBLIC_ROUTES = ['/', '/home', '/settings', '/data-management'] +const PUBLIC_ROUTES = ['/', '/home', '/settings'] function RouteGuard({ children }: RouteGuardProps) { const navigate = useNavigate() diff --git a/src/components/Sidebar.scss b/src/components/Sidebar.scss index 671372a..6899c93 100644 --- a/src/components/Sidebar.scss +++ b/src/components/Sidebar.scss @@ -76,7 +76,7 @@ } .sidebar-footer { - padding: 0 8px; + padding: 0 12px; border-top: 1px solid var(--border-color); padding-top: 12px; margin-top: 8px; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d3890ca..9a44b61 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,11 +1,19 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { NavLink, useLocation } from 'react-router-dom' -import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle } from 'lucide-react' +import { Home, MessageSquare, BarChart3, Users, FileText, Database, Settings, ChevronLeft, ChevronRight, Download, Bot, Aperture, UserCircle, Lock } from 'lucide-react' +import { useAppStore } from '../stores/appStore' +import * as configService from '../services/config' import './Sidebar.scss' function Sidebar() { const location = useLocation() const [collapsed, setCollapsed] = useState(false) + const [authEnabled, setAuthEnabled] = useState(false) + const setLocked = useAppStore(state => state.setLocked) + + useEffect(() => { + configService.getAuthEnabled().then(setAuthEnabled) + }, []) const isActive = (path: string) => { return location.pathname === path || location.pathname.startsWith(`${path}/`) @@ -94,18 +102,21 @@ function Sidebar() { 导出 - {/* 数据管理 */} - - - 数据管理 - +
+ {authEnabled && ( + + )} + (null) - const [wxid, setWxid] = useState(null) - - useEffect(() => { - const loadConfig = async () => { - const [path, id] = await Promise.all([ - configService.getDbPath(), - configService.getMyWxid() - ]) - setDbPath(path) - setWxid(id) - } - loadConfig() - const handleChange = () => { - loadConfig() - } - window.addEventListener('wxid-changed', handleChange as EventListener) - return () => window.removeEventListener('wxid-changed', handleChange as EventListener) - }, []) - - return ( - <> -
-

数据管理

-
- -
-
-
-
-

WCDB 直连模式

-

- 当前版本通过 WCDB DLL 直接读取加密数据库,不再需要解密流程。 -

-
-
- -
-
-
-
- 数据库目录 -
-
{dbPath || '未配置'}
-
-
-
-
-
- 微信ID -
-
{wxid || '未配置'}
-
-
-
-
-
- - ) -} - -export default DataManagementPage diff --git a/src/stores/appStore.ts b/src/stores/appStore.ts index 3d33689..d5e5152 100644 --- a/src/stores/appStore.ts +++ b/src/stores/appStore.ts @@ -33,6 +33,10 @@ export interface AppState { setShowUpdateDialog: (show: boolean) => void setUpdateError: (error: string | null) => void + // 锁定状态 + isLocked: boolean + setLocked: (locked: boolean) => void + reset: () => void } @@ -42,6 +46,7 @@ export const useAppStore = create((set) => ({ myWxid: null, isLoading: false, loadingText: '', + isLocked: false, // 更新状态初始化 updateInfo: null, @@ -62,6 +67,8 @@ export const useAppStore = create((set) => ({ loadingText: text ?? '' }), + setLocked: (locked) => set({ isLocked: locked }), + setUpdateInfo: (info) => set({ updateInfo: info, updateError: null }), setIsDownloading: (isDownloading) => set({ isDownloading: isDownloading }), setDownloadProgress: (progress) => set({ downloadProgress: progress }), @@ -74,6 +81,7 @@ export const useAppStore = create((set) => ({ myWxid: null, isLoading: false, loadingText: '', + isLocked: false, updateInfo: null, isDownloading: false, downloadProgress: { percent: 0 }, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..b1ee881 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,14 @@ /// + +interface Window { + electronAPI: { + // ... other methods ... + auth: { + hello: (message?: string) => Promise<{ success: boolean; error?: string }> + } + // For brevity, using 'any' for other parts or properly importing types if available. + // In a real scenario, you'd likely want to keep the full interface definition consistent with preload.ts + // or import a shared type definition. + [key: string]: any + } +}