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.
This commit is contained in:
Zhengqi Zhang
2026-08-17 22:12:01 +08:00
committed by GitHub
parent 282cd92f2c
commit 35845c14e6
7 changed files with 456 additions and 34 deletions
+2
View File
@@ -8,6 +8,7 @@
"dev": "pnpm --dir ../easytier-web/frontend-lib build && vite", "dev": "pnpm --dir ../easytier-web/frontend-lib build && vite",
"build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build", "build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build",
"preview": "vite preview", "preview": "vite preview",
"test:mobile-vpn": "vitest run src/composables/mobile_vpn.test.ts",
"tauri": "tauri", "tauri": "tauri",
"lint": "eslint . --ignore-pattern src-tauri", "lint": "eslint . --ignore-pattern src-tauri",
"lint:fix": "eslint . --ignore-pattern src-tauri --fix" "lint:fix": "eslint . --ignore-pattern src-tauri --fix"
@@ -56,6 +57,7 @@
"vite": "^5.4.21", "vite": "^5.4.21",
"vite-plugin-vue-devtools": "^7.4.6", "vite-plugin-vue-devtools": "^7.4.6",
"vite-plugin-vue-layouts": "^0.11.0", "vite-plugin-vue-layouts": "^0.11.0",
"vitest": "^2.1.9",
"vue-i18n": "^10.0.0", "vue-i18n": "^10.0.0",
"vue-tsc": "^2.1.10" "vue-tsc": "^2.1.10"
} }
+2
View File
@@ -63,6 +63,7 @@ declare global {
const onErrorCaptured: typeof import('vue')['onErrorCaptured'] const onErrorCaptured: typeof import('vue')['onErrorCaptured']
const onMounted: typeof import('vue')['onMounted'] const onMounted: typeof import('vue')['onMounted']
const onNetworkInstanceChange: typeof import('./composables/mobile_vpn')['onNetworkInstanceChange'] const onNetworkInstanceChange: typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']
const onNetworkInstanceUpdate: typeof import('./composables/mobile_vpn')['onNetworkInstanceUpdate']
const onRenderTracked: typeof import('vue')['onRenderTracked'] const onRenderTracked: typeof import('vue')['onRenderTracked']
const onRenderTriggered: typeof import('vue')['onRenderTriggered'] const onRenderTriggered: typeof import('vue')['onRenderTriggered']
const onScopeDispose: typeof import('vue')['onScopeDispose'] const onScopeDispose: typeof import('vue')['onScopeDispose']
@@ -189,6 +190,7 @@ declare module 'vue' {
readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']> readonly onErrorCaptured: UnwrapRef<typeof import('vue')['onErrorCaptured']>
readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']> readonly onMounted: UnwrapRef<typeof import('vue')['onMounted']>
readonly onNetworkInstanceChange: UnwrapRef<typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']> readonly onNetworkInstanceChange: UnwrapRef<typeof import('./composables/mobile_vpn')['onNetworkInstanceChange']>
readonly onNetworkInstanceUpdate: UnwrapRef<typeof import('./composables/mobile_vpn')['onNetworkInstanceUpdate']>
readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']> readonly onRenderTracked: UnwrapRef<typeof import('vue')['onRenderTracked']>
readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']> readonly onRenderTriggered: UnwrapRef<typeof import('vue')['onRenderTriggered']>
readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']> readonly onScopeDispose: UnwrapRef<typeof import('vue')['onScopeDispose']>
+4 -3
View File
@@ -3,6 +3,7 @@ import { type } from "@tauri-apps/plugin-os";
import { NetworkTypes } from "easytier-frontend-lib" import { NetworkTypes } from "easytier-frontend-lib"
import { Utils } from "easytier-frontend-lib"; import { Utils } from "easytier-frontend-lib";
import { normalizeConfigSource } from './config_source' import { normalizeConfigSource } from './config_source'
import { onNetworkInstanceUpdate } from './mobile_vpn'
interface StoredGuiConfig { interface StoredGuiConfig {
config: NetworkTypes.NetworkConfig config: NetworkTypes.NetworkConfig
@@ -80,7 +81,7 @@ async function onDhcpIpChanged(event: Event<unknown>) {
const instanceId = normalizeInstanceIdPayload(event.payload) const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`); console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`);
if (type() === 'android') { if (type() === 'android') {
await onNetworkInstanceChange(instanceId); await onNetworkInstanceUpdate(instanceId);
} }
} }
@@ -88,13 +89,13 @@ async function onProxyCidrsUpdated(event: Event<unknown>) {
const instanceId = normalizeInstanceIdPayload(event.payload) const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${instanceId}`); console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${instanceId}`);
if (type() === 'android') { if (type() === 'android') {
await onNetworkInstanceChange(instanceId); await onNetworkInstanceUpdate(instanceId);
} }
} }
async function onEventLagged(event: Event<unknown>) { async function onEventLagged(event: Event<unknown>) {
if (type() === 'android') { if (type() === 'android') {
await onNetworkInstanceChange(normalizeInstanceIdPayload(event.payload)); await onNetworkInstanceUpdate(normalizeInstanceIdPayload(event.payload));
} }
} }
@@ -0,0 +1,232 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => {
const listeners = new Map<string, (payload: unknown) => Promise<void>>()
const configs = new Map<string, Record<string, unknown>>()
const networkInfo = new Map<string, unknown>()
return {
listeners,
configs,
networkInfo,
addPluginListener: vi.fn(async (_plugin: string, event: string, listener: (payload: unknown) => Promise<void>) => {
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<Record<string, unknown>>>(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<string, unknown> } }
let resolveNetworkInfo: (value: NetworkInfoResponse) => void = () => undefined
let markCollectStarted: () => void = () => undefined
const collectStarted = new Promise<void>((resolve) => {
markCollectStarted = resolve
})
mocks.collectNetworkInfo.mockImplementationOnce(async () => await new Promise<NetworkInfoResponse>((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()
})
})
+204 -29
View File
@@ -2,6 +2,7 @@ import type { NetworkTypes } from 'easytier-frontend-lib'
import { addPluginListener } from '@tauri-apps/api/core' import { addPluginListener } from '@tauri-apps/api/core'
import { Utils } from 'easytier-frontend-lib' import { Utils } from 'easytier-frontend-lib'
import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api' 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 type Route = NetworkTypes.Route
@@ -13,8 +14,16 @@ interface vpnStatus {
dns: string | null | undefined dns: string | null | undefined
} }
let dhcpPollingTimer: NodeJS.Timeout | null = null let vpnReconcileTimer: ReturnType<typeof setTimeout> | null = null
const DHCP_POLLING_INTERVAL = 2000 // 2秒后重试 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<void> = Promise.resolve()
let vpnPermissionRequest: Promise<boolean> | null = null
const curVpnStatus: vpnStatus = { const curVpnStatus: vpnStatus = {
running: false, running: false,
@@ -24,7 +33,7 @@ const curVpnStatus: vpnStatus = {
dns: undefined, dns: undefined,
} }
async function requestVpnPermission() { async function requestVpnPermissionOnce() {
console.log('prepare vpn') console.log('prepare vpn')
const prepare_ret = await prepare_vpn() const prepare_ret = await prepare_vpn()
console.log('prepare vpn', JSON.stringify((prepare_ret))) console.log('prepare vpn', JSON.stringify((prepare_ret)))
@@ -40,6 +49,75 @@ async function requestVpnPermission() {
return granted 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() { function resetVpnConfigStatus() {
curVpnStatus.ipv4Addr = undefined curVpnStatus.ipv4Addr = undefined
curVpnStatus.ipv4Cidr = undefined curVpnStatus.ipv4Cidr = undefined
@@ -50,6 +128,7 @@ function resetVpnConfigStatus() {
function syncVpnStatusFromNative(status: Awaited<ReturnType<typeof get_vpn_status>>) { function syncVpnStatusFromNative(status: Awaited<ReturnType<typeof get_vpn_status>>) {
curVpnStatus.running = status?.running ?? false curVpnStatus.running = status?.running ?? false
if (!curVpnStatus.running) { if (!curVpnStatus.running) {
activeVpnInstanceId = undefined
resetVpnConfigStatus() resetVpnConfigStatus()
return return
} }
@@ -84,6 +163,7 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
async function doStopVpn(force = false) { async function doStopVpn(force = false) {
const wasRunning = curVpnStatus.running const wasRunning = curVpnStatus.running
if (!force && !wasRunning) { if (!force && !wasRunning) {
activeVpnInstanceId = undefined
return return
} }
console.log('stop vpn') console.log('stop vpn')
@@ -93,10 +173,11 @@ async function doStopVpn(force = false) {
await waitVpnStatus(false, 3) await waitVpnStatus(false, 3)
} }
activeVpnInstanceId = undefined
resetVpnConfigStatus() 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) { if (curVpnStatus.running) {
return return
} }
@@ -130,6 +211,7 @@ async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?
curVpnStatus.ipv4Cidr = cidr curVpnStatus.ipv4Cidr = cidr
curVpnStatus.routes = routes curVpnStatus.routes = routes
curVpnStatus.dns = dns curVpnStatus.dns = dns
activeVpnInstanceId = instanceId
} }
async function onVpnServiceStart(payload: any) { async function onVpnServiceStart(payload: any) {
@@ -145,6 +227,7 @@ async function onVpnServiceStart(payload: any) {
async function onVpnServiceStop(payload: any) { async function onVpnServiceStop(payload: any) {
console.log('vpn service stop', JSON.stringify(payload)) console.log('vpn service stop', JSON.stringify(payload))
curVpnStatus.running = false curVpnStatus.running = false
activeVpnInstanceId = undefined
resetVpnConfigStatus() resetVpnConfigStatus()
} }
@@ -186,14 +269,24 @@ function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.
return Array.from(new Set(ret)).sort() return Array.from(new Set(ret)).sort()
} }
export async function onNetworkInstanceChange(instanceId: string) { async function stopVpnOwnedByOtherInstance(instanceId: string, generation: number) {
console.error('vpn service network instance change id', instanceId) if (!isCurrentVpnReconcile(instanceId, generation))
return false
if (dhcpPollingTimer) { if (curVpnStatus.running && activeVpnInstanceId !== instanceId) {
clearTimeout(dhcpPollingTimer) console.warn('vpn service owner changed', activeVpnInstanceId, instanceId)
dhcpPollingTimer = null await doStopVpn()
} }
return isCurrentVpnReconcile(instanceId, generation)
}
async function reconcileNetworkInstance(instanceId: string, generation: number) {
if (!isCurrentVpnReconcile(instanceId, generation))
return
clearVpnReconcileTimer()
if (!instanceId) { if (!instanceId) {
console.warn('vpn service skipped because instance id is empty') console.warn('vpn service skipped because instance id is empty')
if (curVpnStatus.running) { if (curVpnStatus.running) {
@@ -202,6 +295,9 @@ export async function onNetworkInstanceChange(instanceId: string) {
return return
} }
const config = await getConfig(instanceId) const config = await getConfig(instanceId)
if (!isCurrentVpnReconcile(instanceId, generation))
return
console.log('vpn service loaded config', instanceId, JSON.stringify({ console.log('vpn service loaded config', instanceId, JSON.stringify({
no_tun: config.no_tun, no_tun: config.no_tun,
dhcp: config.dhcp, dhcp: config.dhcp,
@@ -209,11 +305,36 @@ export async function onNetworkInstanceChange(instanceId: string) {
})) }))
if (config.no_tun) { if (config.no_tun) {
console.log('vpn service skipped because no_tun is enabled', instanceId) console.log('vpn service skipped because no_tun is enabled', instanceId)
if (activeVpnInstanceId === instanceId) {
await doStopVpn()
}
return return
} }
const curNetworkInfo = (await collectNetworkInfo(instanceId))?.info?.map?.[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) { if (!await stopVpnOwnedByOtherInstance(instanceId, generation))
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg) 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() await doStopVpn()
return return
} }
@@ -221,18 +342,16 @@ export async function onNetworkInstanceChange(instanceId: string) {
const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4 const virtualIpv4 = curNetworkInfo.my_node_info?.virtual_ipv4
const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined const virtual_ip = virtualIpv4?.address?.addr ? Utils.ipv4ToString(virtualIpv4.address) : undefined
if (config.dhcp && (!virtual_ip || !virtual_ip.length)) { if (!virtual_ip || !virtual_ip.length) {
console.log('DHCP enabled but no IP yet, will retry in', DHCP_POLLING_INTERVAL, 'ms') scheduleVpnReconcile(
dhcpPollingTimer = setTimeout(() => { instanceId,
onNetworkInstanceChange(instanceId) generation,
}, DHCP_POLLING_INTERVAL) config.dhcp ? 'dhcp_ipv4_unavailable' : 'static_ipv4_unavailable',
)
return return
} }
if (!virtual_ip || !virtual_ip.length) { vpnReconcileAttempts = 0
await doStopVpn()
return
}
let network_length = virtualIpv4?.network_length let network_length = virtualIpv4?.network_length
if (!network_length) { if (!network_length) {
@@ -262,7 +381,13 @@ export async function onNetworkInstanceChange(instanceId: string) {
} }
try { 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) { catch (e) {
if (e instanceof Error && e.message === 'need_prepare') { if (e instanceof Error && e.message === 'need_prepare') {
@@ -278,6 +403,56 @@ export async function onNetworkInstanceChange(instanceId: string) {
} }
} }
function enqueueVpnTask(task: () => Promise<void>) {
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) { async function isNoTunEnabled(instanceId: string | undefined) {
if (!instanceId) { if (!instanceId) {
return false return false
@@ -309,7 +484,12 @@ export async function prepareVpnService(instanceId: string) {
if (await isNoTunEnabled(instanceId)) { if (await isNoTunEnabled(instanceId)) {
return return
} }
await requestVpnPermission()
const generation = beginVpnReconcile(instanceId)
const stopPreviousOwner = enqueueVpnTask(async () => {
await stopVpnOwnedByOtherInstance(instanceId, generation)
})
await Promise.all([requestVpnPermission(), stopPreviousOwner])
} }
export async function syncMobileVpnService() { export async function syncMobileVpnService() {
@@ -321,10 +501,5 @@ export async function syncMobileVpnService() {
return return
} }
if (dhcpPollingTimer) { await onNetworkInstanceChange('')
clearTimeout(dhcpPollingTimer)
dhcpPollingTimer = null
}
await doStopVpn(true)
} }
+9 -2
View File
@@ -9,7 +9,7 @@ import { exit } from '@tauri-apps/plugin-process'
import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib" import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib"
import type { MenuItem } from 'primevue/menuitem' import type { MenuItem } from 'primevue/menuitem'
import { useTray } from '~/composables/tray' import { useTray } from '~/composables/tray'
import { initMobileVpnService } from '~/composables/mobile_vpn' import { initMobileVpnService, syncMobileVpnService } from '~/composables/mobile_vpn'
import { GUIRemoteClient } from '~/modules/api' import { GUIRemoteClient } from '~/modules/api'
import { useToast, useConfirm } from 'primevue' import { useToast, useConfirm } from 'primevue'
@@ -213,7 +213,6 @@ onMounted(async () => {
if (type() === 'android') { if (type() === 'android') {
try { try {
await initMobileVpnService() await initMobileVpnService()
console.error("easytier init vpn service done")
} catch (e: any) { } catch (e: any) {
console.error("easytier init vpn service failed", e) console.error("easytier init vpn service failed", e)
} }
@@ -223,6 +222,14 @@ onMounted(async () => {
currentMode.value = loadMode() currentMode.value = loadMode()
await initWithMode(currentMode.value); await initWithMode(currentMode.value);
if (type() === 'android') {
try {
await syncMobileVpnService()
} catch (e: any) {
console.error("easytier sync vpn service failed", e)
}
}
onUnmounted(() => { onUnmounted(() => {
cleanupFns.forEach(unlisten => unlisten()) cleanupFns.forEach(unlisten => unlisten())
}) })
+3
View File
@@ -138,6 +138,9 @@ importers:
vite-plugin-vue-layouts: vite-plugin-vue-layouts:
specifier: ^0.11.0 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)) 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: vue-i18n:
specifier: ^10.0.0 specifier: ^10.0.0
version: 10.0.8(vue@3.5.21(typescript@5.6.3)) version: 10.0.8(vue@3.5.21(typescript@5.6.3))