diff --git a/easytier-gui/package.json b/easytier-gui/package.json index 2ff5b0e4..eadae6ed 100644 --- a/easytier-gui/package.json +++ b/easytier-gui/package.json @@ -8,6 +8,7 @@ "dev": "pnpm --dir ../easytier-web/frontend-lib build && vite", "build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build", "preview": "vite preview", + "test:mobile-vpn": "vitest run src/composables/mobile_vpn.test.ts", "tauri": "tauri", "lint": "eslint . --ignore-pattern src-tauri", "lint:fix": "eslint . --ignore-pattern src-tauri --fix" @@ -56,6 +57,7 @@ "vite": "^5.4.21", "vite-plugin-vue-devtools": "^7.4.6", "vite-plugin-vue-layouts": "^0.11.0", + "vitest": "^2.1.9", "vue-i18n": "^10.0.0", "vue-tsc": "^2.1.10" } diff --git a/easytier-gui/src/auto-imports.d.ts b/easytier-gui/src/auto-imports.d.ts index b246d2dd..f1450cb5 100644 --- a/easytier-gui/src/auto-imports.d.ts +++ b/easytier-gui/src/auto-imports.d.ts @@ -63,6 +63,7 @@ declare global { const onErrorCaptured: typeof import('vue')['onErrorCaptured'] const onMounted: typeof import('vue')['onMounted'] const onNetworkInstanceChange: typeof import('./composables/mobile_vpn')['onNetworkInstanceChange'] + const onNetworkInstanceUpdate: typeof import('./composables/mobile_vpn')['onNetworkInstanceUpdate'] const onRenderTracked: typeof import('vue')['onRenderTracked'] const onRenderTriggered: typeof import('vue')['onRenderTriggered'] const onScopeDispose: typeof import('vue')['onScopeDispose'] @@ -189,6 +190,7 @@ declare module 'vue' { readonly onErrorCaptured: UnwrapRef readonly onMounted: UnwrapRef readonly onNetworkInstanceChange: UnwrapRef + readonly onNetworkInstanceUpdate: UnwrapRef readonly onRenderTracked: UnwrapRef readonly onRenderTriggered: UnwrapRef readonly onScopeDispose: UnwrapRef diff --git a/easytier-gui/src/composables/event.ts b/easytier-gui/src/composables/event.ts index 6fb37023..2b7a029e 100644 --- a/easytier-gui/src/composables/event.ts +++ b/easytier-gui/src/composables/event.ts @@ -3,6 +3,7 @@ import { type } from "@tauri-apps/plugin-os"; import { NetworkTypes } from "easytier-frontend-lib" import { Utils } from "easytier-frontend-lib"; import { normalizeConfigSource } from './config_source' +import { onNetworkInstanceUpdate } from './mobile_vpn' interface StoredGuiConfig { config: NetworkTypes.NetworkConfig @@ -80,7 +81,7 @@ async function onDhcpIpChanged(event: Event) { const instanceId = normalizeInstanceIdPayload(event.payload) console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`); if (type() === 'android') { - await onNetworkInstanceChange(instanceId); + await onNetworkInstanceUpdate(instanceId); } } @@ -88,13 +89,13 @@ async function onProxyCidrsUpdated(event: Event) { const instanceId = normalizeInstanceIdPayload(event.payload) console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${instanceId}`); if (type() === 'android') { - await onNetworkInstanceChange(instanceId); + await onNetworkInstanceUpdate(instanceId); } } async function onEventLagged(event: Event) { if (type() === 'android') { - await onNetworkInstanceChange(normalizeInstanceIdPayload(event.payload)); + await onNetworkInstanceUpdate(normalizeInstanceIdPayload(event.payload)); } } diff --git a/easytier-gui/src/composables/mobile_vpn.test.ts b/easytier-gui/src/composables/mobile_vpn.test.ts new file mode 100644 index 00000000..225975f6 --- /dev/null +++ b/easytier-gui/src/composables/mobile_vpn.test.ts @@ -0,0 +1,232 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => { + const listeners = new Map Promise>() + const configs = new Map>() + const networkInfo = new Map() + + return { + listeners, + configs, + networkInfo, + addPluginListener: vi.fn(async (_plugin: string, event: string, listener: (payload: unknown) => Promise) => { + listeners.set(event, listener) + }), + collectNetworkInfo: vi.fn(async (instanceId: string) => ({ + info: { map: { [instanceId]: networkInfo.get(instanceId) } }, + })), + getConfig: vi.fn(async (instanceId: string) => configs.get(instanceId)), + getVpnStatus: vi.fn<() => Promise>>(async () => ({ running: false })), + listNetworkInstanceIds: vi.fn<() => Promise<{ running_inst_ids: unknown[] }>>(async () => ({ running_inst_ids: [] })), + prepareVpn: vi.fn(async () => ({ granted: true })), + setTunFd: vi.fn(async () => undefined), + startVpn: vi.fn(async () => { + await listeners.get('vpn_service_start')?.({ fd: 1 }) + return {} + }), + stopVpn: vi.fn(async () => { + await listeners.get('vpn_service_stop')?.({}) + return {} + }), + } +}) + +vi.mock('@tauri-apps/api/core', () => ({ + addPluginListener: mocks.addPluginListener, +})) + +vi.mock('easytier-frontend-lib', () => ({ + Utils: { + UuidToStr: (value: unknown) => String(value), + ipv4ToString: (address: { addr: string }) => address.addr, + }, +})) + +vi.mock('tauri-plugin-vpnservice-api', () => ({ + get_vpn_status: mocks.getVpnStatus, + prepare_vpn: mocks.prepareVpn, + start_vpn: mocks.startVpn, + stop_vpn: mocks.stopVpn, +})) + +vi.mock('./backend', () => ({ + collectNetworkInfo: mocks.collectNetworkInfo, + getConfig: mocks.getConfig, + listNetworkInstanceIds: mocks.listNetworkInstanceIds, + setTunFd: mocks.setTunFd, +})) + +function setConfig(instanceId: string, noTun = false) { + mocks.configs.set(instanceId, { + no_tun: noTun, + dhcp: false, + enable_magic_dns: false, + routes: [], + }) +} + +function setReady(instanceId: string, ipv4: string) { + mocks.networkInfo.set(instanceId, { + my_node_info: { + virtual_ipv4: { + address: { addr: ipv4 }, + network_length: 24, + }, + }, + routes: [], + }) +} + +async function loadVpnModule() { + const mobileVpn = await import('./mobile_vpn') + await mobileVpn.initMobileVpnService() + return mobileVpn +} + +beforeEach(() => { + vi.useFakeTimers() + vi.resetModules() + mocks.listeners.clear() + mocks.configs.clear() + mocks.networkInfo.clear() + mocks.addPluginListener.mockClear() + mocks.collectNetworkInfo.mockClear() + mocks.getConfig.mockClear() + mocks.getVpnStatus.mockReset() + mocks.getVpnStatus.mockResolvedValue({ running: false }) + mocks.listNetworkInstanceIds.mockReset() + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: [] }) + mocks.prepareVpn.mockClear() + mocks.setTunFd.mockClear() + mocks.startVpn.mockClear() + mocks.stopVpn.mockClear() +}) + +describe('mobile VPN reconciliation ownership', () => { + it('stops A before retrying an unavailable B, then starts B when it becomes ready', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + + await vpn.onNetworkInstanceChange('A') + expect(mocks.startVpn).toHaveBeenCalledTimes(1) + + mocks.startVpn.mockClear() + await vpn.onNetworkInstanceChange('B') + + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).not.toHaveBeenCalled() + + setReady('B', '10.0.0.2') + await vpn.onNetworkInstanceUpdate('B') + + expect(mocks.startVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).toHaveBeenCalledWith(expect.objectContaining({ ipv4Addr: '10.0.0.2/24' })) + }) + + it('stops the previous owner during pre-run even if the new instance never reaches post-run', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + + await vpn.onNetworkInstanceChange('A') + mocks.stopVpn.mockClear() + + await vpn.prepareVpnService('B') + + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + }) + + it('preserves the VPN while retrying the same instance', async () => { + setConfig('A') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + + await vpn.onNetworkInstanceChange('A') + mocks.stopVpn.mockClear() + mocks.networkInfo.delete('A') + + await vpn.onNetworkInstanceUpdate('A') + + expect(mocks.stopVpn).not.toHaveBeenCalled() + }) + + it('ignores an update from an instance that no longer owns the VPN', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + + await vpn.onNetworkInstanceChange('A') + await vpn.onNetworkInstanceChange('B') + mocks.collectNetworkInfo.mockClear() + + await vpn.onNetworkInstanceUpdate('A') + + expect(mocks.collectNetworkInfo).not.toHaveBeenCalled() + }) + + it('does not apply an in-flight result after the desired instance changes', async () => { + setConfig('A') + setConfig('B') + setReady('A', '10.0.0.1') + const vpn = await loadVpnModule() + + await vpn.onNetworkInstanceChange('A') + mocks.startVpn.mockClear() + mocks.stopVpn.mockClear() + + interface NetworkInfoResponse { info: { map: Record } } + let resolveNetworkInfo: (value: NetworkInfoResponse) => void = () => undefined + let markCollectStarted: () => void = () => undefined + const collectStarted = new Promise((resolve) => { + markCollectStarted = resolve + }) + mocks.collectNetworkInfo.mockImplementationOnce(async () => await new Promise((resolve) => { + resolveNetworkInfo = resolve + markCollectStarted() + })) + + const staleUpdate = vpn.onNetworkInstanceUpdate('A') + await collectStarted + const switchToB = vpn.onNetworkInstanceChange('B') + resolveNetworkInfo({ + info: { + map: { + A: { + my_node_info: { + virtual_ipv4: { + address: { addr: '10.0.0.99' }, + network_length: 24, + }, + }, + routes: [], + }, + }, + }, + }) + + await Promise.all([staleUpdate, switchToB]) + + expect(mocks.startVpn).not.toHaveBeenCalled() + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + }) + + it('stops a native VPN with unknown ownership before retrying the selected instance', async () => { + setConfig('A') + mocks.getVpnStatus.mockResolvedValue({ + running: true, + ipv4Addr: '10.0.0.1/24', + routes: [], + }) + mocks.listNetworkInstanceIds.mockResolvedValue({ running_inst_ids: ['A'] }) + const vpn = await loadVpnModule() + + await vpn.syncMobileVpnService() + + expect(mocks.stopVpn).toHaveBeenCalledTimes(1) + expect(mocks.startVpn).not.toHaveBeenCalled() + }) +}) diff --git a/easytier-gui/src/composables/mobile_vpn.ts b/easytier-gui/src/composables/mobile_vpn.ts index d9987642..b269424f 100644 --- a/easytier-gui/src/composables/mobile_vpn.ts +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -2,6 +2,7 @@ import type { NetworkTypes } from 'easytier-frontend-lib' import { addPluginListener } from '@tauri-apps/api/core' import { Utils } from 'easytier-frontend-lib' import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api' +import { collectNetworkInfo, getConfig, listNetworkInstanceIds, setTunFd } from './backend' type Route = NetworkTypes.Route @@ -13,8 +14,16 @@ interface vpnStatus { dns: string | null | undefined } -let dhcpPollingTimer: NodeJS.Timeout | null = null -const DHCP_POLLING_INTERVAL = 2000 // 2秒后重试 +let vpnReconcileTimer: ReturnType | null = null +const VPN_RECONCILE_INTERVAL_MS = 2000 +const VPN_RECONCILE_MAX_ATTEMPTS = 60 + +let desiredVpnInstanceId: string | undefined +let activeVpnInstanceId: string | undefined +let vpnReconcileGeneration = 0 +let vpnReconcileAttempts = 0 +let vpnReconcileQueue: Promise = Promise.resolve() +let vpnPermissionRequest: Promise | null = null const curVpnStatus: vpnStatus = { running: false, @@ -24,7 +33,7 @@ const curVpnStatus: vpnStatus = { dns: undefined, } -async function requestVpnPermission() { +async function requestVpnPermissionOnce() { console.log('prepare vpn') const prepare_ret = await prepare_vpn() console.log('prepare vpn', JSON.stringify((prepare_ret))) @@ -40,6 +49,75 @@ async function requestVpnPermission() { return granted } +async function requestVpnPermission() { + if (vpnPermissionRequest) { + console.log('reuse pending vpn permission request') + return await vpnPermissionRequest + } + + const request = requestVpnPermissionOnce() + vpnPermissionRequest = request + try { + return await request + } + finally { + if (vpnPermissionRequest === request) { + vpnPermissionRequest = null + } + } +} + +function clearVpnReconcileTimer() { + if (vpnReconcileTimer) { + clearTimeout(vpnReconcileTimer) + vpnReconcileTimer = null + } +} + +function beginVpnReconcile(instanceId?: string) { + clearVpnReconcileTimer() + desiredVpnInstanceId = instanceId + vpnReconcileAttempts = 0 + vpnReconcileGeneration += 1 + return vpnReconcileGeneration +} + +function isCurrentVpnReconcile(instanceId: string, generation: number) { + return desiredVpnInstanceId === (instanceId || undefined) && vpnReconcileGeneration === generation +} + +function scheduleVpnReconcile(instanceId: string, generation: number, reason: string) { + if (!isCurrentVpnReconcile(instanceId, generation)) + return + + if (vpnReconcileAttempts >= VPN_RECONCILE_MAX_ATTEMPTS) { + console.error( + 'vpn service reconcile stopped after maximum attempts', + instanceId, + VPN_RECONCILE_MAX_ATTEMPTS, + reason, + ) + return + } + + clearVpnReconcileTimer() + vpnReconcileAttempts += 1 + console.log( + 'vpn service is not ready, retrying', + JSON.stringify({ + instanceId, + attempt: vpnReconcileAttempts, + maxAttempts: VPN_RECONCILE_MAX_ATTEMPTS, + delayMs: VPN_RECONCILE_INTERVAL_MS, + reason, + }), + ) + vpnReconcileTimer = setTimeout(() => { + vpnReconcileTimer = null + void enqueueVpnReconcile(instanceId, generation) + }, VPN_RECONCILE_INTERVAL_MS) +} + function resetVpnConfigStatus() { curVpnStatus.ipv4Addr = undefined curVpnStatus.ipv4Cidr = undefined @@ -50,6 +128,7 @@ function resetVpnConfigStatus() { function syncVpnStatusFromNative(status: Awaited>) { curVpnStatus.running = status?.running ?? false if (!curVpnStatus.running) { + activeVpnInstanceId = undefined resetVpnConfigStatus() return } @@ -84,6 +163,7 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) { async function doStopVpn(force = false) { const wasRunning = curVpnStatus.running if (!force && !wasRunning) { + activeVpnInstanceId = undefined return } console.log('stop vpn') @@ -93,10 +173,11 @@ async function doStopVpn(force = false) { await waitVpnStatus(false, 3) } + activeVpnInstanceId = undefined resetVpnConfigStatus() } -async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?: string) { +async function doStartVpn(instanceId: string, ipv4Addr: string, cidr: number, routes: string[], dns?: string) { if (curVpnStatus.running) { return } @@ -130,6 +211,7 @@ async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns? curVpnStatus.ipv4Cidr = cidr curVpnStatus.routes = routes curVpnStatus.dns = dns + activeVpnInstanceId = instanceId } async function onVpnServiceStart(payload: any) { @@ -145,6 +227,7 @@ async function onVpnServiceStart(payload: any) { async function onVpnServiceStop(payload: any) { console.log('vpn service stop', JSON.stringify(payload)) curVpnStatus.running = false + activeVpnInstanceId = undefined resetVpnConfigStatus() } @@ -186,14 +269,24 @@ function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes. return Array.from(new Set(ret)).sort() } -export async function onNetworkInstanceChange(instanceId: string) { - console.error('vpn service network instance change id', instanceId) +async function stopVpnOwnedByOtherInstance(instanceId: string, generation: number) { + if (!isCurrentVpnReconcile(instanceId, generation)) + return false - if (dhcpPollingTimer) { - clearTimeout(dhcpPollingTimer) - dhcpPollingTimer = null + if (curVpnStatus.running && activeVpnInstanceId !== instanceId) { + console.warn('vpn service owner changed', activeVpnInstanceId, instanceId) + await doStopVpn() } + return isCurrentVpnReconcile(instanceId, generation) +} + +async function reconcileNetworkInstance(instanceId: string, generation: number) { + if (!isCurrentVpnReconcile(instanceId, generation)) + return + + clearVpnReconcileTimer() + if (!instanceId) { console.warn('vpn service skipped because instance id is empty') if (curVpnStatus.running) { @@ -202,6 +295,9 @@ export async function onNetworkInstanceChange(instanceId: string) { return } const config = await getConfig(instanceId) + if (!isCurrentVpnReconcile(instanceId, generation)) + return + console.log('vpn service loaded config', instanceId, JSON.stringify({ no_tun: config.no_tun, dhcp: config.dhcp, @@ -209,11 +305,36 @@ export async function onNetworkInstanceChange(instanceId: string) { })) if (config.no_tun) { console.log('vpn service skipped because no_tun is enabled', instanceId) + if (activeVpnInstanceId === instanceId) { + await doStopVpn() + } return } - const curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId] - if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) { - console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg) + + if (!await stopVpnOwnedByOtherInstance(instanceId, generation)) + return + + let curNetworkInfo + try { + curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId] + } + catch (e) { + console.warn('vpn service network info query failed', instanceId, e) + scheduleVpnReconcile(instanceId, generation, 'network_info_query_failed') + return + } + + if (!isCurrentVpnReconcile(instanceId, generation)) + return + + if (!curNetworkInfo) { + scheduleVpnReconcile(instanceId, generation, 'network_info_unavailable') + return + } + + if (curNetworkInfo.error_msg?.length) { + console.warn('vpn service skipped because network instance failed', instanceId, curNetworkInfo.error_msg) + vpnReconcileAttempts = 0 await doStopVpn() return } @@ -221,18 +342,16 @@ export async function onNetworkInstanceChange(instanceId: string) { const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4 const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined - if (config.dhcp && (!virtual_ip || !virtual_ip.length)) { - console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms') - dhcpPollingTimer = setTimeout(() => { - onNetworkInstanceChange(instanceId) - }, DHCP_POLLING_INTERVAL) + if (!virtual_ip || !virtual_ip.length) { + scheduleVpnReconcile( + instanceId, + generation, + config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable', + ) return } - if (!virtual_ip || !virtual_ip.length) { - await doStopVpn() - return - } + vpnReconcileAttempts = 0 let network_length = virtualIpv4?.network_length if (!network_length) { @@ -262,7 +381,13 @@ export async function onNetworkInstanceChange(instanceId: string) { } try { - await doStartVpn(virtual_ip, network_length, routes, dns) + if (!isCurrentVpnReconcile(instanceId, generation)) + return + + await doStartVpn(instanceId, virtual_ip, network_length, routes, dns) + if (!isCurrentVpnReconcile(instanceId, generation) && activeVpnInstanceId === instanceId) { + await doStopVpn() + } } catch (e) { if (e instanceof Error && e.message === 'need_prepare') { @@ -278,6 +403,56 @@ export async function onNetworkInstanceChange(instanceId: string) { } } +function enqueueVpnTask(task: () => Promise) { + const run = vpnReconcileQueue + .catch((e) => { + console.error('previous vpn service reconcile failed', e) + }) + .then(task) + vpnReconcileQueue = run.catch((e) => { + console.error('vpn service reconcile failed', e) + }) + return run +} + +function enqueueVpnReconcile(instanceId: string, generation: number) { + return enqueueVpnTask(() => reconcileNetworkInstance(instanceId, generation)) +} + +export async function onNetworkInstanceChange(instanceId: string) { + const generation = beginVpnReconcile(instanceId || undefined) + + if (instanceId && await isNoTunEnabled(instanceId)) { + if (vpnReconcileGeneration !== generation) + return + + if (activeVpnInstanceId === instanceId) { + desiredVpnInstanceId = undefined + await enqueueVpnReconcile('', generation) + return + } + + desiredVpnInstanceId = activeVpnInstanceId + if (activeVpnInstanceId) { + await enqueueVpnReconcile(activeVpnInstanceId, generation) + } + return + } + + if (vpnReconcileGeneration !== generation) + return + + await enqueueVpnReconcile(instanceId, generation) +} + +export async function onNetworkInstanceUpdate(instanceId: string) { + if (!instanceId || instanceId !== desiredVpnInstanceId) + return + + const generation = beginVpnReconcile(instanceId) + await enqueueVpnReconcile(instanceId, generation) +} + async function isNoTunEnabled(instanceId: string | undefined) { if (!instanceId) { return false @@ -309,7 +484,12 @@ export async function prepareVpnService(instanceId: string) { if (await isNoTunEnabled(instanceId)) { return } - await requestVpnPermission() + + const generation = beginVpnReconcile(instanceId) + const stopPreviousOwner = enqueueVpnTask(async () => { + await stopVpnOwnedByOtherInstance(instanceId, generation) + }) + await Promise.all([requestVpnPermission(), stopPreviousOwner]) } export async function syncMobileVpnService() { @@ -321,10 +501,5 @@ export async function syncMobileVpnService() { return } - if (dhcpPollingTimer) { - clearTimeout(dhcpPollingTimer) - dhcpPollingTimer = null - } - - await doStopVpn(true) + await onNetworkInstanceChange('') } diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index d4edfabe..027824cf 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -9,7 +9,7 @@ import { exit } from '@tauri-apps/plugin-process' import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib" import type { MenuItem } from 'primevue/menuitem' import { useTray } from '~/composables/tray' -import { initMobileVpnService } from '~/composables/mobile_vpn' +import { initMobileVpnService, syncMobileVpnService } from '~/composables/mobile_vpn' import { GUIRemoteClient } from '~/modules/api' import { useToast, useConfirm } from 'primevue' @@ -213,7 +213,6 @@ onMounted(async () => { if (type() === 'android') { try { await initMobileVpnService() - console.error("easytier init vpn service done") } catch (e: any) { console.error("easytier init vpn service failed", e) } @@ -223,6 +222,14 @@ onMounted(async () => { currentMode.value = loadMode() await initWithMode(currentMode.value); + if (type() === 'android') { + try { + await syncMobileVpnService() + } catch (e: any) { + console.error("easytier sync vpn service failed", e) + } + } + onUnmounted(() => { cleanupFns.forEach(unlisten => unlisten()) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 18672c14..9d20edfa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -138,6 +138,9 @@ importers: vite-plugin-vue-layouts: specifier: ^0.11.0 version: 0.11.0(vite@5.4.21(@types/node@22.18.1))(vue-router@4.5.1(vue@3.5.21(typescript@5.6.3)))(vue@3.5.21(typescript@5.6.3)) + vitest: + specifier: ^2.1.9 + version: 2.1.9(@types/node@22.18.1)(happy-dom@16.8.1) vue-i18n: specifier: ^10.0.0 version: 10.0.8(vue@3.5.21(typescript@5.6.3))