From 35845c14e6d2a961f485e83d85278f54aef2b762 Mon Sep 17 00:00:00 2001 From: Zhengqi Zhang <1548069580@qq.com> Date: Mon, 17 Aug 2026 22:12:01 +0800 Subject: [PATCH] fix(android): reconcile VPN service after startup (#2491) Summary Reconcile the Android system VPN after the GUI has finished initializing the EasyTier core. Retry reconciliation while network information or the virtual IPv4 address is not ready yet. Serialize reconciliation work and de-duplicate concurrent VPN permission requests. Root cause On Android, the network instance can report that it has started before collectNetworkInfo exposes the instance state and virtual IPv4 address. The previous startup path treated that temporary state as a terminal failure, stopped VPN setup, and relied on another event to retry it. If no later event arrived, peers could connect successfully while the Android VpnService remained inactive until the user stopped and started the network again. PR #1628 added polling for the DHCP-specific empty-IP case. The same race can occur earlier, while network information is still unavailable, and can also affect static-IP configurations. --- easytier-gui/package.json | 2 + easytier-gui/src/auto-imports.d.ts | 2 + easytier-gui/src/composables/event.ts | 7 +- .../src/composables/mobile_vpn.test.ts | 232 +++++++++++++++++ easytier-gui/src/composables/mobile_vpn.ts | 233 +++++++++++++++--- easytier-gui/src/pages/index.vue | 11 +- pnpm-lock.yaml | 3 + 7 files changed, 456 insertions(+), 34 deletions(-) create mode 100644 easytier-gui/src/composables/mobile_vpn.test.ts 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))