diff --git a/.github/workflows/gui.yml b/.github/workflows/gui.yml index a910493..8434aeb 100644 --- a/.github/workflows/gui.yml +++ b/.github/workflows/gui.yml @@ -88,8 +88,8 @@ jobs: - name: Install frontend dependencies run: | - cd easytier-gui - pnpm install + (cd easytier-gui; pnpm install) + (cd tauri-plugin-vpnservice; pnpm install; pnpm build) - name: Cargo cache uses: actions/cache@v4 diff --git a/.github/workflows/mobile.yml b/.github/workflows/mobile.yml new file mode 100644 index 0000000..7afc593 --- /dev/null +++ b/.github/workflows/mobile.yml @@ -0,0 +1,164 @@ +name: EasyTier Mobile + +on: + push: + branches: ["develop", "main"] + pull_request: + branches: ["develop", "main"] + +env: + CARGO_TERM_COLOR: always + +defaults: + run: + # necessary for windows + shell: bash + +jobs: + pre_job: + # continue-on-error: true # Uncomment once integration is finished + runs-on: ubuntu-latest + # Map a step output to a job output + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5 + with: + # All of these options are optional, so you can remove them if you are happy with the defaults + concurrent_skipping: 'never' + skip_after_successful_duplicate: 'true' + paths: '["Cargo.toml", "Cargo.lock", "easytier/**", "easytier-gui/**", "tauri-plugin-vpnservice/**", ".github/workflows/mobile.yml"]' + build-mobile: + strategy: + fail-fast: false + matrix: + include: + - TARGET: android + OS: ubuntu-latest + runs-on: ${{ matrix.OS }} + env: + NAME: easytier + TARGET: ${{ matrix.TARGET }} + OS: ${{ matrix.OS }} + OSS_BUCKET: ${{ secrets.ALIYUN_OSS_BUCKET }} + needs: pre_job + if: needs.pre_job.outputs.should_skip != 'true' + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-java@v4 + with: + distribution: 'oracle' + java-version: '20' + + - name: Setup Android SDK + uses: android-actions/setup-android@v3 + with: + cmdline-tools-version: 11076708 + packages: 'build-tools;34.0.0 ndk;26.0.10792818 tools platform-tools platforms;android-34 ' + + - name: Setup Android Environment + run: | + echo "$ANDROID_HOME/platform-tools" >> $GITHUB_PATH + echo "$ANDROID_HOME/ndk/26.0.10792818/toolchains/llvm/prebuilt/linux-x86_64/bin" >> $GITHUB_PATH + echo "NDK_HOME=$ANDROID_HOME/ndk/26.0.10792818/" > $GITHUB_ENV + + - uses: actions/setup-node@v4 + with: + node-version: 21 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 9 + run_install: false + + - name: Get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: Setup pnpm cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: Install frontend dependencies + run: | + (cd easytier-gui; pnpm install) + (cd tauri-plugin-vpnservice; pnpm install; pnpm build) + + - name: Cargo cache + uses: actions/cache@v4 + with: + path: | + ~/.cargo + ./target + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Install rust target + run: | + bash ./.github/workflows/install_rust.sh + rustup target add aarch64-linux-android + rustup target add armv7-linux-androideabi + rustup target add i686-linux-android + rustup target add x86_64-linux-android + + - name: Setup protoc + uses: arduino/setup-protoc@v2 + with: + # GitHub repo token to use to avoid rate limiter + repo-token: ${{ secrets.GITHUB_TOKEN }} + + - name: Build Android + run: | + cd easytier-gui + pnpm tauri android build + + - name: Compress + run: | + mkdir -p ./artifacts/objects/ + mv easytier-gui/src-tauri/gen/android/app/build/outputs/apk/universal/release/app-universal-release.apk ./artifacts/objects/ + + if [[ $GITHUB_REF_TYPE =~ ^tag$ ]]; then + TAG=$GITHUB_REF_NAME + else + TAG=$GITHUB_SHA + fi + + tar -cvf ./artifacts/$NAME-$TARGET-$TAG.tar -C ./artifacts/objects/ . + rm -rf ./artifacts/objects/ + + - name: Archive artifact + uses: actions/upload-artifact@v4 + with: + name: easytier-gui-${{ matrix.TARGET }} + path: | + ./artifacts/* + + - name: Upload OSS + if: ${{ env.OSS_BUCKET != '' }} + uses: Menci/upload-to-oss@main + with: + access-key-id: ${{ secrets.ALIYUN_OSS_ACCESS_ID }} + access-key-secret: ${{ secrets.ALIYUN_OSS_ACCESS_KEY }} + endpoint: ${{ secrets.ALIYUN_OSS_ENDPOINT }} + bucket: ${{ secrets.ALIYUN_OSS_BUCKET }} + local-path: ./artifacts/ + remote-path: /easytier-releases/${{ github.sha }}/mobile + no-delete-remote-files: true + retry: 5 + mobile-result: + if: needs.pre_job.outputs.should_skip != 'true' && always() + runs-on: ubuntu-latest + needs: + - pre_job + - build-mobile + steps: + - name: Mark result as failed + if: needs.build-mobile.result != 'success' + run: exit 1 diff --git a/Cargo.lock b/Cargo.lock index 5c72e7e..07ad3cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1448,9 +1448,11 @@ dependencies = [ "tauri", "tauri-build", "tauri-plugin-clipboard-manager", + "tauri-plugin-os", "tauri-plugin-positioner", "tauri-plugin-process", "tauri-plugin-shell", + "tauri-plugin-vpnservice", "tokio", ] @@ -3569,6 +3571,17 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_info" +version = "3.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae99c7fa6dd38c7cafe1ec085e804f8f555a2f8659b0dbe03f1f9963a9b51092" +dependencies = [ + "log", + "serde", + "windows-sys 0.52.0", +] + [[package]] name = "os_pipe" version = "1.1.5" @@ -5311,6 +5324,15 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7065abeca94b6a8a577f9bd45aa0867a2238b74e8eb67cf10d492bc39351394" +[[package]] +name = "sys-locale" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e801cf239ecd6ccd71f03d270d67dd53d13e90aab208bf4b8fe4ad957ea949b0" +dependencies = [ + "libc", +] + [[package]] name = "system-configuration" version = "0.5.1" @@ -5618,6 +5640,24 @@ dependencies = [ "thiserror", ] +[[package]] +name = "tauri-plugin-os" +version = "2.0.0-beta.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2946635d31de19ed4191f1c556da20d1257f4667a1bac03852d9bb4be9fa8ffa" +dependencies = [ + "gethostname", + "log", + "os_info", + "serde", + "serde_json", + "serialize-to-javascript", + "sys-locale", + "tauri", + "tauri-plugin", + "thiserror", +] + [[package]] name = "tauri-plugin-positioner" version = "2.0.0-beta.8" @@ -5664,6 +5704,16 @@ dependencies = [ "tokio", ] +[[package]] +name = "tauri-plugin-vpnservice" +version = "0.0.0" +dependencies = [ + "serde", + "tauri", + "tauri-plugin", + "thiserror", +] + [[package]] name = "tauri-runtime" version = "2.0.0-beta.19" @@ -6280,9 +6330,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tun-easytier" -version = "0.6.1" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6d01bd11265e1cb5ca22e9103daf57194afa43b1dc4c8cd49b950c969ffbe7c" +checksum = "060078376b7da6986f3050f23cdb123b05371f11802c1bdc5c0af919db9292bc" dependencies = [ "byteorder", "bytes", diff --git a/easytier-gui/.gitignore b/easytier-gui/.gitignore index 49ef0bd..3613fa4 100644 --- a/easytier-gui/.gitignore +++ b/easytier-gui/.gitignore @@ -23,3 +23,5 @@ dist-ssr *.njsproj *.sln *.sw? + +vite.config.ts.timestamp* diff --git a/easytier-gui/README.md b/easytier-gui/README.md index e6b0bd5..33b405b 100644 --- a/easytier-gui/README.md +++ b/easytier-gui/README.md @@ -1,16 +1,46 @@ -# Tauri + Vue 3 + TypeScript +# GUI for EasyTier -This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 ` @@ -75,10 +78,8 @@ onMounted(async () => { - + /24 @@ -93,10 +94,8 @@ onMounted(async () => { {{ t('network_secret') }} - + @@ -104,21 +103,15 @@ onMounted(async () => { {{ t('networking_method') }} - - + + separator=" " class="grow" /> - + :options="presetPublicServers" /> @@ -132,20 +125,16 @@ onMounted(async () => { {{ t('hostname') }} - + {{ t('proxy_cidrs') }} - + @@ -153,25 +142,19 @@ onMounted(async () => { VPN Portal - + - + /{{ curNetwork.vpn_portal_client_network_len }} - + @@ -179,30 +162,24 @@ onMounted(async () => { {{ t('listener_urls') }} - + {{ t('rpc_port') }} - + - + diff --git a/easytier-gui/src/composables/mobile_vpn.ts b/easytier-gui/src/composables/mobile_vpn.ts new file mode 100644 index 0000000..8cf4883 --- /dev/null +++ b/easytier-gui/src/composables/mobile_vpn.ts @@ -0,0 +1,129 @@ +import { addPluginListener } from '@tauri-apps/api/core'; +import { prepare_vpn, start_vpn, stop_vpn } from 'tauri-plugin-vpnservice-api'; + +const networkStore = useNetworkStore() + +interface vpnStatus { + running: boolean + ipv4Addr: string | null | undefined + ipv4Cidr: number | null | undefined +} + +var curVpnStatus: vpnStatus = { + running: false, + ipv4Addr: undefined, + ipv4Cidr: undefined, +} + +async function waitVpnStatus(target_status: boolean, timeout_sec: number) { + let start_time = Date.now() + while (curVpnStatus.running !== target_status) { + if (Date.now() - start_time > timeout_sec * 1000) { + throw new Error('wait vpn status timeout') + } + await new Promise(r => setTimeout(r, 50)) + } + +} + +async function doStopVpn() { + if (!curVpnStatus.running) { + return + } + console.log('stop vpn') + let stop_ret = await stop_vpn() + console.log('stop vpn', JSON.stringify((stop_ret))) + await waitVpnStatus(false, 3) + + curVpnStatus.ipv4Addr = undefined +} + +async function doStartVpn(ipv4Addr: string, cidr: number) { + if (curVpnStatus.running) { + return + } + + console.log('start vpn') + let start_ret = await start_vpn({ + "ipv4Addr": ipv4Addr + '/' + cidr, + "routes": ["0.0.0.0/0"], + "disallowedApplications": ["com.kkrainbow.easytier"], + "mtu": 1300, + }); + if (start_ret?.errorMsg?.length) { + throw new Error(start_ret.errorMsg) + } + await waitVpnStatus(true, 3) + + curVpnStatus.ipv4Addr = ipv4Addr +} + +async function onVpnServiceStart(payload: any) { + console.log('vpn service start', JSON.stringify(payload)) + curVpnStatus.running = true + if (payload.fd) { + setTunFd(networkStore.networkInstanceIds[0], payload.fd) + } +} + +async function onVpnServiceStop(payload: any) { + console.log('vpn service stop', JSON.stringify(payload)) + curVpnStatus.running = false + networkStore.clearNetworkInstances() + await retainNetworkInstance(networkStore.networkInstanceIds) +} + +async function registerVpnServiceListener() { + console.log('register vpn service listener') + await addPluginListener( + 'vpnservice', + 'vpn_service_start', + onVpnServiceStart + ) + + await addPluginListener( + 'vpnservice', + 'vpn_service_stop', + onVpnServiceStop + ) +} + +async function watchNetworkInstance() { + networkStore.$subscribe(async () => { + let insts = networkStore.networkInstanceIds + if (!insts) { + await doStopVpn() + return + } + + const curNetworkInfo = networkStore.networkInfos[insts[0]] + if (!curNetworkInfo || curNetworkInfo?.error_msg?.length) { + await doStopVpn() + return + } + + const virtual_ip = curNetworkInfo?.node_info?.virtual_ipv4 + if (virtual_ip !== curVpnStatus.ipv4Addr) { + console.log('virtual ip changed', JSON.stringify(curVpnStatus), virtual_ip) + await doStopVpn() + if (virtual_ip.length > 0) { + await doStartVpn(virtual_ip, 24) + } + return + } + }) +} + +export async function initMobileVpnService() { + await registerVpnServiceListener() + await watchNetworkInstance() +} + +export async function prepareVpnService() { + console.log('prepare vpn') + let prepare_ret = await prepare_vpn() + console.log('prepare vpn', JSON.stringify((prepare_ret))) + if (prepare_ret?.errorMsg?.length) { + throw new Error(prepare_ret.errorMsg) + } +} diff --git a/easytier-gui/src/composables/network.ts b/easytier-gui/src/composables/network.ts index bf5173c..5b4eebf 100644 --- a/easytier-gui/src/composables/network.ts +++ b/easytier-gui/src/composables/network.ts @@ -29,3 +29,7 @@ export async function setAutoLaunchStatus(enable: boolean) { export async function setLoggingLevel(level: string) { return await invoke('set_logging_level', { level }) } + +export async function setTunFd(instanceId: string, fd: number) { + return await invoke('set_tun_fd', { instanceId, fd }) +} diff --git a/easytier-gui/src/composables/tray.ts b/easytier-gui/src/composables/tray.ts index a345b98..411a77d 100644 --- a/easytier-gui/src/composables/tray.ts +++ b/easytier-gui/src/composables/tray.ts @@ -15,20 +15,26 @@ async function toggleVisibility() { } export async function useTray(init: boolean = false) { - let tray = await TrayIcon.getById(DEFAULT_TRAY_NAME) - if (!tray) { - tray = await TrayIcon.new({ - tooltip: `EasyTier\n${pkg.version}`, - title: `EasyTier\n${pkg.version}`, - id: DEFAULT_TRAY_NAME, - menu: await Menu.new({ - id: 'main', - items: await generateMenuItem(), - }), - action: async () => { - toggleVisibility() - } - }) + let tray; + try { + tray = await TrayIcon.getById(DEFAULT_TRAY_NAME) + if (!tray) { + tray = await TrayIcon.new({ + tooltip: `EasyTier\n${pkg.version}`, + title: `EasyTier\n${pkg.version}`, + id: DEFAULT_TRAY_NAME, + menu: await Menu.new({ + id: 'main', + items: await generateMenuItem(), + }), + action: async () => { + toggleVisibility() + } + }) + } + } catch (error) { + console.warn('Error while creating tray icon:', error) + return null } if (init) { @@ -63,13 +69,14 @@ export async function MenuItemShow(text: string) { id: 'show', text, action: async () => { - await toggleVisibility(); + await toggleVisibility(); }, }) } export async function setTrayMenu(items: (MenuItem | PredefinedMenuItem)[] | undefined = undefined) { const tray = await useTray() + if (!tray) return const menu = await Menu.new({ id: 'main', items: items || await generateMenuItem(), @@ -79,12 +86,14 @@ export async function setTrayMenu(items: (MenuItem | PredefinedMenuItem)[] | und export async function setTrayRunState(isRunning: boolean = false) { const tray = await useTray() + if (!tray) return tray.setIcon(isRunning ? 'icons/icon-inactive.ico' : 'icons/icon.ico') } export async function setTrayTooltip(tooltip: string) { if (tooltip) { const tray = await useTray() + if (!tray) return tray.setTooltip(`EasyTier\n${pkg.version}\n${tooltip}`) tray.setTitle(`EasyTier\n${pkg.version}\n${tooltip}`) } diff --git a/easytier-gui/src/pages/index.vue b/easytier-gui/src/pages/index.vue index c8c313c..696f854 100644 --- a/easytier-gui/src/pages/index.vue +++ b/easytier-gui/src/pages/index.vue @@ -15,6 +15,7 @@ import { open } from '@tauri-apps/plugin-shell'; import { appLogDir } from '@tauri-apps/api/path' import { writeText } from '@tauri-apps/plugin-clipboard-manager'; import { useTray } from '~/composables/tray'; +import { type } from '@tauri-apps/plugin-os'; const { t, locale } = useI18n() const visible = ref(false) @@ -82,8 +83,13 @@ networkStore.$subscribe(async () => { }) async function runNetworkCb(cfg: NetworkConfig, cb: () => void) { - cb() - networkStore.removeNetworkInstance(cfg.instance_id) + if (type() === 'android') { + await prepareVpnService() + networkStore.clearNetworkInstances() + } else { + networkStore.removeNetworkInstance(cfg.instance_id) + } + await retainNetworkInstance(networkStore.networkInstanceIds) networkStore.addNetworkInstance(cfg.instance_id) @@ -94,6 +100,8 @@ async function runNetworkCb(cfg: NetworkConfig, cb: () => void) { // console.error(e) toast.add({ severity: 'info', detail: e }) } + + cb() } async function stopNetworkCb(cfg: NetworkConfig, cb: () => void) { @@ -112,9 +120,9 @@ onMounted(async () => { intervalId = window.setInterval(async () => { await updateNetworkInfos() }, 500) - await setTrayMenu([ - await MenuItemExit(t('tray.exit')), - await MenuItemShow(t('tray.show')) + await setTrayMenu([ + await MenuItemExit(t('tray.exit')), + await MenuItemShow(t('tray.show')) ]) }) onUnmounted(() => clearInterval(intervalId)) @@ -132,9 +140,9 @@ const setting_menu_items = ref([ icon: 'pi pi-language', command: async () => { await loadLanguageAsync((locale.value === 'en' ? 'cn' : 'en')) - await setTrayMenu([ - await MenuItemExit(t('tray.exit')), - await MenuItemShow(t('tray.show')) + await setTrayMenu([ + await MenuItemExit(t('tray.exit')), + await MenuItemShow(t('tray.show')) ]) }, }, @@ -206,11 +214,15 @@ onMounted(async () => { } } } + if (type() === 'android') { + await initMobileVpnService() + } }) function isRunning(id: string) { return networkStore.networkInstanceIds.includes(id) } +