feat: polish macos window and auth behavior

This commit is contained in:
ILoveBinglu
2026-04-06 22:00:46 +08:00
parent 6f9958c1fb
commit 252d2aa487
17 changed files with 519 additions and 168 deletions
@@ -9,6 +9,59 @@
align-items: center;
justify-content: center;
z-index: 1000;
&.is-mac {
inset: auto 20px auto auto;
top: 54px;
left: auto;
right: 20px;
bottom: auto;
background: transparent;
align-items: flex-start;
justify-content: flex-end;
pointer-events: none;
.decrypt-card {
min-width: 280px;
max-width: 360px;
padding: 18px 20px;
text-align: left;
border-radius: 18px;
border: 1px solid color-mix(in srgb, var(--primary) 20%, var(--border-color) 80%);
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.16);
backdrop-filter: blur(18px);
pointer-events: auto;
}
.decrypt-title {
font-size: 15px;
margin-bottom: 14px;
}
.progress-ring-container {
width: 72px;
height: 72px;
margin: 0 0 12px;
}
.progress-percent {
font-size: 16px;
}
.decrypt-database {
font-size: 12px;
margin-bottom: 6px;
}
.decrypt-detail {
font-size: 12px;
margin-bottom: 10px;
}
.decrypt-hint {
font-size: 11px;
}
}
}
.decrypt-card {
+14 -3
View File
@@ -1,15 +1,26 @@
import { useEffect, useState } from 'react'
import { useAppStore } from '../stores/appStore'
import './DecryptProgressOverlay.scss'
function DecryptProgressOverlay() {
const { isDecrypting, decryptingDatabase, decryptProgress, decryptTotal } = useAppStore()
const [platform, setPlatform] = useState<'win32' | 'darwin' | 'linux'>('win32')
useEffect(() => {
void window.electronAPI.app.getPlatformInfo().then((info) => {
setPlatform((info.platform as 'win32' | 'darwin' | 'linux') || 'win32')
}).catch(() => {
// ignore
})
}, [])
const percent = decryptTotal > 0 ? Math.round((decryptProgress / decryptTotal) * 100) : 0
const isMac = platform === 'darwin'
if (!isDecrypting) return null
const percent = decryptTotal > 0 ? Math.round((decryptProgress / decryptTotal) * 100) : 0
return (
<div className="decrypt-overlay">
<div className={`decrypt-overlay ${isMac ? 'is-mac' : 'is-default'}`}>
<div className="decrypt-card">
<h2 className="decrypt-title"></h2>
+65 -3
View File
@@ -1,20 +1,44 @@
.title-bar {
height: 41px;
background: var(--bg-secondary);
display: flex;
display: grid;
grid-template-columns: minmax(140px, 1fr) auto minmax(140px, 1fr);
align-items: center;
justify-content: space-between;
padding-left: 16px;
padding-right: 144px; // 为窗口控件留空间
border-bottom: 1px solid var(--border-color);
-webkit-app-region: drag;
flex-shrink: 0;
&.is-mac {
padding-left: 84px;
padding-right: 16px;
}
}
.title-bar-left {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
}
.title-bar-center {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
min-width: 0;
.titles {
max-width: 360px;
}
}
.title-bar-traffic-spacer {
width: 68px;
height: 12px;
flex-shrink: 0;
}
.update-status {
@@ -53,6 +77,9 @@
.title-bar-right {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
min-width: 0;
-webkit-app-region: no-drag;
}
@@ -72,6 +99,21 @@
max-width: 600px;
}
.title-bar.is-mac {
.title-bar-left {
justify-content: flex-start;
}
.title-bar-right {
overflow: hidden;
}
.update-status {
margin-left: 0;
max-width: 280px;
}
}
// 导出页面标签切换
.export-tabs {
@@ -82,6 +124,26 @@
border-radius: 999px;
}
@media (max-width: 960px) {
.title-bar {
grid-template-columns: minmax(90px, 1fr) auto minmax(90px, 1fr);
}
.title-bar.is-mac {
padding-left: 76px;
}
.title-bar.is-mac .title-bar-center .titles {
max-width: 220px;
}
.update-status {
.update-text {
display: none;
}
}
}
.export-tab {
display: flex;
align-items: center;
@@ -109,4 +171,4 @@
svg {
flex-shrink: 0;
}
}
}
+39 -16
View File
@@ -1,4 +1,4 @@
import { ReactNode, useEffect } from 'react'
import { ReactNode, useEffect, useState } from 'react'
import { RefreshCw } from 'lucide-react'
import { useTitleBarStore } from '../stores/titleBarStore'
import { useUpdateStatusStore } from '../stores/updateStatusStore'
@@ -15,6 +15,7 @@ function TitleBar({ rightContent, title }: TitleBarProps) {
const displayContent = rightContent ?? storeRightContent
const isUpdating = useUpdateStatusStore(state => state.isUpdating)
const appIcon = useThemeStore(state => state.appIcon)
const [platform, setPlatform] = useState<'win32' | 'darwin' | 'linux'>('win32')
// 调试:检查状态
useEffect(() => {
@@ -23,27 +24,49 @@ function TitleBar({ rightContent, title }: TitleBarProps) {
}
}, [isUpdating])
useEffect(() => {
void window.electronAPI.app.getPlatformInfo().then((info) => {
setPlatform((info.platform as 'win32' | 'darwin' | 'linux') || 'win32')
}).catch(() => {
// ignore
})
}, [])
const isMac = platform === 'darwin'
const updateStatusNode = isUpdating ? (
<div className="update-status">
<RefreshCw
className="update-indicator"
size={16}
strokeWidth={2.5}
/>
<span className="update-text">...</span>
</div>
) : null
return (
<div className="title-bar">
<div className={`title-bar ${isMac ? 'is-mac' : 'is-win'}`}>
<div className="title-bar-left">
<img src={appIcon === 'xinnian' ? "./xinnian.png" : "./logo.png"} alt="密语" className="title-logo" />
<span className="titles">{title || 'CipherTalk'}</span>
{isUpdating && (
<div className="update-status">
<RefreshCw
className="update-indicator"
size={16}
strokeWidth={2.5}
/>
<span className="update-text">...</span>
</div>
{isMac ? (
<div className="title-bar-traffic-spacer" aria-hidden="true" />
) : (
<>
<img src={appIcon === 'xinnian' ? "./xinnian.png" : "./logo.png"} alt="密语" className="title-logo" />
<span className="titles">{title || 'CipherTalk'}</span>
{updateStatusNode}
</>
)}
</div>
{displayContent && (
<div className="title-bar-right">
{displayContent}
{isMac && (
<div className="title-bar-center">
<img src={appIcon === 'xinnian' ? "./xinnian.png" : "./logo.png"} alt="密语" className="title-logo" />
<span className="titles">{title || 'CipherTalk'}</span>
</div>
)}
<div className="title-bar-right">
{isMac && updateStatusNode}
{displayContent}
</div>
</div>
)
}
+2 -2
View File
@@ -901,7 +901,7 @@
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
z-index: 1400;
}
.export-progress-modal {
@@ -1118,4 +1118,4 @@
}
}
}
}
}
+8 -1
View File
@@ -10,8 +10,15 @@ export default function LockScreen() {
const { unlock, verifyPassword, authMethod } = useAuthStore()
const [isVerifying, setIsVerifying] = useState(false)
const [error, setError] = useState('')
const [platformInfo, setPlatformInfo] = useState<{ platform: string; arch: string }>({ platform: 'win32', arch: 'x64' })
const hasInvokedRef = useRef(false)
useEffect(() => {
void window.electronAPI.app.getPlatformInfo().then(setPlatformInfo).catch(() => {
// ignore
})
}, [])
useEffect(() => {
// 自动触发一次验证 (仅当生物识别时)
if (authMethod === 'biometric' && !hasInvokedRef.current) {
@@ -87,7 +94,7 @@ export default function LockScreen() {
disabled={isVerifying}
>
<Fingerprint size={20} />
{isVerifying ? '正在验证...' : '使用 Windows Hello 解锁'}
{isVerifying ? '正在验证...' : platformInfo.platform === 'darwin' ? '使用 Touch ID 解锁' : '使用 Windows Hello 解锁'}
</button>
) : (
<form className="password-form" onSubmit={handlePasswordUnlock}>
+33 -37
View File
@@ -195,6 +195,7 @@ function SettingsPage() {
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [initialConfig, setInitialConfig] = useState<any>(null)
const isMac = platformInfo.platform === 'darwin'
const biometricLabel = isMac ? 'Touch ID' : 'Windows Hello'
useEffect(() => {
loadConfig()
@@ -2381,11 +2382,6 @@ function SettingsPage() {
const handleSecurityMethodSelect = async (method: 'biometric' | 'password') => {
if (method === 'biometric' && isMac) {
showMessage('当前平台不支持 Windows Hello,请改用自定义密码。', false)
return
}
// 1. 如果点击的是当前已激活的方法 -> 关闭
if (isAuthEnabled && authMethod === method) {
await disableAuth()
@@ -2403,7 +2399,7 @@ function SettingsPage() {
show: true,
title: '切换认证方式',
message: method === 'biometric'
? '切换到 Windows Hello 将清除当前的密码设置,是否继续?'
? `切换到${biometricLabel}将清除当前的密码设置,是否继续?`
: '切换到密码认证将清除当前的生物识别设置,是否继续?',
onConfirm: async () => {
await disableAuth()
@@ -2427,10 +2423,10 @@ function SettingsPage() {
}
const activateBiometric = async () => {
showMessage('正在等待 Windows Hello 验证...', true)
showMessage(`正在等待${biometricLabel}验证...`, true)
const result = await enableAuth()
if (result.success) {
showMessage('已启用 Windows Hello', true)
showMessage(`已启用${biometricLabel}`, true)
setShowPasswordInput(false)
} else {
showMessage(result.error || '启用失败', false)
@@ -2441,42 +2437,42 @@ function SettingsPage() {
<div className="tab-content">
<h3 className="section-title"></h3>
<div className="section-desc">
{isMac ? 'macOS 当前仅支持自定义应用密码。Windows Hello 在此平台直接禁用。' : '配置应用启动时的安全验证方式,保护您的隐私数据。'}
{isMac ? '配置应用启动时的安全验证方式。macOS 优先使用 Touch ID,设备不支持时可改用自定义密码。' : '配置应用启动时的安全验证方式,保护您的隐私数据。'}
</div>
<div className="security-grid">
{!isMac && (
<div
className={`security-card ${isAuthEnabled && authMethod === 'biometric' ? 'active' : ''}`}
onClick={() => handleSecurityMethodSelect('biometric')}
style={{ cursor: 'pointer' }}
>
<div className="security-preview-area">
<div className="preview-lock-screen">
<div className="preview-avatar">
<Lock size={20} />
</div>
<div className="preview-badge">
<Fingerprint /> Windows Hello
</div>
<div className="preview-btn" />
<div
className={`security-card ${isAuthEnabled && authMethod === 'biometric' ? 'active' : ''}`}
onClick={() => handleSecurityMethodSelect('biometric')}
style={{ cursor: 'pointer' }}
>
<div className="security-preview-area">
<div className="preview-lock-screen">
<div className="preview-avatar">
<Lock size={20} />
</div>
</div>
<div className="security-content">
<div className="security-header">
<span className="security-title">Windows Hello</span>
{isAuthEnabled && authMethod === 'biometric' && (
<div className="theme-check" style={{ position: 'relative', top: 0, right: 0, transform: 'scale(1)', background: 'var(--primary)', boxShadow: 'none' }}>
<Check size={12} />
</div>
)}
</div>
<div className="security-desc">
使 PIN
<div className="preview-badge">
<Fingerprint /> {biometricLabel}
</div>
<div className="preview-btn" />
</div>
</div>
)}
<div className="security-content">
<div className="security-header">
<span className="security-title">{biometricLabel}</span>
{isAuthEnabled && authMethod === 'biometric' && (
<div className="theme-check" style={{ position: 'relative', top: 0, right: 0, transform: 'scale(1)', background: 'var(--primary)', boxShadow: 'none' }}>
<Check size={12} />
</div>
)}
</div>
<div className="security-desc">
{isMac
? '使用 macOS 系统 Touch ID 进行验证。设备未启用或不支持时,请改用自定义密码。'
: '使用系统的面部识别、指纹或 PIN 码进行验证。体验最流畅,安全性高。'}
</div>
</div>
</div>
{/* Custom Password Card */}
<div
+64 -69
View File
@@ -66,6 +66,7 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
})
const isMac = platformInfo.platform === 'darwin'
const biometricLabel = isMac ? 'Touch ID' : 'Windows Hello'
useEffect(() => {
const removeStatus = window.electronAPI.wxKey?.onStatus?.((payload) => {
@@ -833,12 +834,20 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'security' && (
<div className="info-content">
<h3></h3>
<p>{isMac ? '当前向导不提供 macOS 系统应用锁,后续可在设置中改用自定义密码。' : '为应用添加额外的安全保护(可选)。'}</p>
<p></p>
{isMac ? (
<div className="info-warning">
<ShieldCheck size={16} />
<span>Windows Hello Windows macOS </span>
</div>
<>
<ul className="info-list">
<li></li>
<li>使 macOS Touch ID </li>
<li></li>
<li></li>
</ul>
<div className="info-warning" style={{ background: 'rgba(76, 175, 80, 0.1)', color: '#4CAF50' }}>
<ShieldCheck size={16} />
<span></span>
</div>
</>
) : (
<>
<ul className="info-list">
@@ -1077,76 +1086,62 @@ function WelcomePage({ standalone = false }: WelcomePageProps) {
{currentStep.id === 'security' && (
<div className="setup-body">
{isMac ? (
<div className="auth-setup-card">
<div className="auth-icon-large">
<Lock size={48} />
</div>
<h3></h3>
<p className="auth-desc">
macOS Windows Hello
<br />
使
</p>
<div className="auth-setup-card">
<div className="auth-icon-large">
{isMac ? <Lock size={48} /> : <Fingerprint size={48} />}
</div>
) : (
<div className="auth-setup-card">
<div className="auth-icon-large">
<Fingerprint size={48} />
</div>
<h3>Windows Hello </h3>
<p className="auth-desc">
Windows Hello
<br />
PIN
</p>
<h3>{biometricLabel} </h3>
<p className="auth-desc">
{isMac ? '启用 Touch ID 以保护您的数据。' : '启用 Windows Hello 以保护您的数据。'}
<br />
{isMac ? '启用后,每次打开应用都需要进行系统 Touch ID 验证。' : '启用后,每次打开应用都需要进行生物识别或 PIN 码验证。'}
</p>
<div className="auth-actions">
{!isAuthEnabled ? (
<button
className="btn btn-primary"
onClick={async () => {
setIsEnablingAuth(true)
setAuthStatus('正在等待 Windows Hello 验证...')
const result = await enableAuth()
setIsEnablingAuth(false)
if (result.success) {
setAuthStatus('已成功启用认证保护')
} else {
setError(result.error || '启用失败')
setAuthStatus('')
}
}}
disabled={isEnablingAuth}
>
{isEnablingAuth ? '正在配置...' : '启用应用锁'}
</button>
) : (
<div className="auth-success-state">
<div className="success-badge">
<CheckCircle2 size={16} />
<span></span>
</div>
<button
className="btn btn-text-danger"
onClick={async () => {
await disableAuth()
setAuthStatus('')
}}
>
</button>
<div className="auth-actions">
{!isAuthEnabled ? (
<button
className="btn btn-primary"
onClick={async () => {
setIsEnablingAuth(true)
setAuthStatus(`正在等待${biometricLabel}验证...`)
const result = await enableAuth()
setIsEnablingAuth(false)
if (result.success) {
setAuthStatus('已成功启用认证保护')
} else {
setError(result.error || '启用失败')
setAuthStatus('')
}
}}
disabled={isEnablingAuth}
>
{isEnablingAuth ? '正在配置...' : '启用应用锁'}
</button>
) : (
<div className="auth-success-state">
<div className="success-badge">
<CheckCircle2 size={16} />
<span></span>
</div>
)}
</div>
{authStatus && (
<div className="auth-status-text">
{authStatus}
<button
className="btn btn-text-danger"
onClick={async () => {
await disableAuth()
setAuthStatus('')
}}
>
</button>
</div>
)}
</div>
)}
{authStatus && (
<div className="auth-status-text">
{authStatus}
</div>
)}
</div>
</div>
)}
+46 -32
View File
@@ -34,6 +34,12 @@ async function isWindowsPlatform(): Promise<boolean> {
}
}
function isNativeCredential(credentialId: string | null): boolean {
return credentialId === 'native-windows-hello'
|| credentialId === 'native-macos-touchid'
|| credentialId === 'native-system-auth'
}
// WebAuthn 错误消息映射
function getFriendlyErrorMessage(error: any): string {
const msg = error.message || ''
@@ -54,6 +60,9 @@ function getFriendlyErrorMessage(error: any): string {
if (msg.includes('The user aborted a request')) {
return '用户取消了操作'
}
if (msg.includes('Touch ID')) {
return msg
}
return msg || '认证过程发生未知错误'
}
@@ -99,33 +108,38 @@ export const useAuthStore = create<AuthState>((set, get) => ({
enableAuth: async () => {
try {
if (!(await isWindowsPlatform())) {
return { success: false, error: '当前平台不支持 Windows Hello 应用锁,请改用自定义密码。' }
const systemAuth = window.electronAPI?.systemAuth
const systemStatus = systemAuth ? await systemAuth.getStatus() : null
if (systemStatus?.available) {
const result = await systemAuth.verify(
systemStatus.platform === 'darwin'
? '请验证您的身份以启用 Touch ID 保护'
: '请验证您的身份以启用 Windows Hello 保护'
)
if (result.success) {
const credentialId = systemStatus.platform === 'darwin'
? 'native-macos-touchid'
: 'native-windows-hello'
set({
isAuthEnabled: true,
credentialId,
authMethod: 'biometric'
})
await configService.setAuthEnabled(true)
await configService.setAuthCredentialId(credentialId)
await configService.setAuthPasswordHash(null)
await configService.setAuthPasswordSalt(null)
return { success: true }
}
return { success: false, error: result.error || '验证失败' }
}
// 优先尝试使用原生 Windows Hello DLL (更快)
if (window.electronAPI?.windowsHello) {
const available = await window.electronAPI.windowsHello.isAvailable()
if (available) {
// 使用原生 API 进行首次验证
const result = await window.electronAPI.windowsHello.verify('请验证您的身份以启用 Windows Hello 保护')
if (result.success) {
// 保存状态 (使用简单标记,原生 API 不需要 credential ID)
set({
isAuthEnabled: true,
credentialId: 'native-windows-hello',
authMethod: 'biometric'
})
await configService.setAuthEnabled(true)
await configService.setAuthCredentialId('native-windows-hello')
// 清除密码配置,确保互斥
await configService.setAuthPasswordHash(null)
await configService.setAuthPasswordSalt(null)
return { success: true }
} else {
return { success: false, error: result.error || '验证失败' }
}
}
if (!(await isWindowsPlatform())) {
return { success: false, error: systemStatus?.error || '当前设备不支持系统验证,请改用自定义密码。' }
}
// 回退到 WebAuthn (兼容性)
@@ -234,13 +248,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (!credentialId) return { success: false, error: '未找到凭证' }
try {
if (!(await isWindowsPlatform()) && credentialId === 'native-windows-hello') {
return { success: false, error: '当前平台不支持 Windows Hello 解锁' }
}
// 优先使用原生 Windows Hello DLL (更快)
if (credentialId === 'native-windows-hello' && window.electronAPI?.windowsHello) {
const result = await window.electronAPI.windowsHello.verify('请验证您的身份以解锁 CipherTalk')
if (isNativeCredential(credentialId) && window.electronAPI?.systemAuth) {
const systemStatus = await window.electronAPI.systemAuth.getStatus()
const result = await window.electronAPI.systemAuth.verify(
systemStatus.platform === 'darwin'
? '请验证您的身份以通过 Touch ID 解锁 CipherTalk'
: '请验证您的身份以解锁 CipherTalk'
)
if (result.success) {
set({
isLocked: false,
+14
View File
@@ -288,6 +288,20 @@ export interface ElectronAPI {
error?: string
}>
}
systemAuth: {
getStatus: () => Promise<{
platform: string
available: boolean
method: 'windows-hello' | 'touch-id' | 'none'
displayName: string
error?: string
}>
verify: (reason?: string) => Promise<{
success: boolean
method: 'windows-hello' | 'touch-id' | 'none'
error?: string
}>
}
wxKey: {
isWeChatRunning: () => Promise<boolean>
getWeChatPid: () => Promise<number | null>