From 84b371e73d64852efe18d69022321d72af46b9d2 Mon Sep 17 00:00:00 2001 From: xyz-225648 <3352675144@qq.com> Date: Fri, 17 Jul 2026 00:53:49 +0800 Subject: [PATCH] =?UTF-8?q?feat(security):=20=E6=96=B0=E5=A2=9E=E6=A1=8C?= =?UTF-8?q?=E9=9D=A2=E7=AB=AF=E5=BA=94=E7=94=A8=E9=94=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 4 位 PIN、手动锁定、启动锁定和系统闲置锁定。 新增主进程与 preload 能力、桌面端设置面板、全局锁屏覆盖层及四语言界面。 Fixes #190 Co-Authored-By: Claude --- apps/desktop/main/index.ts | 7 + apps/desktop/main/ipc/index.ts | 2 + apps/desktop/main/ipc/security.ts | 25 ++ apps/desktop/main/security/crypto-utils.ts | 52 +++ apps/desktop/main/security/lock-manager.ts | 336 ++++++++++++++++++ apps/desktop/preload/apis/security.ts | 18 + apps/desktop/preload/index.d.ts | 4 +- apps/desktop/preload/index.ts | 4 + apps/desktop/shared/types.ts | 47 +++ src/App.vue | 8 +- .../common/Settings/SecuritySettingsTab.vue | 323 +++++++++++++++++ src/components/common/SettingsModal.vue | 16 +- src/components/lock-screen/LockScreen.vue | 131 +++++++ src/i18n/locales/en-US/settings.json | 37 ++ src/i18n/locales/ja-JP/settings.json | 37 ++ src/i18n/locales/zh-CN/settings.json | 37 ++ src/i18n/locales/zh-TW/settings.json | 37 ++ 17 files changed, 1115 insertions(+), 6 deletions(-) create mode 100644 apps/desktop/main/ipc/security.ts create mode 100644 apps/desktop/main/security/crypto-utils.ts create mode 100644 apps/desktop/main/security/lock-manager.ts create mode 100644 apps/desktop/preload/apis/security.ts create mode 100644 src/components/common/Settings/SecuritySettingsTab.vue create mode 100644 src/components/lock-screen/LockScreen.vue diff --git a/apps/desktop/main/index.ts b/apps/desktop/main/index.ts index 80c14a7a..2ee1746d 100644 --- a/apps/desktop/main/index.ts +++ b/apps/desktop/main/index.ts @@ -10,6 +10,7 @@ import { initAnalytics } from './analytics' import { logger } from './logger' import { prepareDesktopRuntime } from './app/bootstrap' import { createMainWindow, markAppQuitting } from './window/main-window' +import { initLockManager, cleanupLockManager } from './security/lock-manager' class MainProcess { mainWindow: BrowserWindow | null @@ -104,6 +105,11 @@ class MainProcess { console.log('[Main] Registering IPC handlers...') mainIpcMain(this.mainWindow) console.log('[Main] IPC handlers registered') + + // 初始化应用锁(在 IPC 注册之后) + console.log('[Main] Initializing app lock...') + initLockManager(this.mainWindow) + console.log('[Main] App lock initialized') } app.on('browser-window-created', (_, window) => { @@ -144,6 +150,7 @@ class MainProcess { app.on('will-quit', () => { stopInternalServer().catch(() => {}) cleanup() + cleanupLockManager() }) }) } diff --git a/apps/desktop/main/ipc/index.ts b/apps/desktop/main/ipc/index.ts index 59071368..f7241a0c 100644 --- a/apps/desktop/main/ipc/index.ts +++ b/apps/desktop/main/ipc/index.ts @@ -15,6 +15,7 @@ import { registerNetworkHandlers } from './network' import { registerAnalyticsHandlers } from '../analytics' import { registerApiHandlers, initApiServer, cleanupApiServer } from './api' import { registerDemoHandlers } from './demo' +import { registerSecurityHandlers } from './security' import { cleanupArchiveImportSources } from '../import/archive-source-runtime' // 导入 Worker 模块(用于异步分析查询和流式导入) import * as worker from '../worker/workerManager' @@ -46,6 +47,7 @@ const mainIpcMain = (win: BrowserWindow) => { registerAnalyticsHandlers() registerApiHandlers(context) registerDemoHandlers(context) + registerSecurityHandlers(context) // 启动 ChatLab API 服务(异步,不阻塞 IPC 注册) initApiServer(context).catch((err) => { diff --git a/apps/desktop/main/ipc/security.ts b/apps/desktop/main/ipc/security.ts new file mode 100644 index 00000000..79b71de2 --- /dev/null +++ b/apps/desktop/main/ipc/security.ts @@ -0,0 +1,25 @@ +import { ipcMain } from 'electron' +import type { IpcContext } from './types' +import { + changePassword, + getLockConfig, + getLockState, + lockApp, + resetAppLockPassword, + setPassword, + unlockApp, + updateLockConfig, +} from '../security/lock-manager' + +export function registerSecurityHandlers(_ctx: IpcContext): void { + ipcMain.handle('app-lock:getConfig', getLockConfig) + ipcMain.handle('app-lock:getState', getLockState) + ipcMain.handle('app-lock:lock', lockApp) + ipcMain.handle('app-lock:unlock', (_event, password: unknown) => unlockApp(password)) + ipcMain.handle('app-lock:setPassword', (_event, password: unknown) => setPassword(password)) + ipcMain.handle('app-lock:changePassword', (_event, oldPassword: unknown, newPassword: unknown) => + changePassword(oldPassword, newPassword) + ) + ipcMain.handle('app-lock:resetPassword', resetAppLockPassword) + ipcMain.handle('app-lock:updateConfig', (_event, updates: unknown) => updateLockConfig(updates)) +} diff --git a/apps/desktop/main/security/crypto-utils.ts b/apps/desktop/main/security/crypto-utils.ts new file mode 100644 index 00000000..6fef4f5a --- /dev/null +++ b/apps/desktop/main/security/crypto-utils.ts @@ -0,0 +1,52 @@ +import * as crypto from 'node:crypto' + +const PBKDF2_ITERATIONS = 600000 +const SALT_LENGTH = 32 +const HASH_LENGTH = 64 +const HEX_PATTERN = /^[0-9a-f]+$/i + +export interface PasswordHash { + hash: string + salt: string + version: 1 +} + +export function isPasswordHash(value: unknown): value is PasswordHash { + if (!value || typeof value !== 'object') return false + const record = value as Record + return ( + record.version === 1 && + typeof record.hash === 'string' && + record.hash.length === HASH_LENGTH * 2 && + HEX_PATTERN.test(record.hash) && + typeof record.salt === 'string' && + record.salt.length === SALT_LENGTH * 2 && + HEX_PATTERN.test(record.salt) + ) +} + +function derivePassword(password: string, salt: Buffer): Promise { + return new Promise((resolve, reject) => { + crypto.pbkdf2(password, salt, PBKDF2_ITERATIONS, HASH_LENGTH, 'sha512', (error, hash) => { + if (error) reject(error) + else resolve(hash) + }) + }) +} + +export async function hashPassword(password: string): Promise { + const salt = crypto.randomBytes(SALT_LENGTH) + const hash = await derivePassword(password, salt) + return { + hash: hash.toString('hex'), + salt: salt.toString('hex'), + version: 1, + } +} + +export async function verifyPasswordHash(password: string, stored: PasswordHash): Promise { + if (!isPasswordHash(stored)) return false + const expected = Buffer.from(stored.hash, 'hex') + const actual = await derivePassword(password, Buffer.from(stored.salt, 'hex')) + return crypto.timingSafeEqual(actual, expected) +} diff --git a/apps/desktop/main/security/lock-manager.ts b/apps/desktop/main/security/lock-manager.ts new file mode 100644 index 00000000..471c3a18 --- /dev/null +++ b/apps/desktop/main/security/lock-manager.ts @@ -0,0 +1,336 @@ +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 | 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 + + 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 = { ...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 { + 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 { + 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 { + 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 + 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') +} diff --git a/apps/desktop/preload/apis/security.ts b/apps/desktop/preload/apis/security.ts new file mode 100644 index 00000000..d3f716d6 --- /dev/null +++ b/apps/desktop/preload/apis/security.ts @@ -0,0 +1,18 @@ +import { ipcRenderer } from 'electron' +import type { SecurityApi } from '../../shared/types' + +export const securityApi: SecurityApi = { + getConfig: () => ipcRenderer.invoke('app-lock:getConfig'), + getState: () => ipcRenderer.invoke('app-lock:getState'), + lock: () => ipcRenderer.invoke('app-lock:lock'), + unlock: (password) => ipcRenderer.invoke('app-lock:unlock', password), + setPassword: (password) => ipcRenderer.invoke('app-lock:setPassword', password), + changePassword: (oldPassword, newPassword) => ipcRenderer.invoke('app-lock:changePassword', oldPassword, newPassword), + resetPassword: () => ipcRenderer.invoke('app-lock:resetPassword'), + updateConfig: (updates) => ipcRenderer.invoke('app-lock:updateConfig', updates), + onLockStateChanged: (callback) => { + const handler = (_event: Electron.IpcRendererEvent, locked: boolean) => callback(locked) + ipcRenderer.on('app-lock-state-changed', handler) + return () => ipcRenderer.removeListener('app-lock-state-changed', handler) + }, +} diff --git a/apps/desktop/preload/index.d.ts b/apps/desktop/preload/index.d.ts index 3f6c1047..dc91f89e 100644 --- a/apps/desktop/preload/index.d.ts +++ b/apps/desktop/preload/index.d.ts @@ -1,6 +1,6 @@ import { ElectronAPI } from '@electron-toolkit/preload' import type { ImportProgress, ExportProgress } from '../../../src/types/base' -import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo } from '../shared/types' +import type { TokenUsage, AgentRuntimeStatus, SerializedErrorInfo, SecurityApi } from '../shared/types' import type { TimeFilter } from '@openchatlab/shared-types' // 迁移相关类型 @@ -299,6 +299,7 @@ declare global { networkApi: NetworkApi apiServerApi: ApiServerApi internalApi: InternalApi + securityApi: SecurityApi } } @@ -334,4 +335,5 @@ export { ApiServerStatus, InternalApi, InternalEndpoint, + SecurityApi, } diff --git a/apps/desktop/preload/index.ts b/apps/desktop/preload/index.ts index 9fef1ef4..175a33e9 100644 --- a/apps/desktop/preload/index.ts +++ b/apps/desktop/preload/index.ts @@ -12,6 +12,7 @@ import { aiApi } from './apis/ai' import { networkApi, cacheApi } from './apis/utils' import { apiServerApi } from './apis/api-server' import { internalApi } from './apis/internal-api' +import { securityApi } from './apis/security' // 为渲染进程提供统一的类型入口,避免 type-only import 指向无导出的运行时代码。 export type { PreprocessConfig } from './apis/ai' @@ -41,6 +42,7 @@ if (process.contextIsolated) { contextBridge.exposeInMainWorld('networkApi', networkApi) contextBridge.exposeInMainWorld('apiServerApi', apiServerApi) contextBridge.exposeInMainWorld('internalApi', internalApi) + contextBridge.exposeInMainWorld('securityApi', securityApi) } catch (error) { console.error(error) } @@ -61,4 +63,6 @@ if (process.contextIsolated) { window.apiServerApi = apiServerApi // @ts-ignore (define in dts) window.internalApi = internalApi + // @ts-ignore (define in dts) + window.securityApi = securityApi } diff --git a/apps/desktop/shared/types.ts b/apps/desktop/shared/types.ts index a4e38e70..460ee803 100644 --- a/apps/desktop/shared/types.ts +++ b/apps/desktop/shared/types.ts @@ -41,3 +41,50 @@ export interface AgentRuntimeStatus { totalUsage: TokenUsage updatedAt: number } + +export type AppLockState = 'unlocked' | 'locked' + +export type AppLockErrorCode = + | 'busy' + | 'locked' + | 'disabled' + | 'invalid-pin' + | 'password-already-set' + | 'password-not-set' + | 'same-password' + | 'wrong-password' + | 'save-failed' + | 'invalid-config' + | 'internal-error' + +export interface AppLockConfig { + enabled: boolean + idleTimeoutMinutes: number + lockOnStartup: boolean +} + +export interface AppLockConfigUpdate { + idleTimeoutMinutes?: number + lockOnStartup?: boolean +} + +export interface AppLockResult { + success: boolean + error?: AppLockErrorCode +} + +export interface AppLockUnlockResult extends AppLockResult { + wrongPassword?: boolean +} + +export interface SecurityApi { + getConfig: () => Promise + getState: () => Promise + lock: () => Promise + unlock: (password: string) => Promise + setPassword: (newPassword: string) => Promise + changePassword: (oldPassword: string, newPassword: string) => Promise + resetPassword: () => Promise + updateConfig: (updates: AppLockConfigUpdate) => Promise + onLockStateChanged: (callback: (locked: boolean) => void) => () => void +} diff --git a/src/App.vue b/src/App.vue index 0e08dc90..02b5f4dc 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,5 +1,5 @@ + + diff --git a/src/components/common/SettingsModal.vue b/src/components/common/SettingsModal.vue index 2155df95..38ffa075 100644 --- a/src/components/common/SettingsModal.vue +++ b/src/components/common/SettingsModal.vue @@ -1,5 +1,5 @@ + + diff --git a/src/i18n/locales/en-US/settings.json b/src/i18n/locales/en-US/settings.json index a414546d..0fbce5c5 100644 --- a/src/i18n/locales/en-US/settings.json +++ b/src/i18n/locales/en-US/settings.json @@ -11,6 +11,7 @@ "aiPreprocess": "Preprocess", "dataManage": "Data Management", "storage": "Storage", + "security": "Security", "sessionManage": "Session Index", "about": "About", "api": "Automation", @@ -609,5 +610,41 @@ "nodeRequired": "npx and CLI modes require Node.js 20 or higher.", "docs": "For detailed documentation, visit docs.chatlab.fun." } + }, + "security": { + "lock": { + "title": "App Lock", + "desc": "Protect ChatLab with a 4-digit PIN", + "statusOn": "Enabled", + "statusOff": "Disabled", + "disableBtn": "Disable App Lock", + "disableConfirm": "Disable app lock? Your current PIN will be deleted.", + "manualLock": "Lock Now" + }, + "password": { + "titleSetup": "Set PIN & Enable App Lock", + "titleChange": "Change PIN", + "labelOld": "Current PIN", + "labelNew": "New PIN", + "labelConfirm": "Confirm New PIN", + "btnEnable": "Enable", + "errorInvalidPin": "PIN must be exactly 4 digits", + "errorMismatch": "PINs do not match", + "errorSameAsOld": "New PIN must differ from current PIN", + "errorFailed": "Operation failed, please try again" + }, + "autoLock": { + "title": "Auto Lock", + "idleLabel": "Auto lock after idle timeout", + "startupLabel": "Require unlock on startup" + }, + "lockScreen": { + "titleLocked": "ChatLab is Locked", + "placeholderPassword": "Enter 4-digit PIN", + "btnUnlock": "Unlock", + "verifying": "Verifying...", + "errorService": "Unlock failed, please try again", + "errorWrongPassword": "Incorrect PIN" + } } } diff --git a/src/i18n/locales/ja-JP/settings.json b/src/i18n/locales/ja-JP/settings.json index 781ebc44..cf1844d0 100644 --- a/src/i18n/locales/ja-JP/settings.json +++ b/src/i18n/locales/ja-JP/settings.json @@ -11,6 +11,7 @@ "aiPreprocess": "前処理", "dataManage": "データ管理", "storage": "ストレージ管理", + "security": "セキュリティ", "sessionManage": "セッションインデックス", "about": "ChatLabについて", "api": "オートメーション", @@ -609,5 +610,41 @@ "nodeRequired": "npx および CLI モードには Node.js 20 以上が必要です。", "docs": "詳細なドキュメントは docs.chatlab.fun をご覧ください。" } + }, + "security": { + "lock": { + "title": "アプリロック", + "desc": "4桁のPINでChatLabを保護します", + "statusOn": "有効", + "statusOff": "無効", + "disableBtn": "アプリロックを無効にする", + "disableConfirm": "アプリロックを無効にしますか?現在のPINは削除されます。", + "manualLock": "今すぐロック" + }, + "password": { + "titleSetup": "PIN設定とアプリロック有効化", + "titleChange": "PIN変更", + "labelOld": "現在のPIN", + "labelNew": "新しいPIN", + "labelConfirm": "新しいPIN(確認)", + "btnEnable": "有効にする", + "errorInvalidPin": "PINは4桁の数字で入力してください", + "errorMismatch": "PINが一致しません", + "errorSameAsOld": "新しいPINを現在のものと同じにしないでください", + "errorFailed": "操作に失敗しました。再試行してください" + }, + "autoLock": { + "title": "自動ロック", + "idleLabel": "アイドル時に自動ロック", + "startupLabel": "起動時にロック解除を要求" + }, + "lockScreen": { + "titleLocked": "ChatLab はロックされています", + "placeholderPassword": "4桁のPINを入力", + "btnUnlock": "ロック解除", + "verifying": "検証中...", + "errorService": "ロック解除に失敗しました。再試行してください", + "errorWrongPassword": "PINが正しくありません" + } } } diff --git a/src/i18n/locales/zh-CN/settings.json b/src/i18n/locales/zh-CN/settings.json index b86e5a4f..2d728fdd 100644 --- a/src/i18n/locales/zh-CN/settings.json +++ b/src/i18n/locales/zh-CN/settings.json @@ -11,6 +11,7 @@ "aiPreprocess": "预处理", "dataManage": "数据管理", "storage": "存储管理", + "security": "安全", "sessionManage": "会话索引", "about": "关于", "api": "自动化", @@ -609,5 +610,41 @@ "nodeRequired": "npx 和 CLI 方式需要 Node.js 20 或更高版本。", "docs": "详细文档请访问 docs.chatlab.fun。" } + }, + "security": { + "lock": { + "title": "应用锁", + "desc": "使用 4 位数字密码保护 ChatLab", + "statusOn": "已开启", + "statusOff": "未开启", + "disableBtn": "关闭应用锁", + "disableConfirm": "确定关闭应用锁?关闭后将删除当前密码。", + "manualLock": "立即锁定" + }, + "password": { + "titleSetup": "设置 4 位密码并开启应用锁", + "titleChange": "修改密码", + "labelOld": "旧密码", + "labelNew": "输入新密码", + "labelConfirm": "确认新密码", + "btnEnable": "开启", + "errorInvalidPin": "密码必须是 4 位数字", + "errorMismatch": "两次输入的密码不一致", + "errorSameAsOld": "新密码不能与旧密码相同", + "errorFailed": "操作失败,请重试" + }, + "autoLock": { + "title": "自动锁定", + "idleLabel": "闲置超时自动锁定", + "startupLabel": "启动时要求解锁" + }, + "lockScreen": { + "titleLocked": "ChatLab 已锁定", + "placeholderPassword": "输入 4 位数字密码", + "btnUnlock": "解锁", + "verifying": "验证中...", + "errorService": "解锁失败,请重试", + "errorWrongPassword": "密码错误" + } } } diff --git a/src/i18n/locales/zh-TW/settings.json b/src/i18n/locales/zh-TW/settings.json index 613d6c2b..c6f39b68 100644 --- a/src/i18n/locales/zh-TW/settings.json +++ b/src/i18n/locales/zh-TW/settings.json @@ -11,6 +11,7 @@ "aiPreprocess": "前處理", "dataManage": "資料管理", "storage": "儲存管理", + "security": "安全", "sessionManage": "會話索引", "about": "關於 ChatLab", "api": "自動化", @@ -609,5 +610,41 @@ "nodeRequired": "npx 和 CLI 方式需要 Node.js 20 或更高版本。", "docs": "詳細文件請造訪 docs.chatlab.fun。" } + }, + "security": { + "lock": { + "title": "應用鎖", + "desc": "使用 4 位數字密碼保護 ChatLab", + "statusOn": "已開啟", + "statusOff": "未開啟", + "disableBtn": "關閉應用鎖", + "disableConfirm": "確定關閉應用鎖?關閉後將刪除目前的密碼。", + "manualLock": "立即鎖定" + }, + "password": { + "titleSetup": "設定 4 位密碼並開啟應用鎖", + "titleChange": "修改密碼", + "labelOld": "舊密碼", + "labelNew": "輸入新密碼", + "labelConfirm": "確認新密碼", + "btnEnable": "開啟", + "errorInvalidPin": "密碼必須是 4 位數字", + "errorMismatch": "兩次輸入的密碼不一致", + "errorSameAsOld": "新密碼不能與舊密碼相同", + "errorFailed": "操作失敗,請重試" + }, + "autoLock": { + "title": "自動鎖定", + "idleLabel": "閒置逾時自動鎖定", + "startupLabel": "啟動時要求解鎖" + }, + "lockScreen": { + "titleLocked": "ChatLab 已鎖定", + "placeholderPassword": "輸入 4 位數字密碼", + "btnUnlock": "解鎖", + "verifying": "驗證中...", + "errorService": "解鎖失敗,請重試", + "errorWrongPassword": "密碼錯誤" + } } }