Files
Easytier/easytier-gui/src/pages/index.vue
T

532 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 } 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()
console.error("easytier init vpn service done")
} catch (e: any) {
console.error("easytier init vpn service failed", e)
}
}
cleanupFns.push(await listenGlobalEvents())
currentMode.value = loadMode()
await initWithMode(currentMode.value);
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>