mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-20 03:22:05 +00:00
feat(android): add VPN quick settings tile (#2511)
add an Android Quick Settings tile for starting and stopping EasyTier VPN networks persist tile actions until the Tauri frontend is ready, so cold-start clicks are not lost add the standard Quick Settings preferences activity alias so long-pressing the tile opens EasyTier keep network and VPN lifecycle ownership in the existing frontend reconciliation flow
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
@@ -22,6 +23,16 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name=".VpnTilePreferencesActivity"
|
||||
android:targetActivity=".MainActivity"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<service
|
||||
android:name=".MainForegroundService"
|
||||
android:foregroundServiceType="dataSync"
|
||||
@@ -46,6 +57,9 @@
|
||||
android:label="@string/main_activity_title"
|
||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Maintains the user-initiated EasyTier VPN connection." />
|
||||
<intent-filter>
|
||||
<action android:name="android.net.VpnService" />
|
||||
</intent-filter>
|
||||
|
||||
Vendored
+6
@@ -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<typeof import('pinia')['acceptHMRUpdate']>
|
||||
readonly collectNetworkInfo: UnwrapRef<typeof import('./composables/backend')['collectNetworkInfo']>
|
||||
readonly computed: UnwrapRef<typeof import('vue')['computed']>
|
||||
readonly consumePendingMobileVpnTileAction: UnwrapRef<typeof import('./composables/mobile_vpn')['consumePendingMobileVpnTileAction']>
|
||||
readonly createApp: UnwrapRef<typeof import('vue')['createApp']>
|
||||
readonly createPinia: UnwrapRef<typeof import('pinia')['createPinia']>
|
||||
readonly customRef: UnwrapRef<typeof import('vue')['customRef']>
|
||||
@@ -148,6 +152,7 @@ declare module 'vue' {
|
||||
readonly defineStore: UnwrapRef<typeof import('pinia')['defineStore']>
|
||||
readonly deleteNetworkInstance: UnwrapRef<typeof import('./composables/backend')['deleteNetworkInstance']>
|
||||
readonly effectScope: UnwrapRef<typeof import('vue')['effectScope']>
|
||||
readonly executeVpnTileAction: UnwrapRef<typeof import('./composables/mobile_vpn_tile')['executeVpnTileAction']>
|
||||
readonly generateMenuItem: UnwrapRef<typeof import('./composables/tray')['generateMenuItem']>
|
||||
readonly generateNetworkConfig: UnwrapRef<typeof import('./composables/backend')['generateNetworkConfig']>
|
||||
readonly getActivePinia: UnwrapRef<typeof import('pinia')['getActivePinia']>
|
||||
@@ -215,6 +220,7 @@ declare module 'vue' {
|
||||
readonly setActivePinia: UnwrapRef<typeof import('pinia')['setActivePinia']>
|
||||
readonly setLoggingLevel: UnwrapRef<typeof import('./composables/backend')['setLoggingLevel']>
|
||||
readonly setMapStoreSuffix: UnwrapRef<typeof import('pinia')['setMapStoreSuffix']>
|
||||
readonly setMobileVpnTileActionHandler: UnwrapRef<typeof import('./composables/mobile_vpn')['setMobileVpnTileActionHandler']>
|
||||
readonly setServiceStatus: UnwrapRef<typeof import('./composables/backend')['setServiceStatus']>
|
||||
readonly setTrayMenu: UnwrapRef<typeof import('./composables/tray')['setTrayMenu']>
|
||||
readonly setTrayRunState: UnwrapRef<typeof import('./composables/tray')['setTrayRunState']>
|
||||
|
||||
@@ -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<Record<string, unknown>>>(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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<void> = Promise.resolve()
|
||||
let vpnPermissionRequest: Promise<boolean> | null = null
|
||||
let vpnTileActionHandler: ((action: VpnTileAction) => Promise<void>) | undefined
|
||||
let vpnTileActionQueue: Promise<void> = Promise.resolve()
|
||||
|
||||
const curVpnStatus: vpnStatus = {
|
||||
running: false,
|
||||
@@ -33,6 +42,31 @@ const curVpnStatus: vpnStatus = {
|
||||
dns: undefined,
|
||||
}
|
||||
|
||||
export function setMobileVpnTileActionHandler(
|
||||
handler?: (action: VpnTileAction) => Promise<void>,
|
||||
) {
|
||||
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[] {
|
||||
|
||||
@@ -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 })
|
||||
})
|
||||
})
|
||||
@@ -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<void>
|
||||
}
|
||||
|
||||
export async function executeVpnTileAction(
|
||||
action: VpnTileAction,
|
||||
api: Api.RemoteClient,
|
||||
options: VpnTileActionOptions,
|
||||
): Promise<VpnTileActionResult> {
|
||||
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 }
|
||||
}
|
||||
@@ -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<string | undefined>(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);
|
||||
|
||||
@@ -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 图标
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,5 +7,16 @@
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<application>
|
||||
<service
|
||||
android:name=".EasyTierVpnTileService"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/vpn_tile_icon"
|
||||
android:label="@string/vpn_tile_label"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFFFF"
|
||||
android:pathData="M12,2L4,5.5V11C4,16.05 7.41,20.74 12,22C16.59,20.74 20,16.05 20,11V5.5L12,2ZM12,4.18L18,6.8V11C18,14.89 15.48,18.67 12,19.93C8.52,18.67 6,14.89 6,11V6.8L12,4.18ZM11,7V11H8L12,16V12H15L11,7Z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="vpn_tile_label">EasyTier VPN</string>
|
||||
</resources>
|
||||
@@ -4,6 +4,7 @@ const COMMANDS: &[&str] = &[
|
||||
"start_vpn",
|
||||
"stop_vpn",
|
||||
"get_vpn_status",
|
||||
"consume_vpn_tile_action",
|
||||
"registerListener",
|
||||
];
|
||||
|
||||
|
||||
@@ -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<InvokeResponse | null> {
|
||||
return await invoke<InvokeResponse>('plugin:vpnservice|prepare_vpn', {})
|
||||
}
|
||||
@@ -45,3 +51,7 @@ export async function stop_vpn(): Promise<InvokeResponse | null> {
|
||||
export async function get_vpn_status(): Promise<VpnStatusResponse | null> {
|
||||
return await invoke<VpnStatusResponse>('plugin:vpnservice|get_vpn_status', {})
|
||||
}
|
||||
|
||||
export async function consume_vpn_tile_action(): Promise<VpnTileActionResponse> {
|
||||
return await invoke<VpnTileActionResponse>('plugin:vpnservice|consume_vpn_tile_action', {})
|
||||
}
|
||||
|
||||
+13
@@ -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"]
|
||||
@@ -16,6 +16,32 @@ Default permissions for the plugin
|
||||
</tr>
|
||||
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`vpnservice:allow-consume-vpn-tile-action`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the consume_vpn_tile_action command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`vpnservice:deny-consume-vpn-tile-action`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the consume_vpn_tile_action command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -57,4 +57,13 @@ impl<R: Runtime> Vpnservice<R> {
|
||||
.run_mobile_plugin("get_vpn_status", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
pub fn consume_vpn_tile_action(
|
||||
&self,
|
||||
payload: VoidRequest,
|
||||
) -> crate::Result<VpnTileActionResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("consume_vpn_tile_action", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,3 +42,9 @@ pub struct VpnStatus {
|
||||
pub routes: Option<Vec<String>>,
|
||||
pub dns: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct VpnTileActionResponse {
|
||||
pub action: Option<String>,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user