Files
ChatLab/apps/desktop/main/security/lock-manager.ts
T
xyz-225648andClaude 84b371e73d feat(security): 新增桌面端应用锁
新增 4 位 PIN、手动锁定、启动锁定和系统闲置锁定。

新增主进程与 preload 能力、桌面端设置面板、全局锁屏覆盖层及四语言界面。

Fixes #190

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-18 11:49:09 +08:00

337 lines
11 KiB
TypeScript

import { powerMonitor, type BrowserWindow } from 'electron'
import * as fs from 'node:fs'
import { join } from 'node:path'
import type {
AppLockConfig,
AppLockConfigUpdate,
AppLockResult,
AppLockState,
AppLockUnlockResult,
} from '../../shared/types'
import { logger } from '../logger'
import { getPathProvider } from '../paths/provider'
import { hashPassword, isPasswordHash, verifyPasswordHash, type PasswordHash } from './crypto-utils'
interface LockSettings {
idleTimeoutMinutes: number
lockOnStartup: boolean
}
const LOCK_CONFIG_FILE = 'app-lock.json'
const LOCK_FLAG_FILE = '.app-lock-flag'
const PIN_PATTERN = /^\d{4}$/
const IDLE_CHECK_INTERVAL_MS = 10000
const IDLE_TIMEOUT_OPTIONS = new Set([0, 1, 3, 5, 10, 15, 30, 60])
const DEFAULT_LOCK_SETTINGS: LockSettings = {
idleTimeoutMinutes: 0,
lockOnStartup: false,
}
let lockState: AppLockState = 'unlocked'
let currentSettings: LockSettings = { ...DEFAULT_LOCK_SETTINGS }
let storedPasswordHash: PasswordHash | null = null
let idleTimer: ReturnType<typeof setInterval> | null = null
let isTransitioning = false
let mainWindowRef: BrowserWindow | null = null
function getSettingsDir(): string {
return getPathProvider().getSettingsDir()
}
function getLockConfigPath(): string {
return join(getSettingsDir(), LOCK_CONFIG_FILE)
}
function getLockFlagPath(): string {
return join(getSettingsDir(), LOCK_FLAG_FILE)
}
function isValidIdleTimeout(value: unknown): value is number {
return typeof value === 'number' && IDLE_TIMEOUT_OPTIONS.has(value)
}
function loadConfig(): boolean {
currentSettings = { ...DEFAULT_LOCK_SETTINGS }
storedPasswordHash = null
try {
const configPath = getLockConfigPath()
if (!fs.existsSync(configPath)) return false
const data = JSON.parse(fs.readFileSync(configPath, 'utf-8')) as unknown
if (!data || typeof data !== 'object' || Array.isArray(data)) {
logger.warn('App lock config ignored because it is not an object')
return false
}
const record = data as Record<string, unknown>
currentSettings = {
idleTimeoutMinutes: isValidIdleTimeout(record.idleTimeoutMinutes)
? record.idleTimeoutMinutes
: DEFAULT_LOCK_SETTINGS.idleTimeoutMinutes,
lockOnStartup:
typeof record.lockOnStartup === 'boolean' ? record.lockOnStartup : DEFAULT_LOCK_SETTINGS.lockOnStartup,
}
storedPasswordHash = isPasswordHash(record.passwordHash) ? record.passwordHash : null
return true
} catch (error) {
logger.error(`App lock config ignored because it is invalid: ${error instanceof Error ? error.message : error}`)
return false
}
}
function saveConfig(settings: LockSettings, passwordHash: PasswordHash | null): boolean {
const configPath = getLockConfigPath()
try {
fs.mkdirSync(getSettingsDir(), { recursive: true })
const record: Record<string, unknown> = { ...settings }
if (passwordHash) record.passwordHash = passwordHash
fs.writeFileSync(configPath, JSON.stringify(record, null, 2), 'utf-8')
return true
} catch (error) {
logger.error(`Failed to save app lock config: ${error instanceof Error ? error.message : error}`)
return false
}
}
function setLockFlag(): boolean {
try {
fs.writeFileSync(getLockFlagPath(), Date.now().toString(), 'utf-8')
return true
} catch (error) {
logger.error(`Failed to write app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
function clearLockFlag(): boolean {
try {
const flagPath = getLockFlagPath()
if (fs.existsSync(flagPath)) fs.unlinkSync(flagPath)
return true
} catch (error) {
logger.error(`Failed to clear app lock flag: ${error instanceof Error ? error.message : error}`)
return false
}
}
function hasLockFlag(): boolean {
try {
return fs.existsSync(getLockFlagPath())
} catch {
return false
}
}
function isEnabled(): boolean {
return storedPasswordHash !== null
}
function notifyLockState(): void {
if (mainWindowRef && !mainWindowRef.isDestroyed()) {
mainWindowRef.webContents.send('app-lock-state-changed', lockState === 'locked')
}
}
export function initLockManager(mainWindow: BrowserWindow): void {
mainWindowRef = mainWindow
lockState = 'unlocked'
isTransitioning = false
const loaded = loadConfig()
if (!loaded || !isEnabled()) clearLockFlag()
const wasLocked = isEnabled() && hasLockFlag()
logger.info(`App lock initialized: enabled=${isEnabled()}, wasLocked=${wasLocked}`)
if (isEnabled() && (currentSettings.lockOnStartup || wasLocked)) {
lockApp()
} else if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) {
startIdleTimer()
}
}
export function lockApp(): AppLockResult {
if (lockState === 'locked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!isEnabled()) return { success: false, error: 'disabled' }
isTransitioning = true
try {
if (!setLockFlag()) return { success: false, error: 'save-failed' }
lockState = 'locked'
stopIdleTimer()
logger.info('App locked')
notifyLockState()
return { success: true }
} finally {
isTransitioning = false
}
}
export async function unlockApp(password: unknown): Promise<AppLockUnlockResult> {
if (lockState === 'unlocked') return { success: true }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
if (typeof password !== 'string' || !PIN_PATTERN.test(password)) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
isTransitioning = true
try {
if (!(await verifyPasswordHash(password, storedPasswordHash))) {
return { success: false, error: 'wrong-password', wrongPassword: true }
}
clearLockFlag()
lockState = 'unlocked'
logger.info('App unlocked')
notifyLockState()
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
return { success: true }
} finally {
isTransitioning = false
}
}
export async function setPassword(newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (typeof newPassword !== 'string' || !PIN_PATTERN.test(newPassword)) {
return { success: false, error: 'invalid-pin' }
}
if (storedPasswordHash) return { success: false, error: 'password-already-set' }
isTransitioning = true
try {
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
if (currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
logger.info('App lock password initialized')
return { success: true }
} catch (error) {
logger.error(`Failed to set app lock password: ${error instanceof Error ? error.message : error}`)
return { success: false, error: 'internal-error' }
} finally {
isTransitioning = false
}
}
export async function changePassword(oldPassword: unknown, newPassword: unknown): Promise<AppLockResult> {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (!storedPasswordHash) return { success: false, error: 'password-not-set' }
if (typeof oldPassword !== 'string' || typeof newPassword !== 'string' || !PIN_PATTERN.test(newPassword)) {
return { success: false, error: 'invalid-pin' }
}
if (oldPassword === newPassword) return { success: false, error: 'same-password' }
isTransitioning = true
try {
if (!(await verifyPasswordHash(oldPassword, storedPasswordHash))) {
return { success: false, error: 'wrong-password' }
}
const passwordHash = await hashPassword(newPassword)
if (!saveConfig(currentSettings, passwordHash)) return { success: false, error: 'save-failed' }
storedPasswordHash = passwordHash
logger.info('App lock password changed')
return { success: true }
} catch (error) {
logger.error(`Failed to change app lock password: ${error instanceof Error ? error.message : error}`)
return { success: false, error: 'internal-error' }
} finally {
isTransitioning = false
}
}
export function resetAppLockPassword(): AppLockResult {
if (lockState === 'locked') return { success: false, error: 'locked' }
if (isTransitioning) return { success: false, error: 'busy' }
if (!saveConfig(currentSettings, null)) return { success: false, error: 'save-failed' }
storedPasswordHash = null
stopIdleTimer()
clearLockFlag()
logger.info('App lock disabled and password cleared')
return { success: true }
}
export function getLockConfig(): AppLockConfig {
return {
enabled: isEnabled(),
...currentSettings,
}
}
function parseConfigUpdate(value: unknown): AppLockConfigUpdate | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const record = value as Record<string, unknown>
if (Object.keys(record).some((key) => key !== 'idleTimeoutMinutes' && key !== 'lockOnStartup')) return null
const updates: AppLockConfigUpdate = {}
if ('idleTimeoutMinutes' in record) {
if (!isValidIdleTimeout(record.idleTimeoutMinutes)) return null
updates.idleTimeoutMinutes = record.idleTimeoutMinutes
}
if ('lockOnStartup' in record) {
if (typeof record.lockOnStartup !== 'boolean') return null
updates.lockOnStartup = record.lockOnStartup
}
return updates
}
export function updateLockConfig(value: unknown): AppLockResult & { config?: AppLockConfig } {
if (isTransitioning) return { success: false, error: 'busy' }
const updates = parseConfigUpdate(value)
if (!updates) return { success: false, error: 'invalid-config' }
const nextSettings = { ...currentSettings, ...updates }
if (!saveConfig(nextSettings, storedPasswordHash)) return { success: false, error: 'save-failed' }
currentSettings = nextSettings
if (isEnabled() && currentSettings.idleTimeoutMinutes > 0) startIdleTimer()
else stopIdleTimer()
logger.info(`App lock config updated: ${JSON.stringify(updates)}`)
return { success: true, config: getLockConfig() }
}
export function getLockState(): AppLockState {
return lockState
}
function checkSystemIdle(): void {
if (!isEnabled() || lockState === 'locked' || currentSettings.idleTimeoutMinutes <= 0) return
try {
if (powerMonitor.getSystemIdleTime() >= currentSettings.idleTimeoutMinutes * 60) {
logger.info(`App idle timeout reached (${currentSettings.idleTimeoutMinutes} min)`)
lockApp()
}
} catch (error) {
logger.error(`Failed to read system idle time: ${error instanceof Error ? error.message : error}`)
stopIdleTimer()
}
}
function startIdleTimer(): void {
stopIdleTimer()
idleTimer = setInterval(checkSystemIdle, IDLE_CHECK_INTERVAL_MS)
checkSystemIdle()
}
function stopIdleTimer(): void {
if (!idleTimer) return
clearInterval(idleTimer)
idleTimer = null
}
export function cleanupLockManager(): void {
stopIdleTimer()
mainWindowRef = null
logger.info('App lock manager cleaned up')
}