mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(security): harden desktop app lock
Keep the renderer fail-closed until lock state is known, use native modal dialogs without unmounting business state, and coordinate overlapping dialogs while locked. Isolate desktop E2E data from real user settings and keep startup migration inspection read-only.
This commit is contained in:
1 parent
e7dd418642
commit
058c3448ca
12 files changed
+292
-146
No files matched your search
@@ -10,10 +10,15 @@ function makeTempDir(): string {
|
||||
return fs.mkdtempSync(path.join(baseDir, 'chatlab-desktop-db-core-'))
|
||||
}
|
||||
|
||||
test('openDatabase keeps readonly validation read-only for DELETE journal databases', async () => {
|
||||
test('desktop database inspection keeps DELETE journal databases read-only', async () => {
|
||||
const root = makeTempDir()
|
||||
const previousDataDir = process.env.CHATLAB_DATA_DIR
|
||||
const originalConsoleError = console.error
|
||||
const consoleErrors: unknown[][] = []
|
||||
process.env.CHATLAB_DATA_DIR = root
|
||||
console.error = (...args: unknown[]) => {
|
||||
consoleErrors.push(args)
|
||||
}
|
||||
|
||||
const sessionId = 'legacy-delete-journal'
|
||||
const dbPath = path.join(root, 'databases', `${sessionId}.db`)
|
||||
@@ -53,7 +58,7 @@ test('openDatabase keeps readonly validation read-only for DELETE journal databa
|
||||
},
|
||||
})
|
||||
|
||||
const { openDatabase } = await import('./core.js')
|
||||
const { checkMigrationNeeded, openDatabase } = await import('./core.js')
|
||||
const db = openDatabase(sessionId, true)
|
||||
try {
|
||||
assert.ok(db)
|
||||
@@ -65,7 +70,11 @@ test('openDatabase keeps readonly validation read-only for DELETE journal databa
|
||||
} finally {
|
||||
db?.close()
|
||||
}
|
||||
|
||||
assert.equal(checkMigrationNeeded().count, 0)
|
||||
assert.deepEqual(consoleErrors, [])
|
||||
} finally {
|
||||
console.error = originalConsoleError
|
||||
if (previousDataDir === undefined) {
|
||||
delete process.env.CHATLAB_DATA_DIR
|
||||
} else {
|
||||
|
||||
@@ -241,7 +241,6 @@ export function checkMigrationNeeded(): {
|
||||
|
||||
try {
|
||||
const db = new Database(dbPath, { readonly: true, nativeBinding: resolveDesktopNativeBinding() })
|
||||
db.pragma('journal_mode = WAL')
|
||||
|
||||
// 仅迁移聊天会话数据库:这里最小依赖是 meta + message
|
||||
// 这样可跳过非聊天库,同时避免把 member 缺失的异常库直接误归为“非聊天库”
|
||||
|
||||
+21
-2
@@ -24,6 +24,7 @@ import { configureHttpClient } from '@/services/utils/http'
|
||||
import { IS_ELECTRON } from '@/utils/platform'
|
||||
import { usePlatformService } from '@/services'
|
||||
import { resolvePageTransitionKey } from '@/routes/page-transition-key'
|
||||
import { useLockScreenBootstrap } from '@/components/lock-screen/bootstrap'
|
||||
|
||||
const LockScreen = IS_ELECTRON ? defineAsyncComponent(() => import('@/components/lock-screen/LockScreen.vue')) : null
|
||||
|
||||
@@ -38,10 +39,13 @@ const apiServerStore = useApiServerStore()
|
||||
const { isInitialized } = storeToRefs(sessionStore)
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, syncBootstrapMask, updateLockState } =
|
||||
useLockScreenBootstrap(IS_ELECTRON)
|
||||
|
||||
const isLoginPage = computed(() => !IS_ELECTRON && route.name === 'login')
|
||||
const pageTransitionKey = computed(() => resolvePageTransitionKey(route))
|
||||
const initError = ref<string | null>(null)
|
||||
const bootstrapDialogRef = ref<HTMLDialogElement | null>(null)
|
||||
const colorMode = useColorMode({
|
||||
emitAuto: true,
|
||||
initialValue: 'light',
|
||||
@@ -83,6 +87,7 @@ async function initializeApp() {
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
if (!isApplicationInteractive.value) return
|
||||
const isMeta = navigator.platform.toLowerCase().includes('mac') ? e.metaKey : e.ctrlKey
|
||||
// Ctrl+, → 打开设置
|
||||
if (isMeta && e.key === ',') {
|
||||
@@ -110,6 +115,8 @@ watch(
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(isBootstrapMaskVisible, () => syncBootstrapMask(bootstrapDialogRef.value), { flush: 'post' })
|
||||
|
||||
useWindowsTitleBarOverlay([
|
||||
colorMode,
|
||||
() => route.fullPath,
|
||||
@@ -119,6 +126,8 @@ useWindowsTitleBarOverlay([
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
// 同步原生 modal 在异步锁屏就绪前保持底层文档 inert;chunk 加载失败时不会自动放行。
|
||||
syncBootstrapMask(bootstrapDialogRef.value)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
const platform = navigator.platform.toLowerCase()
|
||||
if (platform.includes('win')) {
|
||||
@@ -212,8 +221,18 @@ onUnmounted(() => {
|
||||
<ChatRecordDrawer />
|
||||
<!-- 全局 AI 后台任务条:允许用户离开当前页面后仍然快速返回进行中的对话。 -->
|
||||
<GlobalTaskBar />
|
||||
<!-- 应用锁覆盖层:最高 z-index,锁定后拦截全部底层操作 -->
|
||||
<LockScreen v-if="IS_ELECTRON" />
|
||||
<!-- 原生模态锁屏:锁定后由浏览器 top layer 隔离全部底层操作 -->
|
||||
<LockScreen v-if="IS_ELECTRON" @ready="markLockScreenReady" @lock-state-change="updateLockState" />
|
||||
<Teleport v-if="IS_ELECTRON" to="body">
|
||||
<dialog
|
||||
ref="bootstrapDialogRef"
|
||||
:aria-label="t('common.initializing')"
|
||||
aria-busy="true"
|
||||
class="pointer-events-auto m-0 h-screen max-h-none w-screen max-w-none border-0 bg-white p-0 outline-none backdrop:bg-white dark:bg-page-dark dark:backdrop:bg-page-dark"
|
||||
tabindex="-1"
|
||||
@cancel.prevent
|
||||
/>
|
||||
</Teleport>
|
||||
</UApp>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useLayoutStore } from '@/stores/layout'
|
||||
import { syncNativeDialog } from './native-dialog'
|
||||
|
||||
const { t } = useI18n()
|
||||
const layoutStore = useLayoutStore()
|
||||
const visible = ref(false)
|
||||
const emit = defineEmits<{
|
||||
ready: []
|
||||
'lock-state-change': [locked: boolean]
|
||||
}>()
|
||||
const isUnlocking = ref(false)
|
||||
const password = ref('')
|
||||
const showPassword = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const retryAfterSeconds = ref(0)
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
const passwordInputRef = ref<HTMLInputElement | null>(null)
|
||||
const canUnlock = computed(() => /^\d{4}$/.test(password.value) && retryAfterSeconds.value === 0)
|
||||
const cooldownMessage = computed(() =>
|
||||
@@ -19,17 +22,27 @@ const cooldownMessage = computed(() =>
|
||||
|
||||
let removeLockListener: (() => void) | null = null
|
||||
let cooldownTimer: ReturnType<typeof setInterval> | null = null
|
||||
let latestLockEvent: boolean | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
removeLockListener = window.securityApi.onLockStateChanged((locked) => {
|
||||
if (locked) showOverlay()
|
||||
else hideOverlay()
|
||||
latestLockEvent = locked
|
||||
void applyLockState(locked)
|
||||
})
|
||||
try {
|
||||
if ((await window.securityApi.getState()) === 'locked') showOverlay()
|
||||
const initialState = await window.securityApi.getState()
|
||||
// 查询期间若收到更新事件,以事件携带的最新状态为准,避免用旧结果覆盖锁屏。
|
||||
if (latestLockEvent === null) {
|
||||
await applyLockState(initialState === 'locked')
|
||||
}
|
||||
} catch {
|
||||
// 主进程会在下一次状态变化时重新通知锁屏。
|
||||
if (latestLockEvent === null) {
|
||||
await applyLockState(true)
|
||||
errorMessage.value = t('settings.security.lockScreen.errorService')
|
||||
}
|
||||
}
|
||||
await nextTick()
|
||||
emit('ready')
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
@@ -37,16 +50,22 @@ onUnmounted(() => {
|
||||
clearCooldownTimer()
|
||||
})
|
||||
|
||||
async function applyLockState(locked: boolean): Promise<void> {
|
||||
emit('lock-state-change', locked)
|
||||
if (locked) await showOverlay()
|
||||
else hideOverlay()
|
||||
}
|
||||
|
||||
async function showOverlay(): Promise<void> {
|
||||
visible.value = true
|
||||
resetForm()
|
||||
layoutStore.closeOverlaysForAppLock()
|
||||
// 原生 modal dialog 会把文档其余部分设为 inert,保留业务状态的同时阻止已有或异步弹窗抢焦点。
|
||||
syncNativeDialog(dialogRef.value, true)
|
||||
await nextTick()
|
||||
passwordInputRef.value?.focus()
|
||||
}
|
||||
|
||||
function hideOverlay(): void {
|
||||
visible.value = false
|
||||
syncNativeDialog(dialogRef.value, false)
|
||||
resetForm()
|
||||
}
|
||||
|
||||
@@ -64,7 +83,10 @@ async function handleUnlock(): Promise<void> {
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await window.securityApi.unlock(password.value)
|
||||
if (result.success) return
|
||||
if (result.success) {
|
||||
await applyLockState(false)
|
||||
return
|
||||
}
|
||||
if (result.retryAfterSeconds && result.retryAfterSeconds > 0) {
|
||||
password.value = ''
|
||||
startCooldownTimer(result.retryAfterSeconds)
|
||||
@@ -105,66 +127,73 @@ function handlePasswordInput(event: Event): void {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[99999] flex items-center justify-center bg-white dark:bg-page-dark"
|
||||
@keydown.enter="handleUnlock"
|
||||
>
|
||||
<div class="w-full max-w-sm px-8">
|
||||
<div class="mb-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-white shadow-sm dark:bg-primary-500"
|
||||
>
|
||||
<UIcon name="i-heroicons-lock-closed" class="h-6 w-6" />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('settings.security.lockScreen.titleLocked') }}
|
||||
</h1>
|
||||
</div>
|
||||
<Teleport to="body">
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="pointer-events-auto m-0 h-screen max-h-none w-screen max-w-none border-0 bg-white p-0 text-gray-900 outline-none backdrop:bg-white dark:bg-page-dark dark:text-white dark:backdrop:bg-page-dark"
|
||||
@cancel.prevent
|
||||
@keydown.enter="handleUnlock"
|
||||
>
|
||||
<div class="flex h-full w-full items-center justify-center">
|
||||
<div class="w-full max-w-sm px-8">
|
||||
<div class="mb-8 text-center">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-xl bg-primary-600 text-white shadow-sm dark:bg-primary-500"
|
||||
>
|
||||
<UIcon name="i-heroicons-lock-closed" class="h-6 w-6" />
|
||||
</div>
|
||||
<h1 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('settings.security.lockScreen.titleLocked') }}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="passwordInputRef"
|
||||
:value="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('settings.security.lockScreen.placeholderPassword')"
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-100 py-3 pl-4 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder-gray-500"
|
||||
:disabled="isUnlocking || retryAfterSeconds > 0"
|
||||
maxlength="4"
|
||||
@input="handlePasswordInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:aria-label="t('settings.security.lockScreen.placeholderPassword')"
|
||||
:disabled="isUnlocking || retryAfterSeconds > 0"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<UIcon :name="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-5 w-5" />
|
||||
</button>
|
||||
<div class="space-y-3">
|
||||
<div class="relative">
|
||||
<input
|
||||
ref="passwordInputRef"
|
||||
:value="password"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
inputmode="numeric"
|
||||
pattern="[0-9]*"
|
||||
autocomplete="current-password"
|
||||
:placeholder="t('settings.security.lockScreen.placeholderPassword')"
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-100 py-3 pl-4 pr-12 text-sm text-gray-900 placeholder-gray-400 outline-none transition focus:border-primary-500 focus:ring-1 focus:ring-primary-500 disabled:opacity-40 dark:border-gray-700 dark:bg-gray-900 dark:text-white dark:placeholder-gray-500"
|
||||
:disabled="isUnlocking || retryAfterSeconds > 0"
|
||||
maxlength="4"
|
||||
@input="handlePasswordInput"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-gray-600 dark:hover:text-gray-300"
|
||||
:aria-label="t('settings.security.lockScreen.placeholderPassword')"
|
||||
:disabled="isUnlocking || retryAfterSeconds > 0"
|
||||
@click="showPassword = !showPassword"
|
||||
>
|
||||
<UIcon :name="showPassword ? 'i-heroicons-eye-slash' : 'i-heroicons-eye'" class="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="retryAfterSeconds > 0" class="text-center text-xs text-amber-500">
|
||||
{{ cooldownMessage }}
|
||||
</p>
|
||||
<p v-else-if="errorMessage" class="text-center text-xs text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<UButton
|
||||
block
|
||||
size="lg"
|
||||
icon="i-heroicons-lock-open"
|
||||
:loading="isUnlocking"
|
||||
:disabled="!canUnlock"
|
||||
class="justify-center rounded-xl"
|
||||
@click="handleUnlock"
|
||||
>
|
||||
{{
|
||||
isUnlocking ? t('settings.security.lockScreen.verifying') : t('settings.security.lockScreen.btnUnlock')
|
||||
}}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="retryAfterSeconds > 0" class="text-center text-xs text-amber-500">
|
||||
{{ cooldownMessage }}
|
||||
</p>
|
||||
<p v-else-if="errorMessage" class="text-center text-xs text-red-500">
|
||||
{{ errorMessage }}
|
||||
</p>
|
||||
<UButton
|
||||
block
|
||||
size="lg"
|
||||
icon="i-heroicons-lock-open"
|
||||
:loading="isUnlocking"
|
||||
:disabled="!canUnlock"
|
||||
class="justify-center rounded-xl"
|
||||
@click="handleUnlock"
|
||||
>
|
||||
{{ isUnlocking ? t('settings.security.lockScreen.verifying') : t('settings.security.lockScreen.btnUnlock') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Teleport>
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { useLockScreenBootstrap } from './bootstrap'
|
||||
|
||||
test('desktop interactions stay blocked until the lock screen is ready and unlocked', () => {
|
||||
let dialogOpen = false
|
||||
let showModalCalls = 0
|
||||
let closeCalls = 0
|
||||
const dialog = {
|
||||
get open() {
|
||||
return dialogOpen
|
||||
},
|
||||
showModal() {
|
||||
showModalCalls += 1
|
||||
dialogOpen = true
|
||||
},
|
||||
close() {
|
||||
closeCalls += 1
|
||||
dialogOpen = false
|
||||
},
|
||||
}
|
||||
const { isBootstrapMaskVisible, isApplicationInteractive, markLockScreenReady, syncBootstrapMask, updateLockState } =
|
||||
useLockScreenBootstrap(true)
|
||||
|
||||
assert.equal(isBootstrapMaskVisible.value, true)
|
||||
assert.equal(isApplicationInteractive.value, false)
|
||||
syncBootstrapMask(dialog)
|
||||
assert.equal(dialogOpen, true)
|
||||
assert.equal(showModalCalls, 1)
|
||||
|
||||
updateLockState(false)
|
||||
assert.equal(isApplicationInteractive.value, false)
|
||||
syncBootstrapMask(dialog)
|
||||
assert.equal(showModalCalls, 1)
|
||||
|
||||
markLockScreenReady()
|
||||
assert.equal(isBootstrapMaskVisible.value, false)
|
||||
assert.equal(isApplicationInteractive.value, true)
|
||||
syncBootstrapMask(dialog)
|
||||
assert.equal(dialogOpen, false)
|
||||
assert.equal(closeCalls, 1)
|
||||
|
||||
updateLockState(true)
|
||||
assert.equal(isApplicationInteractive.value, false)
|
||||
|
||||
updateLockState(false)
|
||||
assert.equal(isApplicationInteractive.value, true)
|
||||
})
|
||||
|
||||
test('web interactions remain enabled and never use the desktop lock bootstrap mask', () => {
|
||||
let showModalCalls = 0
|
||||
let closeCalls = 0
|
||||
const dialog = {
|
||||
open: false,
|
||||
showModal() {
|
||||
showModalCalls += 1
|
||||
},
|
||||
close() {
|
||||
closeCalls += 1
|
||||
},
|
||||
}
|
||||
const { isBootstrapMaskVisible, isApplicationInteractive, syncBootstrapMask, updateLockState } =
|
||||
useLockScreenBootstrap(false)
|
||||
|
||||
assert.equal(isBootstrapMaskVisible.value, false)
|
||||
assert.equal(isApplicationInteractive.value, true)
|
||||
syncBootstrapMask(dialog)
|
||||
assert.equal(showModalCalls, 0)
|
||||
assert.equal(closeCalls, 0)
|
||||
|
||||
updateLockState(true)
|
||||
assert.equal(isApplicationInteractive.value, true)
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { syncNativeDialog, type NativeDialogController } from './native-dialog'
|
||||
|
||||
export function useLockScreenBootstrap(isElectron: boolean) {
|
||||
const isLockScreenReady = ref(!isElectron)
|
||||
const isLocked = ref(isElectron)
|
||||
const isBootstrapMaskVisible = computed(() => isElectron && !isLockScreenReady.value)
|
||||
// 这里只阻断全局快捷键等应用交互,不控制业务组件挂载,避免锁定时丢失未保存状态。
|
||||
const isApplicationInteractive = computed(() => !isElectron || (isLockScreenReady.value && !isLocked.value))
|
||||
|
||||
function markLockScreenReady(): void {
|
||||
isLockScreenReady.value = true
|
||||
}
|
||||
|
||||
function updateLockState(locked: boolean): void {
|
||||
if (!isElectron) return
|
||||
isLocked.value = locked
|
||||
}
|
||||
|
||||
function syncBootstrapMask(dialog: NativeDialogController | null): void {
|
||||
syncNativeDialog(dialog, isBootstrapMaskVisible.value)
|
||||
}
|
||||
|
||||
return {
|
||||
isBootstrapMaskVisible,
|
||||
isApplicationInteractive,
|
||||
markLockScreenReady,
|
||||
syncBootstrapMask,
|
||||
updateLockState,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { syncNativeDialog } from './native-dialog'
|
||||
|
||||
test('native lock dialog opens and closes without repeating browser calls', () => {
|
||||
let open = false
|
||||
let showModalCalls = 0
|
||||
let closeCalls = 0
|
||||
const dialog = {
|
||||
get open() {
|
||||
return open
|
||||
},
|
||||
showModal() {
|
||||
showModalCalls += 1
|
||||
open = true
|
||||
},
|
||||
close() {
|
||||
closeCalls += 1
|
||||
open = false
|
||||
},
|
||||
}
|
||||
|
||||
syncNativeDialog(dialog, true)
|
||||
syncNativeDialog(dialog, true)
|
||||
assert.equal(open, true)
|
||||
assert.equal(showModalCalls, 1)
|
||||
|
||||
syncNativeDialog(dialog, false)
|
||||
syncNativeDialog(dialog, false)
|
||||
assert.equal(open, false)
|
||||
assert.equal(closeCalls, 1)
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface NativeDialogController {
|
||||
readonly open: boolean
|
||||
showModal: () => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
export function syncNativeDialog(dialog: NativeDialogController | null, shouldOpen: boolean): void {
|
||||
if (!dialog || dialog.open === shouldOpen) return
|
||||
|
||||
if (shouldOpen) dialog.showModal()
|
||||
else dialog.close()
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { before, beforeEach, describe, it } from 'node:test'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const storageEntries = new Map<string, string>()
|
||||
const memoryStorage: Storage = {
|
||||
get length() {
|
||||
return storageEntries.size
|
||||
},
|
||||
clear() {
|
||||
storageEntries.clear()
|
||||
},
|
||||
getItem(key) {
|
||||
return storageEntries.get(key) ?? null
|
||||
},
|
||||
key(index) {
|
||||
return [...storageEntries.keys()][index] ?? null
|
||||
},
|
||||
removeItem(key) {
|
||||
storageEntries.delete(key)
|
||||
},
|
||||
setItem(key, value) {
|
||||
storageEntries.set(key, value)
|
||||
},
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, 'localStorage', {
|
||||
configurable: true,
|
||||
value: memoryStorage,
|
||||
})
|
||||
|
||||
let useLayoutStore: typeof import('./layout').useLayoutStore
|
||||
|
||||
describe('layout store app lock overlays', () => {
|
||||
before(async () => {
|
||||
;({ useLayoutStore } = await import('./layout'))
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
it('closes focus-trapping overlays before showing the app lock screen', () => {
|
||||
const store = useLayoutStore()
|
||||
store.showSettings = true
|
||||
store.showScreenCaptureModal = true
|
||||
store.screenCaptureImage = 'data:image/png;base64,test'
|
||||
store.showChatRecordDrawer = true
|
||||
store.chatRecordQuery = {} as never
|
||||
|
||||
store.closeOverlaysForAppLock()
|
||||
|
||||
assert.equal(store.showSettings, false)
|
||||
assert.equal(store.showScreenCaptureModal, false)
|
||||
assert.equal(store.screenCaptureImage, null)
|
||||
assert.equal(store.showChatRecordDrawer, false)
|
||||
assert.equal(store.chatRecordQuery, null)
|
||||
})
|
||||
})
|
||||
@@ -88,17 +88,6 @@ export const useLayoutStore = defineStore(
|
||||
showSettings.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用锁显示前关闭会拦截鼠标和键盘焦点的全局浮层。
|
||||
*/
|
||||
function closeOverlaysForAppLock() {
|
||||
showSettings.value = false
|
||||
showScreenCaptureModal.value = false
|
||||
screenCaptureImage.value = null
|
||||
showChatRecordDrawer.value = false
|
||||
chatRecordQuery.value = null
|
||||
}
|
||||
|
||||
function toggleToolsPanelMini() {
|
||||
isToolsPanelMini.value = !isToolsPanelMini.value
|
||||
if (isToolsPanelMini.value) {
|
||||
@@ -129,7 +118,6 @@ export const useLayoutStore = defineStore(
|
||||
closeChatRecordDrawer,
|
||||
openSettings,
|
||||
closeSettings,
|
||||
closeOverlaysForAppLock,
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -180,6 +180,7 @@ async function launchApp(options = {}, deps = {}) {
|
||||
if (!fsImpl.existsSync(userDataDir)) {
|
||||
fsImpl.mkdirSync(userDataDir, { recursive: true })
|
||||
}
|
||||
const isolatedSystemDataDir = path.join(userDataDir, '.chatlab')
|
||||
|
||||
const appPath = path.resolve(__dirname, '../../../apps/desktop')
|
||||
if (!fsImpl.existsSync(appPath)) {
|
||||
@@ -206,6 +207,11 @@ async function launchApp(options = {}, deps = {}) {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
// ChatLab resolves canonical data from the OS home directory rather than Electron's userData path.
|
||||
// Override both home variants and the configurable data root so E2E cannot read or mutate real user state.
|
||||
HOME: userDataDir,
|
||||
USERPROFILE: userDataDir,
|
||||
CHATLAB_DATA_DIR: path.join(isolatedSystemDataDir, 'data'),
|
||||
TEST_MODE: 'true',
|
||||
CHATLAB_E2E_USER_DATA_DIR: userDataDir,
|
||||
ELECTRON_ENABLE_LOGGING: '1',
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const test = require('node:test')
|
||||
const assert = require('node:assert/strict')
|
||||
const { EventEmitter } = require('node:events')
|
||||
const path = require('node:path')
|
||||
|
||||
const { launchApp, __test__ } = require('./app-launcher')
|
||||
|
||||
@@ -136,6 +137,12 @@ test('launchApp 支持 startPort 并正确注入 TEST_MODE 环境', async () =>
|
||||
assert.deepEqual(captured.spawnArgs, ['--remote-debugging-port=9901', captured.spawnArgs[1]])
|
||||
assert.equal(captured.spawnEnv.TEST_MODE, 'true')
|
||||
assert.match(captured.spawnEnv.CHATLAB_E2E_USER_DATA_DIR, /chatlab-e2e-9901$/)
|
||||
assert.equal(captured.spawnEnv.HOME, captured.spawnEnv.CHATLAB_E2E_USER_DATA_DIR)
|
||||
assert.equal(captured.spawnEnv.USERPROFILE, captured.spawnEnv.CHATLAB_E2E_USER_DATA_DIR)
|
||||
assert.equal(
|
||||
captured.spawnEnv.CHATLAB_DATA_DIR,
|
||||
path.join(captured.spawnEnv.CHATLAB_E2E_USER_DATA_DIR, '.chatlab', 'data')
|
||||
)
|
||||
|
||||
await app.close()
|
||||
assert.deepEqual(captured.killSignals, ['SIGTERM'])
|
||||
|
||||
Reference in new issue
Block a user