mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-03 17:45:44 +00:00
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.
539 lines
16 KiB
Vue
539 lines
16 KiB
Vue
<script setup lang="ts">
|
|
|
|
import { type } from '@tauri-apps/plugin-os'
|
|
|
|
import { invoke } from '@tauri-apps/api/core'
|
|
import { writeText } from '@tauri-apps/plugin-clipboard-manager'
|
|
import { open } from '@tauri-apps/plugin-shell'
|
|
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, syncMobileVpnService } from '~/composables/mobile_vpn'
|
|
import { GUIRemoteClient } from '~/modules/api'
|
|
|
|
import { useToast, useConfirm } from 'primevue'
|
|
import { loadMode, saveMode, WebClientConfig, type Mode } from '~/composables/mode'
|
|
import { saveLastNetworkInstanceId, loadLastNetworkInstanceId } from '~/composables/config'
|
|
import ModeSwitcher from '~/components/ModeSwitcher.vue'
|
|
import { getEasytierVersion, getServiceStatus } from '~/composables/backend'
|
|
|
|
const { t, locale } = useI18n()
|
|
const confirm = useConfirm()
|
|
const aboutVisible = ref(false)
|
|
const modeDialogVisible = ref(false)
|
|
const currentMode = ref<Mode>({ mode: 'normal' })
|
|
const editingMode = ref<Mode>({ mode: 'normal' })
|
|
const isModeSaving = ref(false)
|
|
const manualDisconnect = ref(false)
|
|
|
|
const configServerDialogVisible = ref(false)
|
|
const configServerConnected = ref(false)
|
|
|
|
async function openModeDialog() {
|
|
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
|
|
modeDialogVisible.value = true
|
|
}
|
|
|
|
async function onModeSave() {
|
|
if (isModeSaving.value) {
|
|
return;
|
|
}
|
|
isModeSaving.value = true
|
|
try {
|
|
await initWithMode(editingMode.value);
|
|
modeDialogVisible.value = false
|
|
}
|
|
catch (e: any) {
|
|
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
|
|
console.error("Error switching mode", e, currentMode.value, editingMode.value)
|
|
await initWithMode(currentMode.value);
|
|
}
|
|
finally {
|
|
isModeSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function onUninstallService() {
|
|
confirm.require({
|
|
message: t('mode.uninstall_service_confirm'),
|
|
header: t('mode.uninstall_service'),
|
|
icon: 'pi pi-exclamation-triangle',
|
|
rejectProps: {
|
|
label: t('web.common.cancel'),
|
|
severity: 'secondary',
|
|
outlined: true
|
|
},
|
|
acceptProps: {
|
|
label: t('mode.uninstall_service'),
|
|
severity: 'danger'
|
|
},
|
|
accept: async () => {
|
|
isModeSaving.value = true
|
|
try {
|
|
await initWithMode({ ...currentMode.value, mode: 'normal' });
|
|
await initService(undefined)
|
|
toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.uninstall_service_success'), life: 3000 })
|
|
modeDialogVisible.value = false
|
|
} catch (e: any) {
|
|
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
|
|
console.error("Error uninstalling service", e)
|
|
} finally {
|
|
isModeSaving.value = false
|
|
}
|
|
},
|
|
});
|
|
}
|
|
|
|
function stripModeMetadata(mode: Mode) {
|
|
if (mode.mode !== 'service') {
|
|
return mode
|
|
}
|
|
|
|
const serviceConfig = { ...mode }
|
|
delete serviceConfig.installed_core_version
|
|
return serviceConfig
|
|
}
|
|
|
|
function modeConfigChanged(next: Mode) {
|
|
return JSON.stringify(stripModeMetadata(next)) !== JSON.stringify(stripModeMetadata(currentMode.value))
|
|
}
|
|
|
|
async function onStopService() {
|
|
isModeSaving.value = true
|
|
manualDisconnect.value = true
|
|
try {
|
|
await setServiceStatus(false)
|
|
toast.add({ severity: 'success', summary: t('web.common.success'), detail: t('mode.stop_service_success'), life: 3000 })
|
|
modeDialogVisible.value = false
|
|
}
|
|
catch (e: any) {
|
|
toast.add({ severity: 'error', summary: t('error'), detail: e, life: 10000 })
|
|
console.error("Error stopping service", e)
|
|
}
|
|
finally {
|
|
isModeSaving.value = false
|
|
}
|
|
}
|
|
|
|
async function initWithMode(mode: Mode) {
|
|
const running_inst_ids = (await remoteClient.value.list_network_instance_ids().catch(() => undefined))?.running_inst_ids ?? []
|
|
|
|
if (currentMode.value.mode === 'service' && mode.mode !== 'service') {
|
|
let serviceStatus = await getServiceStatus()
|
|
if (serviceStatus === "Running") {
|
|
manualDisconnect.value = true
|
|
await setServiceStatus(false)
|
|
serviceStatus = await getServiceStatus()
|
|
for (let i = 0; i < 10; i++) { // macOS takes a while to stop the service
|
|
if (serviceStatus === "Stopped") {
|
|
break;
|
|
}
|
|
await new Promise(resolve => setTimeout(resolve, 100))
|
|
serviceStatus = await getServiceStatus()
|
|
}
|
|
}
|
|
if (serviceStatus === "Stopped") {
|
|
await initService(undefined)
|
|
}
|
|
}
|
|
|
|
let url: string | undefined = undefined
|
|
let retrys = 1
|
|
switch (mode.mode) {
|
|
case 'remote':
|
|
if (!mode.remote_rpc_address) {
|
|
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.remote_rpc_address_empty'), life: 10000 })
|
|
return initWithMode({ ...mode, mode: 'normal' });
|
|
}
|
|
url = mode.remote_rpc_address
|
|
break;
|
|
case 'service': {
|
|
if (!mode.config_dir || !mode.file_log_dir || !mode.file_log_level || !mode.rpc_portal) {
|
|
toast.add({ severity: 'error', summary: t('error'), detail: t('mode.service_config_empty'), life: 10000 })
|
|
return initWithMode({ ...mode, mode: 'normal' });
|
|
}
|
|
let serviceStatus = await getServiceStatus()
|
|
const coreVersion = await getEasytierVersion()
|
|
if (serviceStatus === "NotInstalled" || modeConfigChanged(mode) || mode.installed_core_version !== coreVersion) {
|
|
mode.config_server_url = mode.config_server_url || undefined
|
|
await initService({
|
|
config_dir: mode.config_dir,
|
|
file_log_dir: mode.file_log_dir,
|
|
file_log_level: mode.file_log_level,
|
|
rpc_portal: mode.rpc_portal,
|
|
config_server: mode.config_server_url,
|
|
})
|
|
mode.installed_core_version = coreVersion
|
|
serviceStatus = await getServiceStatus()
|
|
}
|
|
if (serviceStatus === "Stopped") {
|
|
await setServiceStatus(true)
|
|
}
|
|
url = "tcp://" + mode.rpc_portal.replace("0.0.0.0", "127.0.0.1")
|
|
retrys = 5
|
|
break;
|
|
}
|
|
case 'normal':
|
|
url = mode.rpc_portal;
|
|
break;
|
|
}
|
|
for (let i = 0; i < retrys; i++) {
|
|
try {
|
|
await connectRpcClient(mode.mode === 'normal', url)
|
|
break;
|
|
} catch (e) {
|
|
if (i === retrys - 1) {
|
|
const errMsg = e instanceof Error ? e.message : String(e)
|
|
toast.add({
|
|
severity: 'error',
|
|
summary: t('error'),
|
|
detail: t('mode.rpc_connection_failed', { error: errMsg }),
|
|
life: 1000,
|
|
})
|
|
throw e;
|
|
}
|
|
console.error("Error connecting rpc client, retrying...", e)
|
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
|
}
|
|
}
|
|
await sendConfigs(running_inst_ids.map(Utils.UuidToStr))
|
|
if (mode.mode === 'normal') {
|
|
mode.config_server_url = mode.config_server_url || undefined
|
|
initWebClient(mode.config_server_url)
|
|
}
|
|
currentMode.value = mode
|
|
saveMode(mode)
|
|
clientRunning.value = await isClientRunning()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
const cleanupFns: Array<() => void> = []
|
|
|
|
if (type() === 'android') {
|
|
try {
|
|
await initMobileVpnService()
|
|
} catch (e: any) {
|
|
console.error("easytier init vpn service failed", e)
|
|
}
|
|
}
|
|
|
|
cleanupFns.push(await listenGlobalEvents())
|
|
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())
|
|
})
|
|
});
|
|
|
|
useTray(true)
|
|
let toast = useToast();
|
|
|
|
const remoteClient = computed(() => new GUIRemoteClient());
|
|
const instanceId = ref<string | undefined>(undefined);
|
|
const clientRunning = ref(false);
|
|
|
|
watch(instanceId, (newVal) => {
|
|
if (newVal) {
|
|
saveLastNetworkInstanceId(newVal);
|
|
}
|
|
});
|
|
|
|
watch(clientRunning, async (newVal, oldVal) => {
|
|
if (!newVal && oldVal) {
|
|
if (manualDisconnect.value) {
|
|
manualDisconnect.value = false
|
|
return
|
|
}
|
|
await reconnectClient()
|
|
} else if (newVal && !oldVal) {
|
|
const lastInstanceId = loadLastNetworkInstanceId();
|
|
if (lastInstanceId) {
|
|
instanceId.value = lastInstanceId;
|
|
}
|
|
}
|
|
})
|
|
|
|
onMounted(async () => {
|
|
clientRunning.value = await isClientRunning().catch(() => false)
|
|
const timer = setInterval(async () => {
|
|
try {
|
|
clientRunning.value = await isClientRunning()
|
|
} catch (e) {
|
|
clientRunning.value = false
|
|
console.error("Error checking client running status", e)
|
|
}
|
|
}, 1000)
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(timer)
|
|
})
|
|
})
|
|
async function reconnectClient() {
|
|
editingMode.value = JSON.parse(JSON.stringify(loadMode()));
|
|
await onModeSave()
|
|
}
|
|
|
|
onMounted(async () => {
|
|
window.setTimeout(async () => {
|
|
await setTrayMenu([
|
|
await MenuItemShow(t('tray.show')),
|
|
await MenuItemExit(t('tray.exit')),
|
|
])
|
|
}, 1000)
|
|
})
|
|
|
|
let current_log_level = 'off'
|
|
|
|
const log_menu = ref()
|
|
// 从后端获取正确的日志路径
|
|
async function getLogDirPath(): Promise<string> {
|
|
return await invoke<string>('get_log_dir_path')
|
|
}
|
|
|
|
const log_menu_items_popup: Ref<MenuItem[]> = ref([
|
|
...['off', 'warn', 'info', 'debug', 'trace'].map(level => ({
|
|
label: () => t(`logging_level_${level}`) + (current_log_level === level ? ' ✓' : ''),
|
|
command: async () => {
|
|
current_log_level = level
|
|
await setLoggingLevel(level)
|
|
},
|
|
})),
|
|
{
|
|
separator: true,
|
|
},
|
|
{
|
|
label: () => t('logging_open_dir'),
|
|
icon: 'pi pi-folder-open',
|
|
command: async () => {
|
|
// console.log('open log dir', await getLogDirPath())
|
|
await open(await getLogDirPath())
|
|
},
|
|
visible: () => type() !== 'android',
|
|
},
|
|
{
|
|
label: () => t('logging_copy_dir'),
|
|
icon: 'pi pi-tablet',
|
|
command: async () => {
|
|
await writeText(await getLogDirPath())
|
|
},
|
|
},
|
|
])
|
|
|
|
function toggle_log_menu(event: any) {
|
|
log_menu.value.toggle(event)
|
|
}
|
|
|
|
function getLabel(item: MenuItem) {
|
|
return typeof item.label === 'function' ? item.label() : item.label
|
|
}
|
|
|
|
const setting_menu_items: Ref<MenuItem[]> = ref([
|
|
{
|
|
label: () => t('exchange_language'),
|
|
icon: 'pi pi-language',
|
|
command: async () => {
|
|
await I18nUtils.loadLanguageAsync((locale.value === 'en' ? 'cn' : 'en'))
|
|
await setTrayMenu([
|
|
await MenuItemShow(t('tray.show')),
|
|
await MenuItemExit(t('tray.exit')),
|
|
])
|
|
},
|
|
},
|
|
{
|
|
label: () => `${t('mode.switch_mode')}: ${t('mode.' + currentMode.value.mode)}`,
|
|
icon: 'pi pi-sync',
|
|
command: openModeDialog,
|
|
visible: () => type() !== 'android',
|
|
},
|
|
{
|
|
label: () => `${t('config-server.title')}${t('config-server.' + configServerConnectionStatus.value)}`,
|
|
icon: 'pi pi-globe',
|
|
command: openConfigServerDialog,
|
|
visible: () => ["normal", "service"].includes(currentMode.value.mode),
|
|
},
|
|
{
|
|
key: 'logging_menu',
|
|
label: () => t('logging'),
|
|
icon: 'pi pi-file',
|
|
items: [], // Keep this to show it's a parent menu
|
|
},
|
|
{
|
|
label: () => t('about.title'),
|
|
icon: 'pi pi-at',
|
|
command: async () => {
|
|
aboutVisible.value = true
|
|
},
|
|
},
|
|
{
|
|
label: () => t('exit'),
|
|
icon: 'pi pi-power-off',
|
|
command: async () => {
|
|
await exit(1)
|
|
},
|
|
},
|
|
])
|
|
|
|
async function connectRpcClient(isNormalMode: boolean, url?: string) {
|
|
await initRpcConnection(isNormalMode, url)
|
|
console.log("easytier rpc connection established, isNormalMode: ", isNormalMode)
|
|
}
|
|
|
|
async function openConfigServerDialog() {
|
|
editingMode.value = JSON.parse(JSON.stringify(loadMode()))
|
|
configServerDialogVisible.value = true
|
|
}
|
|
async function onConfigServerSave() {
|
|
if (JSON.stringify(currentMode.value) === JSON.stringify(editingMode.value)) {
|
|
configServerDialogVisible.value = false
|
|
return;
|
|
}
|
|
if (editingMode.value.mode === 'service') {
|
|
await new Promise<void>((resolve, reject) => {
|
|
confirm.require({
|
|
message: t('config-server.update_service_confirm'),
|
|
icon: 'pi pi-exclamation-triangle',
|
|
rejectProps: {
|
|
label: t('web.common.cancel'),
|
|
severity: 'secondary',
|
|
outlined: true
|
|
},
|
|
acceptProps: {
|
|
label: t('web.common.confirm'),
|
|
},
|
|
accept: async () => {
|
|
resolve()
|
|
},
|
|
reject: () => {
|
|
reject()
|
|
}
|
|
});
|
|
})
|
|
}
|
|
console.log("Saving config server url", (editingMode.value as WebClientConfig).config_server_url)
|
|
await onModeSave();
|
|
configServerDialogVisible.value = false
|
|
}
|
|
onMounted(() => {
|
|
const timer = setInterval(async () => {
|
|
if (currentMode.value.mode !== 'normal') return;
|
|
if (!currentMode.value.config_server_url) return;
|
|
configServerConnected.value = await isWebClientConnected();
|
|
}, 1000)
|
|
|
|
onUnmounted(() => {
|
|
clearInterval(timer)
|
|
})
|
|
})
|
|
const configServerConnectionStatus = computed(() => {
|
|
if (currentMode.value.mode !== 'normal') {
|
|
return 'unknown'
|
|
}
|
|
if (!currentMode.value.config_server_url) {
|
|
return 'disconnected'
|
|
}
|
|
return configServerConnected.value ? 'connected' : 'connecting'
|
|
})
|
|
|
|
</script>
|
|
|
|
<template>
|
|
<div id="root" class="flex flex-col">
|
|
<Dialog v-model:visible="aboutVisible" modal :header="t('about.title')" :style="{ width: '70%' }">
|
|
<About />
|
|
</Dialog>
|
|
<Dialog v-model:visible="modeDialogVisible" modal :header="t('mode.switch_mode')" :style="{ width: '50vw' }">
|
|
<ModeSwitcher v-model="editingMode" @uninstall-service="onUninstallService" @stop-service="onStopService" />
|
|
<template #footer>
|
|
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="modeDialogVisible = false" text />
|
|
<Button :label="t('web.common.save')" icon="pi pi-save" @click="onModeSave" autofocus :loading="isModeSaving" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<Dialog v-model:visible="configServerDialogVisible" modal :header="t('config-server.title')"
|
|
:style="{ width: '50vw' }">
|
|
<div class="flex flex-col gap-3">
|
|
<label for="config-server-address">{{ t('config-server.address') }}</label>
|
|
<InputText id="config-server-address" v-model="(editingMode as WebClientConfig).config_server_url"
|
|
:placeholder="t('config-server.address_placeholder')" />
|
|
<small class="p-text-secondary whitespace-pre-wrap">{{ t('config-server.description') }}</small>
|
|
</div>
|
|
<template #footer>
|
|
<Button :label="t('web.common.cancel')" icon="pi pi-times" @click="configServerDialogVisible = false" text />
|
|
<Button :label="t('web.common.save')" icon="pi pi-save" @click="onConfigServerSave" autofocus
|
|
:loading="isModeSaving" />
|
|
</template>
|
|
</Dialog>
|
|
|
|
<Menu ref="log_menu" :model="log_menu_items_popup" :popup="true" />
|
|
|
|
<RemoteManagement v-if="clientRunning" class="flex-1 overflow-y-auto" :api="remoteClient"
|
|
:pause-auto-refresh="isModeSaving" v-model:instance-id="instanceId" />
|
|
<div v-else class="empty-state flex-1 flex flex-col items-center py-12">
|
|
<i class="pi pi-server text-5xl text-secondary mb-4 opacity-50"></i>
|
|
<div class="text-xl text-center font-medium mb-3">{{ t('client.not_running') }}
|
|
</div>
|
|
<Button @click="reconnectClient" :loading="isModeSaving" :label="t('client.retry')" icon="pi pi-replay"
|
|
iconPos="left" />
|
|
</div>
|
|
|
|
<Menubar :model="setting_menu_items" breakpoint="795px">
|
|
<template #item="{ item, props }">
|
|
<a v-if="item.key === 'logging_menu'" v-bind="props.action" @click="toggle_log_menu">
|
|
<span :class="item.icon" />
|
|
<span class="p-menubar-item-label">{{ getLabel(item) }}</span>
|
|
<span class="pi pi-angle-down p-menubar-item-icon text-[9px]"></span>
|
|
</a>
|
|
<a v-else v-bind="props.action">
|
|
<span :class="item.icon" />
|
|
<span class="p-menubar-item-label">{{ getLabel(item) }}</span>
|
|
</a>
|
|
</template>
|
|
</Menubar>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped lang="postcss">
|
|
#root {
|
|
height: 100vh;
|
|
width: 100vw;
|
|
}
|
|
|
|
.p-dropdown :deep(.p-dropdown-panel .p-dropdown-items .p-dropdown-item) {
|
|
padding: 0 0.5rem;
|
|
}
|
|
</style>
|
|
|
|
<style>
|
|
body {
|
|
height: 100vh;
|
|
width: 100vw;
|
|
padding: 0;
|
|
margin: 0;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.p-menubar .p-menuitem {
|
|
margin: 0;
|
|
}
|
|
|
|
.p-select-overlay {
|
|
max-width: calc(100% - 2rem);
|
|
}
|
|
|
|
/*
|
|
|
|
.p-tabview-panel {
|
|
height: 100%;
|
|
} */
|
|
</style>
|