fix android vpn permission grant (#2023)

* fix android vpn permission grant
* fix url input behaviour
This commit is contained in:
KKRainbow
2026-03-29 23:16:32 +08:00
committed by GitHub
parent 7e289865b2
commit a1bec48dc9
22 changed files with 496 additions and 162 deletions
@@ -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"
]
}
}
+30 -9
View File
@@ -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))?;
}
}
}
+2
View File
@@ -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<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['syncMobileVpnService']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
+47 -14
View File
@@ -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<NetworkTypes.NetworkConfig[]>) {
localStorage.setItem('networkList', JSON.stringify(event.payload.map((config) => NetworkTypes.normalizeNetworkConfig(config))));
}
async function onPreRunNetworkInstance(event: Event<string>) {
function normalizeInstanceIdPayload(payload: unknown): string {
if (typeof payload === 'string') {
return payload
}
if (payload && typeof payload === 'object') {
const uuid = payload as Partial<Utils.UUID>
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<unknown>) {
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<string>) {
async function onPostRunNetworkInstance(event: Event<unknown>) {
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<string>) {
await onNetworkInstanceChange(event.payload);
async function onVpnServiceStop(event: Event<unknown>) {
console.log(`Received event '${EVENTS.VPN_SERVICE_STOP}', raw payload:`, event.payload)
await syncMobileVpnService();
}
async function onDhcpIpChanged(event: Event<string>) {
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${event.payload}`);
async function onDhcpIpChanged(event: Event<unknown>) {
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<string>) {
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${event.payload}`);
async function onProxyCidrsUpdated(event: Event<unknown>) {
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<string>) {
async function onEventLagged(event: Event<unknown>) {
if (type() === 'android') {
await onNetworkInstanceChange(event.payload);
await onNetworkInstanceChange(normalizeInstanceIdPayload(event.payload));
}
}
+140 -26
View File
@@ -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<ReturnType<typeof get_vpn_status>>) {
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)
}
+19 -18
View File
@@ -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
@@ -209,7 +209,8 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
</div>
<div class="items-center flex flex-col p-fluid gap-y-2">
<UrlListInput id="initial_nodes" v-model="curNetwork.peer_urls" :protos="protos"
:add-label="t('add_initial_node')" :placeholder="t('initial_node_placeholder')" />
defaultUrl="tcp://:11010" :add-label="t('add_initial_node')"
:placeholder="t('initial_node_placeholder')" />
</div>
</div>
</div>
@@ -300,8 +301,8 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
<label for="mtu">{{ t('mtu') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mtu_help')"></span>
</div>
<InputNumber id="mtu" v-model="curNetwork.mtu" aria-describedby="mtu-help" :format="false"
:placeholder="t('mtu_placeholder')" :min="400" :max="1380" fluid />
<InputNumber id="mtu" v-model="curNetwork.mtu" aria-describedby="mtu-help" :format="false"
:placeholder="t('mtu_placeholder')" :min="400" :max="1380" fluid />
</div>
</div>
@@ -15,6 +15,7 @@ const url = defineModel<string>({ required: true })
const editing = ref(false)
const container = ref<HTMLElement | null>(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) => {
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown
class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos"
@update:model-value="onProtoChange" />
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="grow" />
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="grow"
@focus="onHostFocus" @blur="onHostBlur" />
<template v-if="!isNoPortProto">
<InputGroupAddon>
<span style="font-weight: bold">:</span>
@@ -156,7 +202,8 @@ const onProtoChange = (newProto: string) => {
</div>
<div class="flex flex-col gap-2">
<label>{{ t('web.common.address') || 'Address' }}</label>
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="w-full" />
<InputText v-model="internalValue.host" :placeholder="placeholder || '0.0.0.0'" class="w-full"
@focus="onHostFocus" @blur="onHostBlur" />
</div>
<div v-if="!isNoPortProto" class="flex flex-col gap-2">
<label>{{ t('port') }}</label>
@@ -164,7 +211,7 @@ const onProtoChange = (newProto: string) => {
</div>
</div>
<template #footer>
<Button :label="t('web.common.confirm') || 'Done'" icon="pi pi-check" @click="editing = false"
<Button :label="t('web.common.confirm') || 'Done'" icon="pi pi-check" @click="onDialogConfirm"
autofocus />
</template>
</Dialog>
@@ -10,7 +10,7 @@ initial_nodes_help: |
• 留空 = 节点独立启动,等别人来连,或你后续手动连。
• 无论直接还是间接连通(通过其他节点搭桥),都能组网互通。
初始节点可以用自己的,也可以用别人分享的。
initial_node_placeholder: 例如:tcp://node.example.com:11010
initial_node_placeholder: 例如:node.example.com
virtual_ipv4: 虚拟IPv4地址
virtual_ipv4_dhcp: DHCP
network_name: 网络名称
@@ -10,7 +10,7 @@ initial_nodes_help: |
• Leaving it empty = the node starts alone until others connect to it, or you connect it later yourself.
• Direct or indirect connectivity, including through relay nodes, can form one network.
Initial nodes can be your own nodes or ones shared by others.
initial_node_placeholder: "Example: tcp://node.example.com:11010"
initial_node_placeholder: "Example: node.example.com"
virtual_ipv4: Virtual IPv4
virtual_ipv4_dhcp: DHCP
network_name: Network Name
+10 -4
View File
@@ -227,11 +227,17 @@ impl NetworkInstanceManager {
}
pub fn set_tun_fd(&self, instance_id: &uuid::Uuid, fd: i32) -> Result<(), anyhow::Error> {
let mut instance = self
let sender = self
.instance_map
.get_mut(instance_id)
.ok_or_else(|| anyhow::anyhow!("instance not found"))?;
instance.set_tun_fd(fd);
.get(instance_id)
.ok_or_else(|| anyhow::anyhow!("instance not found"))?
.get_tun_fd_sender()
.ok_or_else(|| anyhow::anyhow!("tun fd sender not found"))?;
sender
.try_send(Some(fd))
.map_err(|e| anyhow::anyhow!("failed to send tun fd: {}", e))?;
Ok(())
}
+6 -20
View File
@@ -403,12 +403,6 @@ impl NetworkInstance {
self.config.get_network_identity().network_name
}
pub fn set_tun_fd(&mut self, tun_fd: i32) {
if let Some(launcher) = self.launcher.as_ref() {
let _ = launcher.data.tun_fd.0.blocking_send(Some(tun_fd));
}
}
pub fn get_tun_fd_sender(&self) -> Option<mpsc::Sender<TunFd>> {
self.launcher
.as_ref()
@@ -573,8 +567,9 @@ impl NetworkConfig {
peer_public_key: None,
});
}
cfg.set_peers(peers);
if !peers.is_empty() {
cfg.set_peers(peers);
}
}
NetworkingMethod::Standalone => {}
}
@@ -874,18 +869,9 @@ impl NetworkConfig {
}
let peers = config.get_peers();
match peers.len() {
1 => {
result.networking_method = Some(NetworkingMethod::PublicServer as i32);
result.public_server_url = Some(peers[0].uri.to_string());
}
0 => {
result.networking_method = Some(NetworkingMethod::Standalone as i32);
}
_ => {
result.networking_method = Some(NetworkingMethod::Manual as i32);
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
}
result.networking_method = Some(NetworkingMethod::Manual as i32);
if !peers.is_empty() {
result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
}
result.listener_urls = config
@@ -14,6 +14,9 @@ class TauriVpnService : VpnService() {
companion object {
@JvmField var triggerCallback: (String, JSObject) -> Unit = { _, _ -> }
@JvmField var self: TauriVpnService? = null
@JvmField var ipv4Addr: String? = null
@JvmField var routes: Array<String> = emptyArray()
@JvmField var dns: String? = null
const val IPV4_ADDR = "IPV4_ADDR"
const val ROUTES = "ROUTES"
@@ -27,6 +30,9 @@ class TauriVpnService : VpnService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
println("vpn on start command ${intent?.getExtras()} $intent")
var args = intent?.getExtras()
ipv4Addr = args?.getString(IPV4_ADDR)
routes = args?.getStringArray(ROUTES) ?: emptyArray()
dns = args?.getString(DNS)
vpnInterface = createVpnInterface(args)
println("vpn created ${vpnInterface.fd}")
@@ -63,6 +69,13 @@ class TauriVpnService : VpnService() {
triggerCallback("vpn_service_stop", JSObject())
vpnInterface.close()
}
clearStatus()
}
private fun clearStatus() {
ipv4Addr = null
routes = emptyArray()
dns = null
}
private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor {
@@ -3,7 +3,9 @@ package com.plugin.vpnservice
import android.app.Activity
import android.content.Intent
import android.net.VpnService
import androidx.activity.result.ActivityResult
import app.tauri.annotation.Command
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke
@@ -48,46 +50,70 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) {
@Command
fun prepareVpn(invoke: Invoke) {
println("prepare vpn in plugin")
val it = VpnService.prepare(activity)
var ret = JSObject()
if (it != null) {
activity.startActivityForResult(it, 0x0f)
ret.put("errorMsg", "again")
activity.runOnUiThread {
println("prepare vpn in plugin")
val it = VpnService.prepare(activity)
if (it != null) {
startActivityForResult(invoke, it, "onPrepareVpnResult")
return@runOnUiThread
}
val ret = JSObject()
ret.put("granted", true)
invoke.resolve(ret)
}
}
@ActivityCallback
fun onPrepareVpnResult(invoke: Invoke, result: ActivityResult) {
val ret = JSObject()
ret.put("granted", result.resultCode == Activity.RESULT_OK)
invoke.resolve(ret)
}
@Command
fun startVpn(invoke: Invoke) {
val args = invoke.parseArgs(StartVpnArgs::class.java)
println("start vpn in plugin, args: $args")
activity.runOnUiThread {
println("start vpn in plugin, args: $args")
TauriVpnService.self?.onRevoke()
TauriVpnService.self?.onRevoke()
val it = VpnService.prepare(activity)
var ret = JSObject()
if (it != null) {
ret.put("errorMsg", "need_prepare")
} else {
var intent = Intent(activity, TauriVpnService::class.java)
intent.putExtra(TauriVpnService.IPV4_ADDR, args.ipv4Addr)
intent.putExtra(TauriVpnService.ROUTES, args.routes)
intent.putExtra(TauriVpnService.DNS, args.dns)
intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications)
intent.putExtra(TauriVpnService.MTU, args.mtu)
val it = VpnService.prepare(activity)
val ret = JSObject()
if (it != null) {
ret.put("errorMsg", "need_prepare")
} else {
val intent = Intent(activity, TauriVpnService::class.java)
intent.putExtra(TauriVpnService.IPV4_ADDR, args.ipv4Addr)
intent.putExtra(TauriVpnService.ROUTES, args.routes)
intent.putExtra(TauriVpnService.DNS, args.dns)
intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications)
intent.putExtra(TauriVpnService.MTU, args.mtu)
activity.startService(intent)
activity.startService(intent)
}
invoke.resolve(ret)
}
invoke.resolve(ret)
}
@Command
fun stopVpn(invoke: Invoke) {
println("stop vpn in plugin")
TauriVpnService.self?.onRevoke()
activity.stopService(Intent(activity, TauriVpnService::class.java))
println("stop vpn in plugin end")
invoke.resolve(JSObject())
activity.runOnUiThread {
println("stop vpn in plugin")
TauriVpnService.self?.onRevoke()
activity.stopService(Intent(activity, TauriVpnService::class.java))
println("stop vpn in plugin end")
invoke.resolve(JSObject())
}
}
@Command
fun getVpnStatus(invoke: Invoke) {
val ret = JSObject()
ret.put("running", TauriVpnService.self != null)
ret.put("ipv4Addr", TauriVpnService.ipv4Addr)
ret.put("routes", TauriVpnService.routes)
ret.put("dns", TauriVpnService.dns)
invoke.resolve(ret)
}
}
+1
View File
@@ -3,6 +3,7 @@ const COMMANDS: &[&str] = &[
"prepare_vpn",
"start_vpn",
"stop_vpn",
"get_vpn_status",
"registerListener",
];
+12
View File
@@ -10,6 +10,7 @@ export async function ping(value: string): Promise<string | null> {
export interface InvokeResponse {
errorMsg?: string;
granted?: boolean;
}
export interface StartVpnRequest {
@@ -20,6 +21,13 @@ export interface StartVpnRequest {
mtu?: number;
}
export interface VpnStatusResponse {
running: boolean;
ipv4Addr?: string;
routes?: string[];
dns?: string;
}
export async function prepare_vpn(): Promise<InvokeResponse | null> {
return await invoke<InvokeResponse>('plugin:vpnservice|prepare_vpn', {})
}
@@ -33,3 +41,7 @@ export async function start_vpn(request: StartVpnRequest): Promise<InvokeRespons
export async function stop_vpn(): Promise<InvokeResponse | null> {
return await invoke<InvokeResponse>('plugin:vpnservice|stop_vpn', {})
}
export async function get_vpn_status(): Promise<VpnStatusResponse | null> {
return await invoke<VpnStatusResponse>('plugin:vpnservice|get_vpn_status', {})
}
@@ -12,6 +12,10 @@ class ExamplePlugin: Plugin {
let args = try invoke.parseArgs(PingArgs.self)
invoke.resolve(["value": args.value ?? ""])
}
@objc public func getVpnStatus(_ invoke: Invoke) {
invoke.resolve(["running": false])
}
}
@_cdecl("init_plugin_vpnservice")
@@ -0,0 +1,13 @@
# Automatically generated - DO NOT EDIT!
"$schema" = "../../schemas/schema.json"
[[permission]]
identifier = "allow-get-vpn-status"
description = "Enables the get_vpn_status command without any pre-configured scope."
commands.allow = ["get_vpn_status"]
[[permission]]
identifier = "deny-get-vpn-status"
description = "Denies the get_vpn_status command without any pre-configured scope."
commands.deny = ["get_vpn_status"]
@@ -16,6 +16,32 @@ Default permissions for the plugin
</tr>
<tr>
<td>
`vpnservice:allow-get-vpn-status`
</td>
<td>
Enables the get_vpn_status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
`vpnservice:deny-get-vpn-status`
</td>
<td>
Denies the get_vpn_status command without any pre-configured scope.
</td>
</tr>
<tr>
<td>
@@ -294,6 +294,18 @@
"PermissionKind": {
"type": "string",
"oneOf": [
{
"description": "Enables the get_vpn_status command without any pre-configured scope.",
"type": "string",
"const": "allow-get-vpn-status",
"markdownDescription": "Enables the get_vpn_status command without any pre-configured scope."
},
{
"description": "Denies the get_vpn_status command without any pre-configured scope.",
"type": "string",
"const": "deny-get-vpn-status",
"markdownDescription": "Denies the get_vpn_status command without any pre-configured scope."
},
{
"description": "Enables the ping command without any pre-configured scope.",
"type": "string",
+6
View File
@@ -51,4 +51,10 @@ impl<R: Runtime> Vpnservice<R> {
.run_mobile_plugin("stop_vpn", payload)
.map_err(Into::into)
}
pub fn get_vpn_status(&self, payload: VoidRequest) -> crate::Result<VpnStatus> {
self.0
.run_mobile_plugin("get_vpn_status", payload)
.map_err(Into::into)
}
}
+9
View File
@@ -33,3 +33,12 @@ pub struct StartVpnRequest {
pub struct Status {
pub error_msg: Option<String>,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VpnStatus {
pub running: bool,
pub ipv4_addr: Option<String>,
pub routes: Option<Vec<String>>,
pub dns: Option<String>,
}