From a1bec48dc9cbdfb70f63a179bbce0bd0f666e253 Mon Sep 17 00:00:00 2001 From: KKRainbow <443152178@qq.com> Date: Sun, 29 Mar 2026 23:16:32 +0800 Subject: [PATCH] fix android vpn permission grant (#2023) * fix android vpn permission grant * fix url input behaviour --- .../src-tauri/capabilities/migrated.json | 3 +- easytier-gui/src-tauri/src/lib.rs | 39 +++- easytier-gui/src/auto-imports.d.ts | 2 + easytier-gui/src/composables/event.ts | 61 +++++-- easytier-gui/src/composables/mobile_vpn.ts | 166 +++++++++++++++--- easytier-gui/src/pages/index.vue | 37 ++-- .../frontend-lib/src/components/Config.vue | 7 +- .../frontend-lib/src/components/UrlInput.vue | 125 +++++++++---- easytier-web/frontend-lib/src/locales/cn.yaml | 2 +- easytier-web/frontend-lib/src/locales/en.yaml | 2 +- easytier/src/instance_manager.rs | 14 +- easytier/src/launcher.rs | 26 +-- .../android/src/main/java/TauriVpnService.kt | 13 ++ .../android/src/main/java/VpnServicePlugin.kt | 78 +++++--- tauri-plugin-vpnservice/build.rs | 1 + tauri-plugin-vpnservice/guest-js/index.ts | 12 ++ .../ios/Sources/ExamplePlugin.swift | 4 + .../commands/get_vpn_status.toml | 13 ++ .../permissions/autogenerated/reference.md | 26 +++ .../permissions/schemas/schema.json | 12 ++ tauri-plugin-vpnservice/src/mobile.rs | 6 + tauri-plugin-vpnservice/src/models.rs | 9 + 22 files changed, 496 insertions(+), 162 deletions(-) create mode 100644 tauri-plugin-vpnservice/permissions/autogenerated/commands/get_vpn_status.toml diff --git a/easytier-gui/src-tauri/capabilities/migrated.json b/easytier-gui/src-tauri/capabilities/migrated.json index 5c499eb3..941c4908 100644 --- a/easytier-gui/src-tauri/capabilities/migrated.json +++ b/easytier-gui/src-tauri/capabilities/migrated.json @@ -36,6 +36,7 @@ "core:tray:allow-set-show-menu-on-left-click", "core:tray:allow-set-tooltip", "vpnservice:allow-ping", + "vpnservice:allow-get-vpn-status", "vpnservice:allow-prepare-vpn", "vpnservice:allow-start-vpn", "vpnservice:allow-stop-vpn", @@ -47,4 +48,4 @@ "os:allow-platform", "os:allow-locale" ] -} \ No newline at end of file +} diff --git a/easytier-gui/src-tauri/src/lib.rs b/easytier-gui/src-tauri/src/lib.rs index 3947cb5b..35dbe6e8 100644 --- a/easytier-gui/src-tauri/src/lib.rs +++ b/easytier-gui/src-tauri/src/lib.rs @@ -206,6 +206,16 @@ async fn update_network_config_state( .parse() .map_err(|e: uuid::Error| e.to_string())?; let client_manager = get_client_manager!()?; + if !disabled { + let cfg = client_manager + .handle_get_network_config(app.clone(), instance_id) + .await + .map_err(|e| e.to_string())?; + let toml_config = cfg.gen_config().map_err(|e| e.to_string())?; + client_manager + .pre_run_network_instance_hook(&app, &toml_config) + .await?; + } client_manager .handle_update_network_state(app.clone(), instance_id, disabled) .await @@ -215,6 +225,10 @@ async fn update_network_config_state( client_manager .post_stop_network_instances_hook(&app) .await?; + } else { + client_manager + .post_run_network_instance_hook(&app, &instance_id) + .await?; } Ok(()) @@ -830,7 +844,7 @@ mod manager { cfg: &easytier::common::config::TomlConfigLoader, ) -> Result<(), String> { let instance_id = cfg.get_id(); - app.emit("pre_run_network_instance", instance_id) + app.emit("pre_run_network_instance", instance_id.to_string()) .map_err(|e| e.to_string())?; #[cfg(target_os = "android")] @@ -867,20 +881,21 @@ mod manager { let app_clone = app.clone(); let instance_id_clone = *instance_id; tokio::spawn(async move { + let instance_id_str = instance_id_clone.to_string(); loop { match event_receiver.recv().await { Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)) => { - let _ = app_clone.emit("dhcp_ip_changed", instance_id_clone); + let _ = app_clone.emit("dhcp_ip_changed", &instance_id_str); } Ok(easytier::common::global_ctx::GlobalCtxEvent::ProxyCidrsUpdated(_, _)) => { - let _ = app_clone.emit("proxy_cidrs_updated", instance_id_clone); + let _ = app_clone.emit("proxy_cidrs_updated", &instance_id_str); } Ok(_) => {} Err(tokio::sync::broadcast::error::RecvError::Closed) => { break; } Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { - let _ = app_clone.emit("event_lagged", instance_id_clone); + let _ = app_clone.emit("event_lagged", &instance_id_str); event_receiver = event_receiver.resubscribe(); } } @@ -892,7 +907,7 @@ mod manager { self.storage.enabled_networks.insert(*instance_id); - app.emit("post_run_network_instance", instance_id) + app.emit("post_run_network_instance", instance_id.to_string()) .map_err(|e| e.to_string())?; Ok(()) @@ -971,20 +986,26 @@ mod manager { .network_configs .get(&uuid) .map(|i| i.value().1.clone()); - if config.is_none() { + let Some(config) = config else { continue; - } + }; + let toml_config = config.gen_config()?; + self.pre_run_network_instance_hook(&app, &toml_config) + .await + .map_err(|e| anyhow::anyhow!(e))?; client .run_network_instance( BaseController::default(), RunNetworkInstanceRequest { inst_id: None, - config, + config: Some(config), overwrite: false, }, ) .await?; - self.storage.enabled_networks.insert(uuid); + self.post_run_network_instance_hook(&app, &uuid) + .await + .map_err(|e| anyhow::anyhow!(e))?; } } } diff --git a/easytier-gui/src/auto-imports.d.ts b/easytier-gui/src/auto-imports.d.ts index 5db4b676..25d72636 100644 --- a/easytier-gui/src/auto-imports.d.ts +++ b/easytier-gui/src/auto-imports.d.ts @@ -93,6 +93,7 @@ declare global { const shallowReadonly: typeof import('vue')['shallowReadonly'] const shallowRef: typeof import('vue')['shallowRef'] const storeToRefs: typeof import('pinia')['storeToRefs'] + const syncMobileVpnService: typeof import('./composables/mobile_vpn')['syncMobileVpnService'] const toRaw: typeof import('vue')['toRaw'] const toRef: typeof import('vue')['toRef'] const toRefs: typeof import('vue')['toRefs'] @@ -217,6 +218,7 @@ declare module 'vue' { readonly shallowReadonly: UnwrapRef readonly shallowRef: UnwrapRef readonly storeToRefs: UnwrapRef + readonly syncMobileVpnService: UnwrapRef readonly toRaw: UnwrapRef readonly toRef: UnwrapRef readonly toRefs: UnwrapRef diff --git a/easytier-gui/src/composables/event.ts b/easytier-gui/src/composables/event.ts index 7d1df1d9..6f24ec36 100644 --- a/easytier-gui/src/composables/event.ts +++ b/easytier-gui/src/composables/event.ts @@ -1,6 +1,7 @@ import { Event, listen } from "@tauri-apps/api/event"; import { type } from "@tauri-apps/plugin-os"; import { NetworkTypes } from "easytier-frontend-lib" +import { Utils } from "easytier-frontend-lib"; const EVENTS = Object.freeze({ SAVE_CONFIGS: 'save_configs', @@ -17,39 +18,71 @@ function onSaveConfigs(event: Event) { localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config)))); } -async function onPreRunNetworkInstance(event: Event) { +function normalizeInstanceIdPayload(payload: unknown): string { + if (typeof payload === 'string') { + return payload + } + + if (payload && typeof payload === 'object') { + const uuid = payload as Partial + if ( + typeof uuid.part1 === 'number' + && typeof uuid.part2 === 'number' + && typeof uuid.part3 === 'number' + && typeof uuid.part4 === 'number' + ) { + return Utils.UuidToStr(uuid as Utils.UUID) + } + } + + if (payload == null) { + return '' + } + + const fallback = String(payload) + return fallback === '[object Object]' ? '' : fallback +} + +async function onPreRunNetworkInstance(event: Event) { + const instanceId = normalizeInstanceIdPayload(event.payload) + console.log(`Received event '${EVENTS.PRE_RUN_NETWORK_INSTANCE}', raw payload:`, event.payload, 'normalized:', instanceId) if (type() === 'android') { - await prepareVpnService(event.payload); + await prepareVpnService(instanceId); } } -async function onPostRunNetworkInstance(event: Event) { +async function onPostRunNetworkInstance(event: Event) { + const instanceId = normalizeInstanceIdPayload(event.payload) + console.log(`Received event '${EVENTS.POST_RUN_NETWORK_INSTANCE}', raw payload:`, event.payload, 'normalized:', instanceId) if (type() === 'android') { - await onNetworkInstanceChange(event.payload); + await onNetworkInstanceChange(instanceId); } } -async function onVpnServiceStop(event: Event) { - await onNetworkInstanceChange(event.payload); +async function onVpnServiceStop(event: Event) { + console.log(`Received event '${EVENTS.VPN_SERVICE_STOP}', raw payload:`, event.payload) + await syncMobileVpnService(); } -async function onDhcpIpChanged(event: Event) { - console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${event.payload}`); +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(event.payload); + await onNetworkInstanceChange(instanceId); } } -async function onProxyCidrsUpdated(event: Event) { - console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${event.payload}`); +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(event.payload); + await onNetworkInstanceChange(instanceId); } } -async function onEventLagged(event: Event) { +async function onEventLagged(event: Event) { if (type() === 'android') { - await onNetworkInstanceChange(event.payload); + await onNetworkInstanceChange(normalizeInstanceIdPayload(event.payload)); } } diff --git a/easytier-gui/src/composables/mobile_vpn.ts b/easytier-gui/src/composables/mobile_vpn.ts index 3aef5fe6..a5332608 100644 --- a/easytier-gui/src/composables/mobile_vpn.ts +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -1,7 +1,7 @@ import type { NetworkTypes } from 'easytier-frontend-lib' import { addPluginListener } from '@tauri-apps/api/core' import { Utils } from 'easytier-frontend-lib' -import { 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' type Route = NetworkTypes.Route @@ -24,6 +24,53 @@ const curVpnStatus: vpnStatus = { dns: undefined, } +async function requestVpnPermission() { + console.log('prepare vpn') + const prepare_ret = await prepare_vpn() + console.log('prepare vpn', JSON.stringify((prepare_ret))) + if (prepare_ret?.errorMsg?.length) { + throw new Error(prepare_ret.errorMsg) + } + + const granted = prepare_ret?.granted ?? true + if (!granted) { + console.info('vpn permission request was denied or dismissed') + } + + return granted +} + +function resetVpnConfigStatus() { + curVpnStatus.ipv4Addr = undefined + curVpnStatus.ipv4Cidr = undefined + curVpnStatus.routes = [] + curVpnStatus.dns = undefined +} + +function syncVpnStatusFromNative(status: Awaited>) { + curVpnStatus.running = status?.running ?? false + if (!curVpnStatus.running) { + resetVpnConfigStatus() + return + } + + const ipv4WithCidr = status?.ipv4Addr + if (ipv4WithCidr?.length) { + const [ipv4Addr, cidr] = ipv4WithCidr.split('/') + curVpnStatus.ipv4Addr = ipv4Addr + + const parsedCidr = Number(cidr) + curVpnStatus.ipv4Cidr = Number.isInteger(parsedCidr) ? parsedCidr : undefined + } + else { + curVpnStatus.ipv4Addr = undefined + curVpnStatus.ipv4Cidr = undefined + } + + curVpnStatus.routes = [...(status?.routes ?? [])] + curVpnStatus.dns = status?.dns ?? undefined +} + async function waitVpnStatus(target_status: boolean, timeout_sec: number) { const start_time = Date.now() while (curVpnStatus.running !== target_status) { @@ -34,18 +81,19 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) { } } -async function doStopVpn() { - if (!curVpnStatus.running) { +async function doStopVpn(force = false) { + const wasRunning = curVpnStatus.running + if (!force && !wasRunning) { return } console.log('stop vpn') const stop_ret = await stop_vpn() console.log('stop vpn', JSON.stringify((stop_ret))) - await waitVpnStatus(false, 3) + if (wasRunning) { + await waitVpnStatus(false, 3) + } - curVpnStatus.ipv4Addr = undefined - curVpnStatus.routes = [] - curVpnStatus.dns = undefined + resetVpnConfigStatus() } async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?: string) { @@ -54,19 +102,32 @@ async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns? } console.log('start vpn service', ipv4Addr, cidr, routes, dns) - const start_ret = await start_vpn({ + const request = { ipv4Addr: `${ipv4Addr}/${cidr}`, routes, dns, disallowedApplications: ['com.kkrainbow.easytier'], mtu: 1300, - }) + } + + let start_ret = await start_vpn(request) + console.log('start vpn response', JSON.stringify(start_ret)) + if (start_ret?.errorMsg === 'need_prepare') { + const granted = await requestVpnPermission() + if (!granted) { + throw new Error('vpn_permission_denied') + } + start_ret = await start_vpn(request) + console.log('start vpn retry response', JSON.stringify(start_ret)) + } + if (start_ret?.errorMsg?.length) { throw new Error(start_ret.errorMsg) } await waitVpnStatus(true, 3) curVpnStatus.ipv4Addr = ipv4Addr + curVpnStatus.ipv4Cidr = cidr curVpnStatus.routes = routes curVpnStatus.dns = dns } @@ -75,13 +136,16 @@ async function onVpnServiceStart(payload: any) { console.log('vpn service start', JSON.stringify(payload)) curVpnStatus.running = true if (payload.fd) { - setTunFd(payload.fd) + await setTunFd(payload.fd).catch((e) => { + console.error('set tun fd failed', e) + }) } } async function onVpnServiceStop(payload: any) { console.log('vpn service stop', JSON.stringify(payload)) curVpnStatus.running = false + resetVpnConfigStatus() } async function registerVpnServiceListener() { @@ -135,15 +199,25 @@ export async function onNetworkInstanceChange(instanceId: string) { } if (!instanceId) { - await doStopVpn() + console.warn('vpn service skipped because instance id is empty') + if (curVpnStatus.running) { + await doStopVpn() + } return } const config = await getConfig(instanceId) + console.log('vpn service loaded config', instanceId, JSON.stringify({ + no_tun: config.no_tun, + dhcp: config.dhcp, + enable_magic_dns: config.enable_magic_dns, + })) if (config.no_tun) { + console.log('vpn service skipped because no_tun is enabled', instanceId) 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) await doStopVpn() return } @@ -170,27 +244,39 @@ export async function onNetworkInstanceChange(instanceId: string) { const routes = getRoutesForVpn(curNetworkInfo?.routes, config) - const dns = config.enable_magic_dns ? '100.100.100.101' : undefined; + const dns = config.enable_magic_dns ? '100.100.100.101' : undefined const ipChanged = virtual_ip !== curVpnStatus.ipv4Addr + const cidrChanged = network_length !== curVpnStatus.ipv4Cidr const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes) const dnsChanged = dns != curVpnStatus.dns + const configChanged = ipChanged || cidrChanged || routesChanged || dnsChanged + const shouldStartVpn = !curVpnStatus.running - if (ipChanged || routesChanged || dnsChanged) { + if (shouldStartVpn || configChanged) { console.info('vpn service virtual ip changed', JSON.stringify(curVpnStatus), virtual_ip) - try { - await doStopVpn() - } - catch (e) { - console.error(e) + if (curVpnStatus.running) { + try { + await doStopVpn() + } + catch (e) { + console.error(e) + } } try { await doStartVpn(virtual_ip, network_length, routes, dns) } catch (e) { - console.error('start vpn service failed, stop all other network insts.', e) - await runNetworkInstance(config, true); //on android config should always be saved + if (e instanceof Error && e.message === 'need_prepare') { + console.info('vpn permission is required before starting the Android VPN service') + return + } + if (e instanceof Error && e.message === 'vpn_permission_denied') { + console.info('vpn permission request was denied or dismissed') + return + } + console.error('start vpn service failed', e) } } } @@ -202,6 +288,22 @@ async function isNoTunEnabled(instanceId: string | undefined) { return (await getConfig(instanceId)).no_tun ?? false } +async function findRunningTunInstanceId() { + const instanceIds = await listNetworkInstanceIds() + const runningIds = instanceIds.running_inst_ids.map(Utils.UuidToStr) + console.log('vpn service sync running instances', JSON.stringify(runningIds)) + + for (const instanceId of runningIds) { + if (await isNoTunEnabled(instanceId)) { + continue + } + + return instanceId + } + + return undefined +} + export async function initMobileVpnService() { await registerVpnServiceListener() } @@ -210,10 +312,22 @@ export async function prepareVpnService(instanceId: string) { if (await isNoTunEnabled(instanceId)) { return } - console.log('prepare vpn') - const prepare_ret = await prepare_vpn() - console.log('prepare vpn', JSON.stringify((prepare_ret))) - if (prepare_ret?.errorMsg?.length) { - throw new Error(prepare_ret.errorMsg) - } + await requestVpnPermission() +} + +export async function syncMobileVpnService() { + syncVpnStatusFromNative(await get_vpn_status()) + const instanceId = await findRunningTunInstanceId() + if (instanceId) { + console.log('vpn service sync selected instance', instanceId) + await onNetworkInstanceChange(instanceId) + return + } + + if (dhcpPollingTimer) { + clearTimeout(dhcpPollingTimer) + dhcpPollingTimer = null + } + + await doStopVpn(true) } diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index f83bba4e..f533e5ca 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -9,6 +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 { GUIRemoteClient } from '~/modules/api' import { useToast, useConfirm } from 'primevue' @@ -189,9 +190,25 @@ async function initWithMode(mode: Mode) { clientRunning.value = await isClientRunning() } -onMounted(() => { +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() - initWithMode(currentMode.value); + await initWithMode(currentMode.value); + + onUnmounted(() => { + cleanupFns.forEach(unlisten => unlisten()) + }) }); useTray(true) @@ -347,22 +364,6 @@ async function connectRpcClient(isNormalMode: boolean, url?: string) { console.log("easytier rpc connection established, isNormalMode: ", isNormalMode) } -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) - } - } - const unlisten = await listenGlobalEvents() - - onUnmounted(() => { - unlisten() - }) -}) - async function openConfigServerDialog() { editingMode.value = JSON.parse(JSON.stringify(loadMode())) configServerDialogVisible.value = true diff --git a/easytier-web/frontend-lib/src/components/Config.vue b/easytier-web/frontend-lib/src/components/Config.vue index 24c9e9bf..d054cc76 100644 --- a/easytier-web/frontend-lib/src/components/Config.vue +++ b/easytier-web/frontend-lib/src/components/Config.vue @@ -209,7 +209,8 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
+ defaultUrl="tcp://:11010" :add-label="t('add_initial_node')" + :placeholder="t('initial_node_placeholder')" />
@@ -300,8 +301,8 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa - + diff --git a/easytier-web/frontend-lib/src/components/UrlInput.vue b/easytier-web/frontend-lib/src/components/UrlInput.vue index 503b269a..f80f9877 100644 --- a/easytier-web/frontend-lib/src/components/UrlInput.vue +++ b/easytier-web/frontend-lib/src/components/UrlInput.vue @@ -15,6 +15,7 @@ const url = defineModel({ required: true }) const editing = ref(false) const container = ref(null) const internalCompact = ref(false) +const hostFocused = ref(false) onMounted(() => { if (container.value) { @@ -36,36 +37,86 @@ const parseUrl = (val: string | null | undefined) => { const p = parseInt(portStr) return isNaN(p) ? (props.protos[proto] ?? 11010) : p } + const parseByPattern = (input: string) => { + const trimmed = input.trim() + if (!trimmed) { + return null + } + const match = trimmed.match(/^(\w+):\/\/(.*)$/) + const proto = match ? match[1] : 'tcp' + const rest = match ? match[2] : trimmed + const authority = rest.split(/[/?#]/)[0] + if (!authority) { + return null + } + const hostAndMaybePort = authority.includes('@') ? authority.slice(authority.lastIndexOf('@') + 1) : authority + if (hostAndMaybePort.startsWith('[')) { + const ipv6End = hostAndMaybePort.indexOf(']') + if (ipv6End > 0) { + const host = hostAndMaybePort.slice(0, ipv6End + 1) + const remain = hostAndMaybePort.slice(ipv6End + 1) + const port = remain.startsWith(':') ? getValidPort(remain.slice(1), proto) : (props.protos[proto] ?? 11010) + return { proto, host, port } + } + } + const portMatch = hostAndMaybePort.match(/^(.*):(\d+)$/) + const host = portMatch ? portMatch[1] : hostAndMaybePort + const port = portMatch ? parseInt(portMatch[2]) : (props.protos[proto] ?? 11010) + return { proto, host, port } + } if (!val) { return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 } } - try { - const urlObj = new URL(val) - const proto = urlObj.protocol.replace(':', '') - return { - proto: proto, - host: urlObj.hostname, - port: getValidPort(urlObj.port, proto) - } - } catch (e) { - // Fallback for incomplete or invalid URLs - const match = val.match(/^(\w+):\/\/(.*)$/) - if (match) { - const proto = match[1] - const rest = match[2] - const portMatch = rest.match(/:(\d+)$/) - return { - proto, - host: portMatch ? rest.slice(0, portMatch.index) : rest, - port: portMatch ? parseInt(portMatch[1]) : (props.protos[proto] ?? 11010) - } - } - return { proto: 'tcp', host: '', port: 11010 } + const parsedByPattern = parseByPattern(val) + if (parsedByPattern) { + return parsedByPattern } + return { proto: 'tcp', host: '', port: 11010 } } const internalValue = ref(parseUrl(url.value)) +const defaultHost = '0.0.0.0' + +const buildUrlValue = (value: { proto: string, host: string, port: number }, forceDefaultHost = false) => { + const proto = value.proto || 'tcp' + const rawHost = (value.host ?? '').trim() + const host = rawHost || (forceDefaultHost ? defaultHost : '') + if (!host) { + return null + } + let port = value.port + if (isNaN(parseInt(port as any))) { + port = props.protos[proto] ?? 11010 + } + + if (props.protos[proto] === 0) { + return `${proto}://${host}` + } + return `${proto}://${host}:${port}` +} + +const syncUrlFromInternal = (forceDefaultHost = false) => { + const nextUrl = buildUrlValue(internalValue.value, forceDefaultHost) + if (!nextUrl || nextUrl === url.value) { + return + } + url.value = nextUrl +} + +const onHostBlur = () => { + hostFocused.value = false + syncUrlFromInternal(true) +} + +const onHostFocus = () => { + hostFocused.value = true +} + +const onDialogConfirm = () => { + syncUrlFromInternal(true) + editing.value = false +} const isNoPortProto = computed(() => { return props.protos[internalValue.value.proto] === 0 @@ -73,28 +124,22 @@ const isNoPortProto = computed(() => { // Sync from external watch(() => url.value, (newVal) => { + if (hostFocused.value) { + return + } const parsed = parseUrl(newVal) + const internalHost = internalValue.value.host ?? '' + const sameHost = parsed.host === internalHost || (!internalHost.trim() && parsed.host === defaultHost) if (parsed.proto !== internalValue.value.proto || - parsed.host !== internalValue.value.host || + !sameHost || parsed.port !== internalValue.value.port) { internalValue.value = parsed } }) // Sync to external -watch(internalValue, (newVal) => { - const proto = newVal.proto || 'tcp' - const host = newVal.host || '0.0.0.0' - let port = newVal.port - if (isNaN(parseInt(port as any))) { - port = props.protos[proto] ?? 11010 - } - - if (props.protos[proto] === 0) { - url.value = `${proto}://${host}` - } else { - url.value = `${proto}://${host}:${port}` - } +watch(internalValue, () => { + syncUrlFromInternal(false) }, { deep: true }) const protoOptions = computed(() => Object.keys(props.protos)) @@ -128,7 +173,8 @@ const onProtoChange = (newProto: string) => { - +