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-show-menu-on-left-click",
"core:tray:allow-set-tooltip", "core:tray:allow-set-tooltip",
"vpnservice:allow-ping", "vpnservice:allow-ping",
"vpnservice:allow-get-vpn-status",
"vpnservice:allow-prepare-vpn", "vpnservice:allow-prepare-vpn",
"vpnservice:allow-start-vpn", "vpnservice:allow-start-vpn",
"vpnservice:allow-stop-vpn", "vpnservice:allow-stop-vpn",
@@ -47,4 +48,4 @@
"os:allow-platform", "os:allow-platform",
"os:allow-locale" "os:allow-locale"
] ]
} }
+30 -9
View File
@@ -206,6 +206,16 @@ async fn update_network_config_state(
.parse() .parse()
.map_err(|e: uuid::Error| e.to_string())?; .map_err(|e: uuid::Error| e.to_string())?;
let client_manager = get_client_manager!()?; 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 client_manager
.handle_update_network_state(app.clone(), instance_id, disabled) .handle_update_network_state(app.clone(), instance_id, disabled)
.await .await
@@ -215,6 +225,10 @@ async fn update_network_config_state(
client_manager client_manager
.post_stop_network_instances_hook(&app) .post_stop_network_instances_hook(&app)
.await?; .await?;
} else {
client_manager
.post_run_network_instance_hook(&app, &instance_id)
.await?;
} }
Ok(()) Ok(())
@@ -830,7 +844,7 @@ mod manager {
cfg: &easytier::common::config::TomlConfigLoader, cfg: &easytier::common::config::TomlConfigLoader,
) -> Result<(), String> { ) -> Result<(), String> {
let instance_id = cfg.get_id(); 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())?; .map_err(|e| e.to_string())?;
#[cfg(target_os = "android")] #[cfg(target_os = "android")]
@@ -867,20 +881,21 @@ mod manager {
let app_clone = app.clone(); let app_clone = app.clone();
let instance_id_clone = *instance_id; let instance_id_clone = *instance_id;
tokio::spawn(async move { tokio::spawn(async move {
let instance_id_str = instance_id_clone.to_string();
loop { loop {
match event_receiver.recv().await { match event_receiver.recv().await {
Ok(easytier::common::global_ctx::GlobalCtxEvent::DhcpIpv4Changed(_, _)) => { 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(_, _)) => { 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(_) => {} Ok(_) => {}
Err(tokio::sync::broadcast::error::RecvError::Closed) => { Err(tokio::sync::broadcast::error::RecvError::Closed) => {
break; break;
} }
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => { 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(); event_receiver = event_receiver.resubscribe();
} }
} }
@@ -892,7 +907,7 @@ mod manager {
self.storage.enabled_networks.insert(*instance_id); 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())?; .map_err(|e| e.to_string())?;
Ok(()) Ok(())
@@ -971,20 +986,26 @@ mod manager {
.network_configs .network_configs
.get(&uuid) .get(&uuid)
.map(|i| i.value().1.clone()); .map(|i| i.value().1.clone());
if config.is_none() { let Some(config) = config else {
continue; continue;
} };
let toml_config = config.gen_config()?;
self.pre_run_network_instance_hook(&app, &toml_config)
.await
.map_err(|e| anyhow::anyhow!(e))?;
client client
.run_network_instance( .run_network_instance(
BaseController::default(), BaseController::default(),
RunNetworkInstanceRequest { RunNetworkInstanceRequest {
inst_id: None, inst_id: None,
config, config: Some(config),
overwrite: false, overwrite: false,
}, },
) )
.await?; .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 shallowReadonly: typeof import('vue')['shallowReadonly']
const shallowRef: typeof import('vue')['shallowRef'] const shallowRef: typeof import('vue')['shallowRef']
const storeToRefs: typeof import('pinia')['storeToRefs'] const storeToRefs: typeof import('pinia')['storeToRefs']
const syncMobileVpnService: typeof import('./composables/mobile_vpn')['syncMobileVpnService']
const toRaw: typeof import('vue')['toRaw'] const toRaw: typeof import('vue')['toRaw']
const toRef: typeof import('vue')['toRef'] const toRef: typeof import('vue')['toRef']
const toRefs: typeof import('vue')['toRefs'] const toRefs: typeof import('vue')['toRefs']
@@ -217,6 +218,7 @@ declare module 'vue' {
readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']> readonly shallowReadonly: UnwrapRef<typeof import('vue')['shallowReadonly']>
readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']> readonly shallowRef: UnwrapRef<typeof import('vue')['shallowRef']>
readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']> readonly storeToRefs: UnwrapRef<typeof import('pinia')['storeToRefs']>
readonly syncMobileVpnService: UnwrapRef<typeof import('./composables/mobile_vpn')['syncMobileVpnService']>
readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']> readonly toRaw: UnwrapRef<typeof import('vue')['toRaw']>
readonly toRef: UnwrapRef<typeof import('vue')['toRef']> readonly toRef: UnwrapRef<typeof import('vue')['toRef']>
readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']> readonly toRefs: UnwrapRef<typeof import('vue')['toRefs']>
+47 -14
View File
@@ -1,6 +1,7 @@
import { Event, listen } from "@tauri-apps/api/event"; import { Event, listen } from "@tauri-apps/api/event";
import { type } from "@tauri-apps/plugin-os"; 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";
const EVENTS = Object.freeze({ const EVENTS = Object.freeze({
SAVE_CONFIGS: 'save_configs', 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)))); 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') { 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') { if (type() === 'android') {
await onNetworkInstanceChange(event.payload); await onNetworkInstanceChange(instanceId);
} }
} }
async function onVpnServiceStop(event: Event<string>) { async function onVpnServiceStop(event: Event<unknown>) {
await onNetworkInstanceChange(event.payload); console.log(`Received event '${EVENTS.VPN_SERVICE_STOP}', raw payload:`, event.payload)
await syncMobileVpnService();
} }
async function onDhcpIpChanged(event: Event<string>) { async function onDhcpIpChanged(event: Event<unknown>) {
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${event.payload}`); const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.DHCP_IP_CHANGED}' for instance: ${instanceId}`);
if (type() === 'android') { if (type() === 'android') {
await onNetworkInstanceChange(event.payload); await onNetworkInstanceChange(instanceId);
} }
} }
async function onProxyCidrsUpdated(event: Event<string>) { async function onProxyCidrsUpdated(event: Event<unknown>) {
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${event.payload}`); const instanceId = normalizeInstanceIdPayload(event.payload)
console.log(`Received event '${EVENTS.PROXY_CIDRS_UPDATED}' for instance: ${instanceId}`);
if (type() === 'android') { 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') { 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 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 { 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 type Route = NetworkTypes.Route
@@ -24,6 +24,53 @@ const curVpnStatus: vpnStatus = {
dns: undefined, 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) { async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
const start_time = Date.now() const start_time = Date.now()
while (curVpnStatus.running !== target_status) { while (curVpnStatus.running !== target_status) {
@@ -34,18 +81,19 @@ async function waitVpnStatus(target_status: boolean, timeout_sec: number) {
} }
} }
async function doStopVpn() { async function doStopVpn(force = false) {
if (!curVpnStatus.running) { const wasRunning = curVpnStatus.running
if (!force && !wasRunning) {
return return
} }
console.log('stop vpn') console.log('stop vpn')
const stop_ret = await stop_vpn() const stop_ret = await stop_vpn()
console.log('stop vpn', JSON.stringify((stop_ret))) console.log('stop vpn', JSON.stringify((stop_ret)))
await waitVpnStatus(false, 3) if (wasRunning) {
await waitVpnStatus(false, 3)
}
curVpnStatus.ipv4Addr = undefined resetVpnConfigStatus()
curVpnStatus.routes = []
curVpnStatus.dns = undefined
} }
async function doStartVpn(ipv4Addr: string, cidr: number, routes: string[], dns?: string) { 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) console.log('start vpn service', ipv4Addr, cidr, routes, dns)
const start_ret = await start_vpn({ const request = {
ipv4Addr: `${ipv4Addr}/${cidr}`, ipv4Addr: `${ipv4Addr}/${cidr}`,
routes, routes,
dns, dns,
disallowedApplications: ['com.kkrainbow.easytier'], disallowedApplications: ['com.kkrainbow.easytier'],
mtu: 1300, 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) { if (start_ret?.errorMsg?.length) {
throw new Error(start_ret.errorMsg) throw new Error(start_ret.errorMsg)
} }
await waitVpnStatus(true, 3) await waitVpnStatus(true, 3)
curVpnStatus.ipv4Addr = ipv4Addr curVpnStatus.ipv4Addr = ipv4Addr
curVpnStatus.ipv4Cidr = cidr
curVpnStatus.routes = routes curVpnStatus.routes = routes
curVpnStatus.dns = dns curVpnStatus.dns = dns
} }
@@ -75,13 +136,16 @@ async function onVpnServiceStart(payload: any) {
console.log('vpn service start', JSON.stringify(payload)) console.log('vpn service start', JSON.stringify(payload))
curVpnStatus.running = true curVpnStatus.running = true
if (payload.fd) { if (payload.fd) {
setTunFd(payload.fd) await setTunFd(payload.fd).catch((e) => {
console.error('set tun fd failed', e)
})
} }
} }
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
resetVpnConfigStatus()
} }
async function registerVpnServiceListener() { async function registerVpnServiceListener() {
@@ -135,15 +199,25 @@ export async function onNetworkInstanceChange(instanceId: string) {
} }
if (!instanceId) { if (!instanceId) {
await doStopVpn() console.warn('vpn service skipped because instance id is empty')
if (curVpnStatus.running) {
await doStopVpn()
}
return return
} }
const config = await getConfig(instanceId) 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) { if (config.no_tun) {
console.log('vpn service skipped because no_tun is enabled', instanceId)
return return
} }
const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId] const curNetworkInfo = (await collectNetworkInfo(instanceId)).info.map[instanceId]
if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) { if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) {
console.warn('vpn service skipped because network info is unavailable', instanceId, curNetworkInfo?.error_msg)
await doStopVpn() await doStopVpn()
return return
} }
@@ -170,27 +244,39 @@ export async function onNetworkInstanceChange(instanceId: string) {
const routes = getRoutesForVpn(curNetworkInfo?.routes, config) 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 ipChanged = virtual_ip !== curVpnStatus.ipv4Addr
const cidrChanged = network_length !== curVpnStatus.ipv4Cidr
const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes) const routesChanged = JSON.stringify(routes) !== JSON.stringify(curVpnStatus.routes)
const dnsChanged = dns != curVpnStatus.dns 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) console.info('vpn service virtual ip changed', JSON.stringify(curVpnStatus), virtual_ip)
try { if (curVpnStatus.running) {
await doStopVpn() try {
} await doStopVpn()
catch (e) { }
console.error(e) catch (e) {
console.error(e)
}
} }
try { try {
await doStartVpn(virtual_ip, network_length, routes, dns) await doStartVpn(virtual_ip, network_length, routes, dns)
} }
catch (e) { catch (e) {
console.error('start vpn service failed, stop all other network insts.', e) if (e instanceof Error && e.message === 'need_prepare') {
await runNetworkInstance(config, true); //on android config should always be saved 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 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() { export async function initMobileVpnService() {
await registerVpnServiceListener() await registerVpnServiceListener()
} }
@@ -210,10 +312,22 @@ export async function prepareVpnService(instanceId: string) {
if (await isNoTunEnabled(instanceId)) { if (await isNoTunEnabled(instanceId)) {
return return
} }
console.log('prepare vpn') await requestVpnPermission()
const prepare_ret = await prepare_vpn() }
console.log('prepare vpn', JSON.stringify((prepare_ret)))
if (prepare_ret?.errorMsg?.length) { export async function syncMobileVpnService() {
throw new Error(prepare_ret.errorMsg) 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 { 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 { GUIRemoteClient } from '~/modules/api' import { GUIRemoteClient } from '~/modules/api'
import { useToast, useConfirm } from 'primevue' import { useToast, useConfirm } from 'primevue'
@@ -189,9 +190,25 @@ async function initWithMode(mode: Mode) {
clientRunning.value = await isClientRunning() 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() currentMode.value = loadMode()
initWithMode(currentMode.value); await initWithMode(currentMode.value);
onUnmounted(() => {
cleanupFns.forEach(unlisten => unlisten())
})
}); });
useTray(true) useTray(true)
@@ -347,22 +364,6 @@ async function connectRpcClient(isNormalMode: boolean, url?: string) {
console.log("easytier rpc connection established, isNormalMode: ", isNormalMode) 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() { async function openConfigServerDialog() {
editingMode.value = JSON.parse(JSON.stringify(loadMode())) editingMode.value = JSON.parse(JSON.stringify(loadMode()))
configServerDialogVisible.value = true configServerDialogVisible.value = true
@@ -209,7 +209,8 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
</div> </div>
<div class="items-center flex flex-col p-fluid gap-y-2"> <div class="items-center flex flex-col p-fluid gap-y-2">
<UrlListInput id="initial_nodes" v-model="curNetwork.peer_urls" :protos="protos" <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> </div>
</div> </div>
@@ -300,8 +301,8 @@ watch(() => curNetwork.value, syncNormalizedNetwork, { immediate: true, deep: fa
<label for="mtu">{{ t('mtu') }}</label> <label for="mtu">{{ t('mtu') }}</label>
<span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mtu_help')"></span> <span class="pi pi-question-circle ml-2 self-center" v-tooltip="t('mtu_help')"></span>
</div> </div>
<InputNumber id="mtu" v-model="curNetwork.mtu" aria-describedby="mtu-help" :format="false" <InputNumber id="mtu" v-model="curNetwork.mtu" aria-describedby="mtu-help" :format="false"
:placeholder="t('mtu_placeholder')" :min="400" :max="1380" fluid /> :placeholder="t('mtu_placeholder')" :min="400" :max="1380" fluid />
</div> </div>
</div> </div>
@@ -15,6 +15,7 @@ const url = defineModel<string>({ required: true })
const editing = ref(false) const editing = ref(false)
const container = ref<HTMLElement | null>(null) const container = ref<HTMLElement | null>(null)
const internalCompact = ref(false) const internalCompact = ref(false)
const hostFocused = ref(false)
onMounted(() => { onMounted(() => {
if (container.value) { if (container.value) {
@@ -36,36 +37,86 @@ const parseUrl = (val: string | null | undefined) => {
const p = parseInt(portStr) const p = parseInt(portStr)
return isNaN(p) ? (props.protos[proto] ?? 11010) : p 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) { if (!val) {
return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 } return { proto: 'tcp', host: '', port: props.protos['tcp'] ?? 11010 }
} }
try { const parsedByPattern = parseByPattern(val)
const urlObj = new URL(val) if (parsedByPattern) {
const proto = urlObj.protocol.replace(':', '') return parsedByPattern
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 }
} }
return { proto: 'tcp', host: '', port: 11010 }
} }
const internalValue = ref(parseUrl(url.value)) 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(() => { const isNoPortProto = computed(() => {
return props.protos[internalValue.value.proto] === 0 return props.protos[internalValue.value.proto] === 0
@@ -73,28 +124,22 @@ const isNoPortProto = computed(() => {
// Sync from external // Sync from external
watch(() => url.value, (newVal) => { watch(() => url.value, (newVal) => {
if (hostFocused.value) {
return
}
const parsed = parseUrl(newVal) 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 || if (parsed.proto !== internalValue.value.proto ||
parsed.host !== internalValue.value.host || !sameHost ||
parsed.port !== internalValue.value.port) { parsed.port !== internalValue.value.port) {
internalValue.value = parsed internalValue.value = parsed
} }
}) })
// Sync to external // Sync to external
watch(internalValue, (newVal) => { watch(internalValue, () => {
const proto = newVal.proto || 'tcp' syncUrlFromInternal(false)
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}`
}
}, { deep: true }) }, { deep: true })
const protoOptions = computed(() => Object.keys(props.protos)) const protoOptions = computed(() => Object.keys(props.protos))
@@ -128,7 +173,8 @@ const onProtoChange = (newProto: string) => {
<AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown <AutoComplete :model-value="internalValue.proto" :suggestions="filteredProtos" dropdown
class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos" class="max-w-32 proto-autocomplete-in-group" @complete="searchProtos"
@update:model-value="onProtoChange" /> @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"> <template v-if="!isNoPortProto">
<InputGroupAddon> <InputGroupAddon>
<span style="font-weight: bold">:</span> <span style="font-weight: bold">:</span>
@@ -156,7 +202,8 @@ const onProtoChange = (newProto: string) => {
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<label>{{ t('web.common.address') || 'Address' }}</label> <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>
<div v-if="!isNoPortProto" class="flex flex-col gap-2"> <div v-if="!isNoPortProto" class="flex flex-col gap-2">
<label>{{ t('port') }}</label> <label>{{ t('port') }}</label>
@@ -164,7 +211,7 @@ const onProtoChange = (newProto: string) => {
</div> </div>
</div> </div>
<template #footer> <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 /> autofocus />
</template> </template>
</Dialog> </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: 虚拟IPv4地址
virtual_ipv4_dhcp: DHCP virtual_ipv4_dhcp: DHCP
network_name: 网络名称 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. • 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. • 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 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: Virtual IPv4
virtual_ipv4_dhcp: DHCP virtual_ipv4_dhcp: DHCP
network_name: Network Name 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> { pub fn set_tun_fd(&self, instance_id: &uuid::Uuid, fd: i32) -> Result<(), anyhow::Error> {
let mut instance = self let sender = self
.instance_map .instance_map
.get_mut(instance_id) .get(instance_id)
.ok_or_else(|| anyhow::anyhow!("instance not found"))?; .ok_or_else(|| anyhow::anyhow!("instance not found"))?
instance.set_tun_fd(fd); .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(()) Ok(())
} }
+6 -20
View File
@@ -403,12 +403,6 @@ impl NetworkInstance {
self.config.get_network_identity().network_name 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>> { pub fn get_tun_fd_sender(&self) -> Option<mpsc::Sender<TunFd>> {
self.launcher self.launcher
.as_ref() .as_ref()
@@ -573,8 +567,9 @@ impl NetworkConfig {
peer_public_key: None, peer_public_key: None,
}); });
} }
if !peers.is_empty() {
cfg.set_peers(peers); cfg.set_peers(peers);
}
} }
NetworkingMethod::Standalone => {} NetworkingMethod::Standalone => {}
} }
@@ -874,18 +869,9 @@ impl NetworkConfig {
} }
let peers = config.get_peers(); let peers = config.get_peers();
match peers.len() { result.networking_method = Some(NetworkingMethod::Manual as i32);
1 => { if !peers.is_empty() {
result.networking_method = Some(NetworkingMethod::PublicServer as i32); result.peer_urls = peers.iter().map(|p| p.uri.to_string()).collect();
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.listener_urls = config result.listener_urls = config
@@ -14,6 +14,9 @@ class TauriVpnService : VpnService() {
companion object { companion object {
@JvmField var triggerCallback: (String, JSObject) -> Unit = { _, _ -> } @JvmField var triggerCallback: (String, JSObject) -> Unit = { _, _ -> }
@JvmField var self: TauriVpnService? = null @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 IPV4_ADDR = "IPV4_ADDR"
const val ROUTES = "ROUTES" const val ROUTES = "ROUTES"
@@ -27,6 +30,9 @@ class TauriVpnService : VpnService() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
println("vpn on start command ${intent?.getExtras()} $intent") println("vpn on start command ${intent?.getExtras()} $intent")
var args = intent?.getExtras() var args = intent?.getExtras()
ipv4Addr = args?.getString(IPV4_ADDR)
routes = args?.getStringArray(ROUTES) ?: emptyArray()
dns = args?.getString(DNS)
vpnInterface = createVpnInterface(args) vpnInterface = createVpnInterface(args)
println("vpn created ${vpnInterface.fd}") println("vpn created ${vpnInterface.fd}")
@@ -63,6 +69,13 @@ class TauriVpnService : VpnService() {
triggerCallback("vpn_service_stop", JSObject()) triggerCallback("vpn_service_stop", JSObject())
vpnInterface.close() vpnInterface.close()
} }
clearStatus()
}
private fun clearStatus() {
ipv4Addr = null
routes = emptyArray()
dns = null
} }
private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor { private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor {
@@ -3,7 +3,9 @@ package com.plugin.vpnservice
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.VpnService import android.net.VpnService
import androidx.activity.result.ActivityResult
import app.tauri.annotation.Command import app.tauri.annotation.Command
import app.tauri.annotation.ActivityCallback
import app.tauri.annotation.InvokeArg import app.tauri.annotation.InvokeArg
import app.tauri.annotation.TauriPlugin import app.tauri.annotation.TauriPlugin
import app.tauri.plugin.Invoke import app.tauri.plugin.Invoke
@@ -48,46 +50,70 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) {
@Command @Command
fun prepareVpn(invoke: Invoke) { fun prepareVpn(invoke: Invoke) {
println("prepare vpn in plugin") activity.runOnUiThread {
val it = VpnService.prepare(activity) println("prepare vpn in plugin")
var ret = JSObject() val it = VpnService.prepare(activity)
if (it != null) { if (it != null) {
activity.startActivityForResult(it, 0x0f) startActivityForResult(invoke, it, "onPrepareVpnResult")
ret.put("errorMsg", "again") 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) invoke.resolve(ret)
} }
@Command @Command
fun startVpn(invoke: Invoke) { fun startVpn(invoke: Invoke) {
val args = invoke.parseArgs(StartVpnArgs::class.java) 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) val it = VpnService.prepare(activity)
var ret = JSObject() val ret = JSObject()
if (it != null) { if (it != null) {
ret.put("errorMsg", "need_prepare") ret.put("errorMsg", "need_prepare")
} else { } else {
var intent = Intent(activity, TauriVpnService::class.java) val intent = Intent(activity, TauriVpnService::class.java)
intent.putExtra(TauriVpnService.IPV4_ADDR, args.ipv4Addr) intent.putExtra(TauriVpnService.IPV4_ADDR, args.ipv4Addr)
intent.putExtra(TauriVpnService.ROUTES, args.routes) intent.putExtra(TauriVpnService.ROUTES, args.routes)
intent.putExtra(TauriVpnService.DNS, args.dns) intent.putExtra(TauriVpnService.DNS, args.dns)
intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications) intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications)
intent.putExtra(TauriVpnService.MTU, args.mtu) intent.putExtra(TauriVpnService.MTU, args.mtu)
activity.startService(intent) activity.startService(intent)
}
invoke.resolve(ret)
} }
invoke.resolve(ret)
} }
@Command @Command
fun stopVpn(invoke: Invoke) { fun stopVpn(invoke: Invoke) {
println("stop vpn in plugin") activity.runOnUiThread {
TauriVpnService.self?.onRevoke() println("stop vpn in plugin")
activity.stopService(Intent(activity, TauriVpnService::class.java)) TauriVpnService.self?.onRevoke()
println("stop vpn in plugin end") activity.stopService(Intent(activity, TauriVpnService::class.java))
invoke.resolve(JSObject()) 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", "prepare_vpn",
"start_vpn", "start_vpn",
"stop_vpn", "stop_vpn",
"get_vpn_status",
"registerListener", "registerListener",
]; ];
+12
View File
@@ -10,6 +10,7 @@ export async function ping(value: string): Promise<string | null> {
export interface InvokeResponse { export interface InvokeResponse {
errorMsg?: string; errorMsg?: string;
granted?: boolean;
} }
export interface StartVpnRequest { export interface StartVpnRequest {
@@ -20,6 +21,13 @@ export interface StartVpnRequest {
mtu?: number; mtu?: number;
} }
export interface VpnStatusResponse {
running: boolean;
ipv4Addr?: string;
routes?: string[];
dns?: string;
}
export async function prepare_vpn(): Promise<InvokeResponse | null> { export async function prepare_vpn(): Promise<InvokeResponse | null> {
return await invoke<InvokeResponse>('plugin:vpnservice|prepare_vpn', {}) 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> { export async function stop_vpn(): Promise<InvokeResponse | null> {
return await invoke<InvokeResponse>('plugin:vpnservice|stop_vpn', {}) 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) let args = try invoke.parseArgs(PingArgs.self)
invoke.resolve(["value": args.value ?? ""]) invoke.resolve(["value": args.value ?? ""])
} }
@objc public func getVpnStatus(_ invoke: Invoke) {
invoke.resolve(["running": false])
}
} }
@_cdecl("init_plugin_vpnservice") @_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>
<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> <tr>
<td> <td>
@@ -294,6 +294,18 @@
"PermissionKind": { "PermissionKind": {
"type": "string", "type": "string",
"oneOf": [ "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.", "description": "Enables the ping command without any pre-configured scope.",
"type": "string", "type": "string",
+6
View File
@@ -51,4 +51,10 @@ impl<R: Runtime> Vpnservice<R> {
.run_mobile_plugin("stop_vpn", payload) .run_mobile_plugin("stop_vpn", payload)
.map_err(Into::into) .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 struct Status {
pub error_msg: Option<String>, 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>,
}