diff --git a/easytier-gui/package.json b/easytier-gui/package.json index eadae6ed..f8bdf05f 100644 --- a/easytier-gui/package.json +++ b/easytier-gui/package.json @@ -8,7 +8,7 @@ "dev": "pnpm --dir ../easytier-web/frontend-lib build && vite", "build": "pnpm --dir ../easytier-web/frontend-lib build && vue-tsc --noEmit && vite build", "preview": "vite preview", - "test:mobile-vpn": "vitest run src/composables/mobile_vpn.test.ts", + "test:mobile-vpn": "vitest run src/composables/mobile_vpn*.test.ts", "tauri": "tauri", "lint": "eslint . --ignore-pattern src-tauri", "lint:fix": "eslint . --ignore-pattern src-tauri --fix" diff --git a/easytier-gui/src-tauri/capabilities/migrated.json b/easytier-gui/src-tauri/capabilities/migrated.json index 941c4908..2de1e08c 100644 --- a/easytier-gui/src-tauri/capabilities/migrated.json +++ b/easytier-gui/src-tauri/capabilities/migrated.json @@ -40,6 +40,7 @@ "vpnservice:allow-prepare-vpn", "vpnservice:allow-start-vpn", "vpnservice:allow-stop-vpn", + "vpnservice:allow-consume-vpn-tile-action", "vpnservice:allow-registerListener", "os:default", "os:allow-os-type", diff --git a/easytier-gui/src-tauri/gen/android/app/src/main/AndroidManifest.xml b/easytier-gui/src-tauri/gen/android/app/src/main/AndroidManifest.xml index d39174a0..c2a0206d 100644 --- a/easytier-gui/src-tauri/gen/android/app/src/main/AndroidManifest.xml +++ b/easytier-gui/src-tauri/gen/android/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + + + + + + + + diff --git a/easytier-gui/src/auto-imports.d.ts b/easytier-gui/src/auto-imports.d.ts index 571aff10..34230ae2 100644 --- a/easytier-gui/src/auto-imports.d.ts +++ b/easytier-gui/src/auto-imports.d.ts @@ -12,6 +12,7 @@ declare global { const acceptHMRUpdate: typeof import('pinia')['acceptHMRUpdate'] const collectNetworkInfo: typeof import('./composables/backend')['collectNetworkInfo'] const computed: typeof import('vue')['computed'] + const consumePendingMobileVpnTileAction: typeof import('./composables/mobile_vpn')['consumePendingMobileVpnTileAction'] const createApp: typeof import('vue')['createApp'] const createPinia: typeof import('pinia')['createPinia'] const customRef: typeof import('vue')['customRef'] @@ -20,6 +21,7 @@ declare global { const defineStore: typeof import('pinia')['defineStore'] const deleteNetworkInstance: typeof import('./composables/backend')['deleteNetworkInstance'] const effectScope: typeof import('vue')['effectScope'] + const executeVpnTileAction: typeof import('./composables/mobile_vpn_tile')['executeVpnTileAction'] const generateMenuItem: typeof import('./composables/tray')['generateMenuItem'] const generateNetworkConfig: typeof import('./composables/backend')['generateNetworkConfig'] const getActivePinia: typeof import('pinia')['getActivePinia'] @@ -87,6 +89,7 @@ declare global { const setActivePinia: typeof import('pinia')['setActivePinia'] const setLoggingLevel: typeof import('./composables/backend')['setLoggingLevel'] const setMapStoreSuffix: typeof import('pinia')['setMapStoreSuffix'] + const setMobileVpnTileActionHandler: typeof import('./composables/mobile_vpn')['setMobileVpnTileActionHandler'] const setServiceStatus: typeof import('./composables/backend')['setServiceStatus'] const setTrayMenu: typeof import('./composables/tray')['setTrayMenu'] const setTrayRunState: typeof import('./composables/tray')['setTrayRunState'] @@ -140,6 +143,7 @@ declare module 'vue' { readonly acceptHMRUpdate: UnwrapRef readonly collectNetworkInfo: UnwrapRef readonly computed: UnwrapRef + readonly consumePendingMobileVpnTileAction: UnwrapRef readonly createApp: UnwrapRef readonly createPinia: UnwrapRef readonly customRef: UnwrapRef @@ -148,6 +152,7 @@ declare module 'vue' { readonly defineStore: UnwrapRef readonly deleteNetworkInstance: UnwrapRef readonly effectScope: UnwrapRef + readonly executeVpnTileAction: UnwrapRef readonly generateMenuItem: UnwrapRef readonly generateNetworkConfig: UnwrapRef readonly getActivePinia: UnwrapRef @@ -215,6 +220,7 @@ declare module 'vue' { readonly setActivePinia: UnwrapRef readonly setLoggingLevel: UnwrapRef readonly setMapStoreSuffix: UnwrapRef + readonly setMobileVpnTileActionHandler: UnwrapRef readonly setServiceStatus: UnwrapRef readonly setTrayMenu: UnwrapRef readonly setTrayRunState: UnwrapRef diff --git a/easytier-gui/src/composables/mobile_vpn.test.ts b/easytier-gui/src/composables/mobile_vpn.test.ts index 225975f6..19c94abe 100644 --- a/easytier-gui/src/composables/mobile_vpn.test.ts +++ b/easytier-gui/src/composables/mobile_vpn.test.ts @@ -15,6 +15,7 @@ const mocks = vi.hoisted(() => { collectNetworkInfo: vi.fn(async (instanceId: string) => ({ info: { map: { [instanceId]: networkInfo.get(instanceId) } }, })), + consumeVpnTileAction: vi.fn(async () => ({})), getConfig: vi.fn(async (instanceId: string) => configs.get(instanceId)), getVpnStatus: vi.fn<() => Promise>>(async () => ({ running: false })), listNetworkInstanceIds: vi.fn<() => Promise<{ running_inst_ids: unknown[] }>>(async () => ({ running_inst_ids: [] })), @@ -43,6 +44,7 @@ vi.mock('easytier-frontend-lib', () => ({ })) vi.mock('tauri-plugin-vpnservice-api', () => ({ + consume_vpn_tile_action: mocks.consumeVpnTileAction, get_vpn_status: mocks.getVpnStatus, prepare_vpn: mocks.prepareVpn, start_vpn: mocks.startVpn, @@ -91,6 +93,8 @@ beforeEach(() => { mocks.networkInfo.clear() mocks.addPluginListener.mockClear() mocks.collectNetworkInfo.mockClear() + mocks.consumeVpnTileAction.mockReset() + mocks.consumeVpnTileAction.mockResolvedValue({}) mocks.getConfig.mockClear() mocks.getVpnStatus.mockReset() mocks.getVpnStatus.mockResolvedValue({ running: false }) @@ -230,3 +234,22 @@ describe('mobile VPN reconciliation ownership', () => { expect(mocks.startVpn).not.toHaveBeenCalled() }) }) + +describe('mobile VPN tile action delivery', () => { + it('does not consume a pending action before a handler is ready', async () => { + const vpn = await loadVpnModule() + + expect(await vpn.consumePendingMobileVpnTileAction()).toBe(false) + expect(mocks.consumeVpnTileAction).not.toHaveBeenCalled() + }) + + it('consumes and dispatches a pending action once a handler is registered', async () => { + const vpn = await loadVpnModule() + const handler = vi.fn(async () => undefined) + mocks.consumeVpnTileAction.mockResolvedValue({ action: 'start' }) + vpn.setMobileVpnTileActionHandler(handler) + + expect(await vpn.consumePendingMobileVpnTileAction()).toBe(true) + expect(handler).toHaveBeenCalledWith('start') + }) +}) diff --git a/easytier-gui/src/composables/mobile_vpn.ts b/easytier-gui/src/composables/mobile_vpn.ts index b269424f..1cd2adc3 100644 --- a/easytier-gui/src/composables/mobile_vpn.ts +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -1,7 +1,14 @@ import type { NetworkTypes } from 'easytier-frontend-lib' import { addPluginListener } from '@tauri-apps/api/core' import { Utils } from 'easytier-frontend-lib' -import { get_vpn_status, prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api' +import { + consume_vpn_tile_action, + get_vpn_status, + prepare_vpn, + start_vpn, + stop_vpn, + type VpnTileAction, +} from 'tauri-plugin-vpnservice-api' import { collectNetworkInfo, getConfig, listNetworkInstanceIds, setTunFd } from './backend' type Route = NetworkTypes.Route @@ -24,6 +31,8 @@ let vpnReconcileGeneration = 0 let vpnReconcileAttempts = 0 let vpnReconcileQueue: Promise = Promise.resolve() let vpnPermissionRequest: Promise | null = null +let vpnTileActionHandler: ((action: VpnTileAction) => Promise) | undefined +let vpnTileActionQueue: Promise = Promise.resolve() const curVpnStatus: vpnStatus = { running: false, @@ -33,6 +42,31 @@ const curVpnStatus: vpnStatus = { dns: undefined, } +export function setMobileVpnTileActionHandler( + handler?: (action: VpnTileAction) => Promise, +) { + vpnTileActionHandler = handler +} + +export async function consumePendingMobileVpnTileAction() { + const handler = vpnTileActionHandler + if (!handler) { + return false + } + + const action = (await consume_vpn_tile_action())?.action + if (action !== 'start' && action !== 'stop') { + return false + } + + const run = vpnTileActionQueue + .catch(error => console.error('previous VPN tile action failed', error)) + .then(() => handler(action)) + vpnTileActionQueue = run.catch(error => console.error('VPN tile action failed', error)) + await run + return true +} + async function requestVpnPermissionOnce() { console.log('prepare vpn') const prepare_ret = await prepare_vpn() @@ -244,6 +278,16 @@ async function registerVpnServiceListener() { 'vpn_service_stop', onVpnServiceStop, ) + + await addPluginListener( + 'vpnservice', + 'vpn_tile_action', + () => { + void consumePendingMobileVpnTileAction().catch((error) => { + console.error('consume VPN tile action failed', error) + }) + }, + ) } function getRoutesForVpn(routes: Route[] | undefined, node_config: NetworkTypes.NetworkConfig): string[] { diff --git a/easytier-gui/src/composables/mobile_vpn_tile.test.ts b/easytier-gui/src/composables/mobile_vpn_tile.test.ts new file mode 100644 index 00000000..71d8d5ed --- /dev/null +++ b/easytier-gui/src/composables/mobile_vpn_tile.test.ts @@ -0,0 +1,128 @@ +import type { Api, NetworkTypes } from 'easytier-frontend-lib' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeVpnTileAction } from './mobile_vpn_tile' + +vi.mock('easytier-frontend-lib', () => ({ + Utils: { + UuidToStr: (value: unknown) => String(value), + }, +})) + +function createApi(runningIds: string[], disabledIds: string[], noTunIds: string[] = []) { + const configs = new Map( + [...new Set([...runningIds, ...disabledIds])].map(instanceId => [ + instanceId, + { instance_id: instanceId, no_tun: noTunIds.includes(instanceId) } as NetworkTypes.NetworkConfig, + ]), + ) + return { + api: { + list_network_instance_ids: vi.fn(async () => ({ + running_inst_ids: runningIds, + disabled_inst_ids: disabledIds, + })), + get_network_config: vi.fn(async (instanceId: string) => configs.get(instanceId)!), + run_network: vi.fn(async () => undefined), + update_network_instance_state: vi.fn(async () => undefined), + } as unknown as Api.RemoteClient, + configs, + } +} + +describe('vpn quick settings tile actions', () => { + const syncVpnService = vi.fn(async () => undefined) + + beforeEach(() => { + syncVpnService.mockClear() + }) + + it('starts the last configured network and reconciles the Android VPN', async () => { + const { api, configs } = createApi([], ['first', 'last']) + + const result = await executeVpnTileAction('start', api, { + lastInstanceId: 'last', + syncVpnService, + }) + + expect(api.run_network).toHaveBeenCalledWith(configs.get('last'), true) + expect(syncVpnService).toHaveBeenCalledOnce() + expect(result).toEqual({ action: 'start', instanceId: 'last', changed: true }) + }) + + it('skips a no_tun last network and starts the first TUN-capable network', async () => { + const { api, configs } = createApi([], ['first', 'last'], ['last']) + + const result = await executeVpnTileAction('start', api, { + lastInstanceId: 'last', + syncVpnService, + }) + + expect(api.run_network).toHaveBeenCalledWith(configs.get('first'), true) + expect(syncVpnService).toHaveBeenCalledOnce() + expect(result).toEqual({ action: 'start', instanceId: 'first', changed: true }) + }) + + it('does nothing when only no_tun networks are configured', async () => { + const { api } = createApi([], ['headless'], ['headless']) + + const result = await executeVpnTileAction('start', api, { + lastInstanceId: 'headless', + syncVpnService, + }) + + expect(api.run_network).not.toHaveBeenCalled() + expect(syncVpnService).not.toHaveBeenCalled() + expect(result).toEqual({ action: 'start', changed: false }) + }) + + it('does not restart an already running network', async () => { + const { api } = createApi(['running'], []) + + const result = await executeVpnTileAction('start', api, { + lastInstanceId: 'running', + syncVpnService, + }) + + expect(api.run_network).not.toHaveBeenCalled() + expect(syncVpnService).toHaveBeenCalledOnce() + expect(result.changed).toBe(false) + }) + + it('stops the last running network, falling back to the first running network', async () => { + const { api } = createApi(['first', 'second'], ['disabled']) + + const result = await executeVpnTileAction('stop', api, { + lastInstanceId: 'disabled', + syncVpnService, + }) + + expect(api.update_network_instance_state).toHaveBeenCalledWith('first', true) + expect(syncVpnService).toHaveBeenCalledOnce() + expect(result).toEqual({ action: 'stop', instanceId: 'first', changed: true }) + }) + + it('does not select a running no_tun network for stop', async () => { + const { api } = createApi(['headless', 'vpn'], [], ['headless']) + + const result = await executeVpnTileAction('stop', api, { + lastInstanceId: 'headless', + syncVpnService, + }) + + expect(api.update_network_instance_state).toHaveBeenCalledWith('vpn', true) + expect(syncVpnService).toHaveBeenCalledOnce() + expect(result).toEqual({ action: 'stop', instanceId: 'vpn', changed: true }) + }) + + it('reports that no action is possible when no matching network exists', async () => { + const { api } = createApi([], ['configured']) + + const result = await executeVpnTileAction('stop', api, { + syncVpnService, + }) + + expect(api.update_network_instance_state).not.toHaveBeenCalled() + expect(syncVpnService).not.toHaveBeenCalled() + expect(result).toEqual({ action: 'stop', changed: false }) + }) +}) diff --git a/easytier-gui/src/composables/mobile_vpn_tile.ts b/easytier-gui/src/composables/mobile_vpn_tile.ts new file mode 100644 index 00000000..47ee1b3a --- /dev/null +++ b/easytier-gui/src/composables/mobile_vpn_tile.ts @@ -0,0 +1,55 @@ +import type { Api } from 'easytier-frontend-lib' +import type { VpnTileAction } from 'tauri-plugin-vpnservice-api' +import { Utils } from 'easytier-frontend-lib' + +export interface VpnTileActionResult { + action: VpnTileAction + instanceId?: string + changed: boolean +} + +export interface VpnTileActionOptions { + lastInstanceId?: string | null + syncVpnService: () => Promise +} + +export async function executeVpnTileAction( + action: VpnTileAction, + api: Api.RemoteClient, + options: VpnTileActionOptions, +): Promise { + const response = await api.list_network_instance_ids() + const runningIds = (response.running_inst_ids ?? []).map(Utils.UuidToStr) + const disabledIds = (response.disabled_inst_ids ?? []).map(Utils.UuidToStr) + const configuredIds = [...new Set([...runningIds, ...disabledIds])] + + const candidateIds = action === 'stop' ? runningIds : configuredIds + const candidateConfigs = new Map( + await Promise.all(candidateIds.map(async (instanceId) => { + const config = await api.get_network_config(instanceId) + return [instanceId, config] as const + })), + ) + const candidates = candidateIds.filter(instanceId => !candidateConfigs.get(instanceId)?.no_tun) + const instanceId = options.lastInstanceId && candidates.includes(options.lastInstanceId) + ? options.lastInstanceId + : candidates[0] + + if (!instanceId) { + return { action, changed: false } + } + + let changed = false + if (action === 'start' && !runningIds.includes(instanceId)) { + const config = candidateConfigs.get(instanceId)! + await api.run_network(config, true) + changed = true + } + else if (action === 'stop') { + await api.update_network_instance_state(instanceId, true) + changed = true + } + + await options.syncVpnService() + return { action, instanceId, changed } +} diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index 027824cf..947076e5 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -9,7 +9,13 @@ import { exit } from '@tauri-apps/plugin-process' import { I18nUtils, RemoteManagement, Utils } from "easytier-frontend-lib" import type { MenuItem } from 'primevue/menuitem' import { useTray } from '~/composables/tray' -import { initMobileVpnService, syncMobileVpnService } from '~/composables/mobile_vpn' +import { + consumePendingMobileVpnTileAction, + initMobileVpnService, + setMobileVpnTileActionHandler, + syncMobileVpnService, +} from '~/composables/mobile_vpn' +import { executeVpnTileAction } from '~/composables/mobile_vpn_tile' import { GUIRemoteClient } from '~/modules/api' import { useToast, useConfirm } from 'primevue' @@ -223,7 +229,10 @@ onMounted(async () => { await initWithMode(currentMode.value); if (type() === 'android') { + setMobileVpnTileActionHandler(handleMobileVpnTileAction) + cleanupFns.push(() => setMobileVpnTileActionHandler()) try { + await consumePendingMobileVpnTileAction() await syncMobileVpnService() } catch (e: any) { console.error("easytier sync vpn service failed", e) @@ -242,6 +251,42 @@ const remoteClient = computed(() => new GUIRemoteClient()); const instanceId = ref(undefined); const clientRunning = ref(false); +async function handleMobileVpnTileAction(action: 'start' | 'stop') { + try { + const result = await executeVpnTileAction(action, remoteClient.value, { + lastInstanceId: loadLastNetworkInstanceId(), + syncVpnService: syncMobileVpnService, + }) + + if (!result.instanceId) { + toast.add({ + severity: 'warn', + summary: t('vpn_tile_no_network'), + detail: t('vpn_tile_no_network_description'), + life: 5000, + }) + return + } + + instanceId.value = result.instanceId + saveLastNetworkInstanceId(result.instanceId) + toast.add({ + severity: action === 'start' ? 'success' : 'secondary', + summary: t(action === 'start' ? 'vpn_tile_started' : 'vpn_tile_stopped'), + life: 3000, + }) + } + catch (error) { + console.error('VPN tile action failed', action, error) + toast.add({ + severity: 'error', + summary: t('error'), + detail: t('vpn_tile_action_failed', { error: String(error) }), + life: 8000, + }) + } +} + watch(instanceId, (newVal) => { if (newVal) { saveLastNetworkInstanceId(newVal); diff --git a/easytier-web/frontend-lib/src/locales/cn.yaml b/easytier-web/frontend-lib/src/locales/cn.yaml index aa955863..3c0425c5 100644 --- a/easytier-web/frontend-lib/src/locales/cn.yaml +++ b/easytier-web/frontend-lib/src/locales/cn.yaml @@ -72,6 +72,11 @@ logging_level_trace: 跟踪 logging_level_off: 关闭 logging_open_dir: 打开日志目录 logging_copy_dir: 复制日志路径 +vpn_tile_no_network: 没有已配置的网络 +vpn_tile_no_network_description: 请先在 EasyTier 中配置网络,再使用 VPN 快捷磁贴。 +vpn_tile_started: VPN 网络已启动 +vpn_tile_stopped: VPN 网络已停止 +vpn_tile_action_failed: 'VPN 磁贴操作失败:{error}' disable_auto_launch: 关闭开机自启 enable_auto_launch: 开启开机自启 hide_dock_icon: 隐藏 Dock 图标 diff --git a/easytier-web/frontend-lib/src/locales/en.yaml b/easytier-web/frontend-lib/src/locales/en.yaml index 58a72a00..c75a3305 100644 --- a/easytier-web/frontend-lib/src/locales/en.yaml +++ b/easytier-web/frontend-lib/src/locales/en.yaml @@ -72,6 +72,11 @@ logging_level_trace: Trace logging_level_off: Off logging_open_dir: Open Log Directory logging_copy_dir: Copy Log Path +vpn_tile_no_network: No configured network +vpn_tile_no_network_description: Configure a network in EasyTier before using the VPN tile. +vpn_tile_started: VPN network started +vpn_tile_stopped: VPN network stopped +vpn_tile_action_failed: 'VPN tile action failed: {error}' disable_auto_launch: Disable Launch on Reboot enable_auto_launch: Enable Launch on Reboot hide_dock_icon: Hide Dock Icon diff --git a/tauri-plugin-vpnservice/android/src/main/AndroidManifest.xml b/tauri-plugin-vpnservice/android/src/main/AndroidManifest.xml index b1566427..e3890576 100644 --- a/tauri-plugin-vpnservice/android/src/main/AndroidManifest.xml +++ b/tauri-plugin-vpnservice/android/src/main/AndroidManifest.xml @@ -7,5 +7,16 @@ - + + + + + + + diff --git a/tauri-plugin-vpnservice/android/src/main/java/EasyTierVpnTileService.kt b/tauri-plugin-vpnservice/android/src/main/java/EasyTierVpnTileService.kt new file mode 100644 index 00000000..12db3eb5 --- /dev/null +++ b/tauri-plugin-vpnservice/android/src/main/java/EasyTierVpnTileService.kt @@ -0,0 +1,100 @@ +package com.plugin.vpnservice + +import android.app.PendingIntent +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.net.VpnService +import android.os.Build +import android.service.quicksettings.Tile +import android.service.quicksettings.TileService + +class EasyTierVpnTileService : TileService() { + companion object { + private const val PREFS_NAME = "easytier_vpn_tile" + private const val PENDING_ACTION_KEY = "pending_action" + const val ACTION_START = "start" + const val ACTION_STOP = "stop" + + @Synchronized + fun consumePendingAction(context: Context): String? { + val preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val action = preferences.getString(PENDING_ACTION_KEY, null) + if (action != null) { + preferences.edit().remove(PENDING_ACTION_KEY).commit() + } + return action + } + + fun requestStateUpdate(context: Context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + requestListeningState(context, ComponentName(context, EasyTierVpnTileService::class.java)) + } + } + + private fun pendingAction(context: Context): String? = + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .getString(PENDING_ACTION_KEY, null) + + private fun savePendingAction(context: Context, action: String) { + // TileService may be reclaimed as soon as onClick returns, so persist synchronously. + context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(PENDING_ACTION_KEY, action) + .commit() + } + } + + override fun onStartListening() { + super.onStartListening() + updateTileState() + } + + override fun onClick() { + super.onClick() + + if (isLocked) { + unlockAndRun(::handleClick) + } else { + handleClick() + } + } + + private fun handleClick() { + val action = pendingAction(this) ?: if (TauriVpnService.self == null) ACTION_START else ACTION_STOP + savePendingAction(this, action) + updateTileState() + + val delivered = VpnServicePlugin.dispatchTileAction(action) + val permissionRequired = action == ACTION_START && VpnService.prepare(this) != null + if (!delivered || permissionRequired) { + openApp() + } + } + + private fun updateTileState() { + qsTile?.apply { + state = if (TauriVpnService.self == null) Tile.STATE_INACTIVE else Tile.STATE_ACTIVE + updateTile() + } + } + + private fun openApp() { + val intent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } ?: return + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + val pendingIntent = PendingIntent.getActivity( + this, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + startActivityAndCollapse(pendingIntent) + } else { + @Suppress("DEPRECATION") + startActivityAndCollapse(intent) + } + } +} diff --git a/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt b/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt index b1827da0..e1be67bd 100644 --- a/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt +++ b/tauri-plugin-vpnservice/android/src/main/java/TauriVpnService.kt @@ -1,10 +1,15 @@ package com.plugin.vpnservice +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent import android.content.Intent import android.net.VpnService import android.os.Build import android.os.ParcelFileDescriptor import android.os.Bundle +import android.content.pm.ServiceInfo +import androidx.core.app.NotificationCompat import java.net.InetAddress import java.util.Arrays @@ -23,12 +28,16 @@ class TauriVpnService : VpnService() { const val DNS = "DNS" const val DISALLOWED_APPLICATIONS = "DISALLOWED_APPLICATIONS" const val MTU = "MTU" + + private const val NOTIFICATION_CHANNEL_ID = "easytier_vpn_channel" + private const val NOTIFICATION_ID = 1356 } private lateinit var vpnInterface: ParcelFileDescriptor override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { println("vpn on start command ${intent?.getExtras()} $intent") + startVpnForegroundService() var args = intent?.getExtras() ipv4Addr = args?.getString(IPV4_ADDR) routes = args?.getStringArray(ROUTES) ?: emptyArray() @@ -40,6 +49,7 @@ class TauriVpnService : VpnService() { var event_data = JSObject() event_data.put("fd", vpnInterface.fd) triggerCallback("vpn_service_start", event_data) + EasyTierVpnTileService.requestStateUpdate(this) return START_STICKY } @@ -52,16 +62,20 @@ class TauriVpnService : VpnService() { override fun onDestroy() { println("vpn on destroy") - super.onDestroy() disconnect() + stopForeground(STOP_FOREGROUND_REMOVE) self = null + EasyTierVpnTileService.requestStateUpdate(this) + super.onDestroy() } override fun onRevoke() { println("vpn on revoke") - super.onRevoke() disconnect() + stopForeground(STOP_FOREGROUND_REMOVE) self = null + EasyTierVpnTileService.requestStateUpdate(this) + super.onRevoke() } private fun disconnect() { @@ -78,6 +92,53 @@ class TauriVpnService : VpnService() { dns = null } + private fun startVpnForegroundService() { + createNotificationChannel() + + val launchIntent = packageManager.getLaunchIntentForPackage(packageName)?.apply { + addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + } + val contentIntent = launchIntent?.let { + PendingIntent.getActivity( + this, + 0, + it, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + val notification = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_menu_manage) + .setContentTitle("EasyTier VPN is running") + .setContentText("VPN connection is active") + .setOngoing(true) + .setOnlyAlertOnce(true) + .setCategory(NotificationCompat.CATEGORY_SERVICE) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .apply { contentIntent?.let(::setContentIntent) } + .build() + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE, + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } + } + + private fun createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "EasyTier VPN", + NotificationManager.IMPORTANCE_LOW, + ) + getSystemService(NotificationManager::class.java).createNotificationChannel(channel) + } + } + private fun createVpnInterface(args: Bundle?): ParcelFileDescriptor { var builder = Builder() .setSession("TauriVpnService") diff --git a/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt b/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt index abd4a23f..25fda216 100644 --- a/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt +++ b/tauri-plugin-vpnservice/android/src/main/java/VpnServicePlugin.kt @@ -29,7 +29,20 @@ class StartVpnArgs { @TauriPlugin class VpnServicePlugin(private val activity: Activity) : Plugin(activity) { + companion object { + @Volatile + private var tileActionCallback: (String) -> Boolean = { false } + + fun dispatchTileAction(action: String): Boolean = tileActionCallback(action) + } + private val implementation = Example() + private val tileActionHandler: (String) -> Boolean = { action -> + val data = JSObject() + data.put("action", action) + trigger("vpn_tile_action", data) + true + } override fun load(webView: WebView) { println("load vpn service plugin") @@ -37,6 +50,14 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) { println("vpn: triggerCallback $event $data") trigger(event, data) } + tileActionCallback = tileActionHandler + } + + override fun onDestroy() { + if (tileActionCallback === tileActionHandler) { + tileActionCallback = { false } + } + super.onDestroy() } @Command @@ -90,7 +111,11 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) { intent.putExtra(TauriVpnService.DISALLOWED_APPLICATIONS, args.disallowedApplications) intent.putExtra(TauriVpnService.MTU, args.mtu) - activity.startService(intent) + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + activity.startForegroundService(intent) + } else { + activity.startService(intent) + } } invoke.resolve(ret) } @@ -116,4 +141,11 @@ class VpnServicePlugin(private val activity: Activity) : Plugin(activity) { ret.put("dns", TauriVpnService.dns) invoke.resolve(ret) } + + @Command + fun consumeVpnTileAction(invoke: Invoke) { + val ret = JSObject() + ret.put("action", EasyTierVpnTileService.consumePendingAction(activity)) + invoke.resolve(ret) + } } diff --git a/tauri-plugin-vpnservice/android/src/main/res/drawable/vpn_tile_icon.xml b/tauri-plugin-vpnservice/android/src/main/res/drawable/vpn_tile_icon.xml new file mode 100644 index 00000000..9a3769ae --- /dev/null +++ b/tauri-plugin-vpnservice/android/src/main/res/drawable/vpn_tile_icon.xml @@ -0,0 +1,9 @@ + + + diff --git a/tauri-plugin-vpnservice/android/src/main/res/values/strings.xml b/tauri-plugin-vpnservice/android/src/main/res/values/strings.xml new file mode 100644 index 00000000..c55166ca --- /dev/null +++ b/tauri-plugin-vpnservice/android/src/main/res/values/strings.xml @@ -0,0 +1,3 @@ + + EasyTier VPN + diff --git a/tauri-plugin-vpnservice/build.rs b/tauri-plugin-vpnservice/build.rs index 0c02e10a..2b8ebbb8 100644 --- a/tauri-plugin-vpnservice/build.rs +++ b/tauri-plugin-vpnservice/build.rs @@ -4,6 +4,7 @@ const COMMANDS: &[&str] = &[ "start_vpn", "stop_vpn", "get_vpn_status", + "consume_vpn_tile_action", "registerListener", ]; diff --git a/tauri-plugin-vpnservice/guest-js/index.ts b/tauri-plugin-vpnservice/guest-js/index.ts index 1ce6d662..2044ed5e 100644 --- a/tauri-plugin-vpnservice/guest-js/index.ts +++ b/tauri-plugin-vpnservice/guest-js/index.ts @@ -28,6 +28,12 @@ export interface VpnStatusResponse { dns?: string; } +export type VpnTileAction = 'start' | 'stop'; + +export interface VpnTileActionResponse { + action?: VpnTileAction; +} + export async function prepare_vpn(): Promise { return await invoke('plugin:vpnservice|prepare_vpn', {}) } @@ -45,3 +51,7 @@ export async function stop_vpn(): Promise { export async function get_vpn_status(): Promise { return await invoke('plugin:vpnservice|get_vpn_status', {}) } + +export async function consume_vpn_tile_action(): Promise { + return await invoke('plugin:vpnservice|consume_vpn_tile_action', {}) +} diff --git a/tauri-plugin-vpnservice/permissions/autogenerated/commands/consume_vpn_tile_action.toml b/tauri-plugin-vpnservice/permissions/autogenerated/commands/consume_vpn_tile_action.toml new file mode 100644 index 00000000..94f6dfd3 --- /dev/null +++ b/tauri-plugin-vpnservice/permissions/autogenerated/commands/consume_vpn_tile_action.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-consume-vpn-tile-action" +description = "Enables the consume_vpn_tile_action command without any pre-configured scope." +commands.allow = ["consume_vpn_tile_action"] + +[[permission]] +identifier = "deny-consume-vpn-tile-action" +description = "Denies the consume_vpn_tile_action command without any pre-configured scope." +commands.deny = ["consume_vpn_tile_action"] diff --git a/tauri-plugin-vpnservice/permissions/autogenerated/reference.md b/tauri-plugin-vpnservice/permissions/autogenerated/reference.md index 1c22f0c5..425e6b7f 100644 --- a/tauri-plugin-vpnservice/permissions/autogenerated/reference.md +++ b/tauri-plugin-vpnservice/permissions/autogenerated/reference.md @@ -16,6 +16,32 @@ Default permissions for the plugin + + + +`vpnservice:allow-consume-vpn-tile-action` + + + + +Enables the consume_vpn_tile_action command without any pre-configured scope. + + + + + + + +`vpnservice:deny-consume-vpn-tile-action` + + + + +Denies the consume_vpn_tile_action command without any pre-configured scope. + + + + diff --git a/tauri-plugin-vpnservice/permissions/schemas/schema.json b/tauri-plugin-vpnservice/permissions/schemas/schema.json index 5ab4c092..adb42fc8 100644 --- a/tauri-plugin-vpnservice/permissions/schemas/schema.json +++ b/tauri-plugin-vpnservice/permissions/schemas/schema.json @@ -294,6 +294,18 @@ "PermissionKind": { "type": "string", "oneOf": [ + { + "description": "Enables the consume_vpn_tile_action command without any pre-configured scope.", + "type": "string", + "const": "allow-consume-vpn-tile-action", + "markdownDescription": "Enables the consume_vpn_tile_action command without any pre-configured scope." + }, + { + "description": "Denies the consume_vpn_tile_action command without any pre-configured scope.", + "type": "string", + "const": "deny-consume-vpn-tile-action", + "markdownDescription": "Denies the consume_vpn_tile_action command without any pre-configured scope." + }, { "description": "Enables the get_vpn_status command without any pre-configured scope.", "type": "string", diff --git a/tauri-plugin-vpnservice/src/mobile.rs b/tauri-plugin-vpnservice/src/mobile.rs index 20ed1efb..28703118 100644 --- a/tauri-plugin-vpnservice/src/mobile.rs +++ b/tauri-plugin-vpnservice/src/mobile.rs @@ -57,4 +57,13 @@ impl Vpnservice { .run_mobile_plugin("get_vpn_status", payload) .map_err(Into::into) } + + pub fn consume_vpn_tile_action( + &self, + payload: VoidRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("consume_vpn_tile_action", payload) + .map_err(Into::into) + } } diff --git a/tauri-plugin-vpnservice/src/models.rs b/tauri-plugin-vpnservice/src/models.rs index 7c1716d8..a1b8a869 100644 --- a/tauri-plugin-vpnservice/src/models.rs +++ b/tauri-plugin-vpnservice/src/models.rs @@ -42,3 +42,9 @@ pub struct VpnStatus { pub routes: Option>, pub dns: Option, } + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct VpnTileActionResponse { + pub action: Option, +}