forked from core/mobile_nebula
Compare commits
80 Commits
log-timezo
...
master
Author | SHA1 | Date |
---|---|---|
core | 1ecaaa60ff | |
John Maguire | ec1af2974a | |
John Maguire | fbd2759d4f | |
Nate Brown | 83145f6fa4 | |
Nate Brown | 81901b5dae | |
John Maguire | dcf54b1c38 | |
John Maguire | 096bad25ee | |
John Maguire | 693c7b6346 | |
Nate Brown | 69d0641874 | |
Nate Brown | 450e7c714c | |
John Maguire | cfca253ec1 | |
John Maguire | f7a7093879 | |
Nate Brown | 5ae4c20c70 | |
John Maguire | 7f5fa57b93 | |
John Maguire | 5cc41f1129 | |
John Maguire | 4bbd6c01ea | |
John Maguire | ffe2db283c | |
John Maguire | b36914ef17 | |
John Maguire | 9947676fd0 | |
John Maguire | a435517e3e | |
John Maguire | a5139c4335 | |
Nate Brown | 6b1bbf7352 | |
Caleb Jasik | 84d1256656 | |
Nate Brown | 8fc3a40467 | |
John Maguire | 17cc3477b7 | |
John Maguire | 4924888879 | |
Nate Brown | bb457c7c83 | |
John Maguire | a5ec4f5ed5 | |
John Maguire | 974c7a4eed | |
Nate Brown | 6108bf801b | |
John Maguire | 37758d4a01 | |
John Maguire | 9dd5b9cad9 | |
John Maguire | e4bbd0a31c | |
John Maguire | a5684e1978 | |
John Maguire | c7a53c3905 | |
John Maguire | c3f5c39d83 | |
John Maguire | 5ec6004a9f | |
John Maguire | 552d16bce2 | |
Nate Brown | 7bf9ee553e | |
Nate Brown | 469195cdf7 | |
Nate Brown | d8e90a1b4b | |
Nate Brown | 562e715a9a | |
Nate Brown | dabca6dd32 | |
Nate Brown | b29661abff | |
Nate Brown | dbe67c2f81 | |
Nate Brown | e3780bda1e | |
Nate Brown | 958b15d711 | |
Nate Brown | 457952b5ed | |
Nate Brown | d37bdd9032 | |
Nate Brown | 64b056618c | |
Nate Brown | 2831c84b57 | |
John Maguire | 3e0da2a8f0 | |
John Maguire | fec77613bf | |
John Maguire | 145a6c9b4f | |
Nate Brown | 87ce46df19 | |
Nate Brown | 6c188ff1d1 | |
Nate Brown | a1694727aa | |
Nate Brown | 69830d64cd | |
Nate Brown | 93b106fd88 | |
Nate Brown | cff26eb679 | |
Nate Brown | de43f11ab2 | |
Nate Brown | bde3f6fdfa | |
John Maguire | 47865d568b | |
Nate Brown | f176047510 | |
Nate Brown | 9934f226e3 | |
Nate Brown | 3a37802f4d | |
John Maguire | f3fab82e34 | |
Nate Brown | 0bb2a30829 | |
Nate Brown | a283bf8010 | |
John Maguire | 3194028a78 | |
John Maguire | 1d044a1e36 | |
John Maguire | 3123ce5f9a | |
Nate Brown | a7c32f5bd4 | |
Nathan Brown | 4c28cc196e | |
Nathan Brown | 4cad646a7c | |
Nathan Brown | 1283ce30e9 | |
Nathan Brown | a5ca3f86af | |
John Maguire | 10d6b6bb9a | |
John Maguire | b2c674d65a | |
micha3lbrown | e844e2c195 |
|
@ -0,0 +1,2 @@
|
||||||
|
# Big flutter format run
|
||||||
|
9934f226e3e79c3567ce07dbab9e9f6443e7afc5
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
DIRS="lib test"
|
||||||
|
EXIT=0
|
||||||
|
|
||||||
|
for DIR in $DIRS; do
|
||||||
|
OUT="$(flutter format -l 120 --suppress-analytics "$DIR" | sed -e "s/^Formatted \(.*\)/::error file=$DIR\/\1::Not formatted/g")"
|
||||||
|
echo "$OUT" | grep "::error" && EXIT=1
|
||||||
|
done
|
||||||
|
|
||||||
|
exit $EXIT
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Flutter format
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/flutterfmt.yml'
|
||||||
|
- '.github/workflows/flutterfmt.sh'
|
||||||
|
- '**.dart'
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
gofmt:
|
||||||
|
name: Run flutter format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Install flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.3.5'
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: flutter format
|
||||||
|
run: $GITHUB_WORKSPACE/.github/workflows/flutterfmt.sh
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
rm -f ./gofmterr
|
||||||
|
find . -iname '*.go' ! -name '*.pb.go' -exec "$0" {} \;
|
||||||
|
[ -f ./gofmterr ] && exit 1
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT="$(./nebula/goimports -d "$1" | awk '{printf "%s%%0A",$0}')"
|
||||||
|
if [ -n "$OUT" ]; then
|
||||||
|
echo "::error file=$1::$OUT"
|
||||||
|
touch ./gofmterr
|
||||||
|
fi
|
|
@ -0,0 +1,34 @@
|
||||||
|
name: gofmt
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/gofmt.yml'
|
||||||
|
- '.github/workflows/gofmt.sh'
|
||||||
|
- '**.go'
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
gofmt:
|
||||||
|
name: Run gofmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Set up Go 1.20
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: "1.20"
|
||||||
|
id: go
|
||||||
|
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install goimports
|
||||||
|
working-directory: nebula
|
||||||
|
run: |
|
||||||
|
go get golang.org/x/tools/cmd/goimports
|
||||||
|
go build golang.org/x/tools/cmd/goimports
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
run: $GITHUB_WORKSPACE/.github/workflows/gofmt.sh
|
|
@ -0,0 +1,154 @@
|
||||||
|
name: Create release and upload to Apple and Google
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
# Only builds for tags with a meaningless build number suffix: v1.0.0-1
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ios and android package
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set up Go 1.20
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: "1.20"
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '11'
|
||||||
|
|
||||||
|
- name: Install flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.3.5'
|
||||||
|
|
||||||
|
- uses: nttld/setup-ndk@v1
|
||||||
|
id: setup-ndk
|
||||||
|
with:
|
||||||
|
ndk-version: r21b
|
||||||
|
# r21b is 21.1.6352462, if it is not published here https://developer.android.com/ndk/downloads
|
||||||
|
# or here https://github.com/android/ndk/wiki/Unsupported-Downloads then you must download them and look at
|
||||||
|
# source.properties to determine the version
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Configure git for private modules
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.MACHINE_USER_PAT }}
|
||||||
|
run: git config --global url."https://defined-machine:${TOKEN}@github.com".insteadOf "https://github.com"
|
||||||
|
|
||||||
|
- name: Install the appstore connect key material
|
||||||
|
env:
|
||||||
|
AC_API_KEY_SECRET_BASE64: ${{ secrets.AC_API_KEY_SECRET_BASE64 }}
|
||||||
|
run: |
|
||||||
|
AC_API_KEY_SECRET_PATH="$RUNNER_TEMP/key.p8"
|
||||||
|
echo "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH=$AC_API_KEY_SECRET_PATH" >> $GITHUB_ENV
|
||||||
|
echo -n "$AC_API_KEY_SECRET_BASE64" | base64 --decode --output "$AC_API_KEY_SECRET_PATH"
|
||||||
|
|
||||||
|
- name: Install the google play key material
|
||||||
|
env:
|
||||||
|
GOOGLE_PLAY_API_JWT_BASE64: ${{ secrets.GOOGLE_PLAY_API_JWT_BASE64 }}
|
||||||
|
GOOGLE_PLAY_KEYSTORE_BASE64: ${{ secrets.GOOGLE_PLAY_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
GOOGLE_PLAY_API_JWT_PATH="$RUNNER_TEMP/gp_api.json"
|
||||||
|
echo "GOOGLE_PLAY_API_JWT_PATH=$GOOGLE_PLAY_API_JWT_PATH" >> $GITHUB_ENV
|
||||||
|
echo -n "$GOOGLE_PLAY_API_JWT_BASE64" | base64 --decode --output "$GOOGLE_PLAY_API_JWT_PATH"
|
||||||
|
|
||||||
|
GOOGLE_PLAY_KEYSTORE_PATH="$RUNNER_TEMP/gp_signing.jks"
|
||||||
|
echo "GOOGLE_PLAY_KEYSTORE_PATH=$GOOGLE_PLAY_KEYSTORE_PATH" >> $GITHUB_ENV
|
||||||
|
echo -n "$GOOGLE_PLAY_KEYSTORE_BASE64" | base64 --decode --output "$GOOGLE_PLAY_KEYSTORE_PATH"
|
||||||
|
|
||||||
|
- name: Get build name and number, install dependencies
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||||
|
gomobile init
|
||||||
|
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
touch env.sh
|
||||||
|
|
||||||
|
cd android
|
||||||
|
fastlane release_build_number
|
||||||
|
echo "BUILD_NUMBER=$(cat ../release_build_number)" >> $GITHUB_ENV
|
||||||
|
BUILD_NAME="${GITHUB_REF#refs/tags/v}" # strip the front refs/tags/v off
|
||||||
|
BUILD_NAME="${BUILD_NAME%-*}" # strip the junk build number off
|
||||||
|
echo "BUILD_NAME=$BUILD_NAME" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build iOS
|
||||||
|
env:
|
||||||
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
cd ios
|
||||||
|
pod install
|
||||||
|
fastlane build
|
||||||
|
|
||||||
|
- name: Collect iOS artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: MobileNebula.ipa
|
||||||
|
path: ios/MobileNebula.ipa
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Build Android
|
||||||
|
env:
|
||||||
|
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||||
|
GOOGLE_PLAY_KEYSTORE_PASSWORD: ${{ secrets.GOOGLE_PLAY_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
flutter build appbundle --build-number="$BUILD_NUMBER" --build-name="$BUILD_NAME"
|
||||||
|
|
||||||
|
- name: Collect Android artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: MobileNebula.aab
|
||||||
|
path: build/app/outputs/bundle/release/app-release.aab
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Publish to iOS TestFlight
|
||||||
|
env:
|
||||||
|
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.AC_API_KEY_ISSUER_ID }}
|
||||||
|
run: |
|
||||||
|
cd ios
|
||||||
|
fastlane release
|
||||||
|
|
||||||
|
- name: Publish to Android internal track
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
fastlane release
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref }}
|
||||||
|
release_name: Release ${{ github.ref }}
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload release Android app
|
||||||
|
uses: actions/upload-release-asset@v1.0.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_path: build/app/outputs/bundle/release/app-release.aab
|
||||||
|
asset_name: MobileNebula.aab
|
||||||
|
asset_content_type: text/plain
|
||||||
|
|
||||||
|
- name: Upload release iOS app
|
||||||
|
uses: actions/upload-release-asset@v1.0.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_path: ios/MobileNebula.ipa
|
||||||
|
asset_name: MobileNebula.ipa
|
||||||
|
asset_content_type: text/plain
|
||||||
|
|
|
@ -47,3 +47,7 @@ lib/generated_plugin_registrant.dart
|
||||||
/lib/.gen.versions.dart
|
/lib/.gen.versions.dart
|
||||||
/ios/Flutter/.last_build_id
|
/ios/Flutter/.last_build_id
|
||||||
/local.properties
|
/local.properties
|
||||||
|
/.gradle/
|
||||||
|
*.keystore
|
||||||
|
/nebula/MobileNebula.xcframework/
|
||||||
|
/ios/MobileNebula.xcframework/
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.0.41] - 2021-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an option to wrap logs in the hamburger menu. (#10)
|
||||||
|
|
||||||
|
- IPv6 and better roaming support. (#24)
|
||||||
|
|
||||||
|
- Certificates can now be replaced. (#33)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded to Flutter 2. (#26)
|
||||||
|
|
||||||
|
- Upgraded core Nebula to 1.4.1. (#41)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- iOS: Reworked vpn process IPC for more reliable communication. (#28)
|
||||||
|
|
||||||
|
- Android: Detecting the active vpn site on app boot is now more reliable. (#29)
|
||||||
|
|
||||||
|
- Android: Quickly toggling site connection status no longer presents an error. (#16)
|
||||||
|
|
||||||
|
- Android: Better vpn shutdown support. (#34)
|
||||||
|
|
||||||
|
- Android: System DNS will continue to work when moving between IPv4 only and IPv6 networks. (#40)
|
||||||
|
|
||||||
|
## [0.0.38] - 2020-09-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial public release.
|
||||||
|
|
||||||
|
[0.0.38]: https://github.com/DefinedNet/mobile_nebula/releases/tag/v0.0.38
|
||||||
|
[0.0.41]: https://github.com/DefinedNet/mobile_nebula/releases/tag/v0.0.41
|
71
README.md
71
README.md
|
@ -1,14 +1,34 @@
|
||||||
# Dependencies
|
# Mobile Nebula
|
||||||
|
|
||||||
- [`flutter`](https://flutter.dev/docs/get-started/install)
|
[Play Store](https://play.google.com/store/apps/details?id=net.defined.mobile_nebula&hl=en_US&gl=US) | [App Store](https://apps.apple.com/us/app/mobile-nebula/id1509587936)
|
||||||
- [`gomobile`](https://godoc.org/golang.org/x/mobile/cmd/gomobile)
|
|
||||||
|
## Setting up dev environment
|
||||||
|
|
||||||
|
Install all of the following things:
|
||||||
|
|
||||||
|
- [`xcode`](https://apps.apple.com/us/app/xcode/)
|
||||||
- [`android-studio`](https://developer.android.com/studio)
|
- [`android-studio`](https://developer.android.com/studio)
|
||||||
- [Enable NDK](https://developer.android.com/studio/projects/install-ndk) Check local.properties for current NDK version
|
- [`flutter` 3.3.5](https://docs.flutter.dev/get-started/install)
|
||||||
|
- [`gomobile`](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile)
|
||||||
|
- [Flutter Android Studio Extension](https://docs.flutter.dev/get-started/editor?tab=androidstudio)
|
||||||
|
|
||||||
Copy env.sh.example to env.sh and update your PATH variable to expose both flutter and go bin directories
|
Ensure your path is set up correctly to execute flutter
|
||||||
|
|
||||||
```export PATH="$PATH:/path/to/go/bin:/path/to/flutter/bin```
|
Run `flutter doctor` and fix everything it complains before proceeding
|
||||||
|
|
||||||
|
*NOTE* on iOS, always open `Runner.xcworkspace` and NOT the `Runner.xccodeproj`
|
||||||
|
|
||||||
|
### Before first compile
|
||||||
|
|
||||||
|
- Copy `env.sh.example` and set it up for your machine
|
||||||
|
- Ensure you have run `gomobile init`
|
||||||
|
- In Android Studio, make sure you have the current ndk installed by going to Tools -> SDK Manager, go to the SDK Tools tab, check the `Show package details` box, expand the NDK section and select `21.1.6352462` version.
|
||||||
|
- Ensure you have downloaded an ndk via android studio, this is likely not the default one and you need to check the
|
||||||
|
`Show package details` box to select the correct version. The correct version comes from the error when you try and compile
|
||||||
|
- Make sure you have `gem` installed with `sudo gem install`
|
||||||
|
- If on MacOS arm, `sudo gem install ffi -- --enable-libffi-alloc`
|
||||||
|
|
||||||
|
If you are having issues with iOS pods, try blowing it all away! `cd ios && rm -rf Pods/ Podfile.lock && pod install --repo-update`
|
||||||
|
|
||||||
# Formatting
|
# Formatting
|
||||||
|
|
||||||
|
@ -19,49 +39,18 @@ Use:
|
||||||
flutter format lib/ test/ -l 120
|
flutter format lib/ test/ -l 120
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
||||||
# Release
|
# Release
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Android
|
|
||||||
|
|
||||||
## Terminology
|
|
||||||
APK: Android Package
|
|
||||||
ADB: Android Debug Bridge - A tool for interacting with running emulators
|
|
||||||
AVD: Android Virtual Device Manager - A tool for launching, configuring and managing emulators
|
|
||||||
|
|
||||||
# `ADB` (Android Debug Bridge)
|
|
||||||
Android Debug Bridge is an executible that can be found under `~/Android/Sdk/platform-tools/adb` for ease of use add `~/Android/Sdk/platform-tools/` to your `$PATH`. `adb` surfaces a few useful commands.
|
|
||||||
|
|
||||||
You can sideload a downloaded APK into a running emulator
|
|
||||||
```
|
|
||||||
$ adb install ~/Downloads/Ping_v1.7.03_apkpure.com.apk
|
|
||||||
Performing Streamed Install
|
|
||||||
Success
|
|
||||||
```
|
|
||||||
|
|
||||||
You can also use shell to access network utilities like ping
|
|
||||||
```
|
|
||||||
$ adb shell ping 127.0.0.1
|
|
||||||
PING 127.0.0.1 (127.0.0.1) 56(84) bytes of data.
|
|
||||||
64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.206 ms
|
|
||||||
64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.056 ms
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
## Release
|
|
||||||
Update `version` in `pubspec.yaml` to reflect this release, then
|
Update `version` in `pubspec.yaml` to reflect this release, then
|
||||||
|
|
||||||
`flutter build appbundle --no-shrink`
|
## Android
|
||||||
|
|
||||||
|
`flutter build appbundle`
|
||||||
|
|
||||||
This will create an android app bundle at `build/app/outputs/bundle/release/`
|
This will create an android app bundle at `build/app/outputs/bundle/release/`
|
||||||
|
|
||||||
Upload the android bundle to the google play store https://play.google.com/apps/publish
|
Upload the android bundle to the google play store https://play.google.com/apps/publish
|
||||||
|
|
||||||
# iOS
|
## iOS
|
||||||
|
|
||||||
In xcode, Release -> Archive then follow the directions to upload to the app store. If you have issues, https://flutter.dev/docs/deployment/ios#create-a-build-archive
|
In xcode, Release -> Archive then follow the directions to upload to the app store. If you have issues, https://flutter.dev/docs/deployment/ios#create-a-build-archive
|
||||||
|
|
||||||
## Release
|
|
||||||
Update `version` in `pubspec.yaml` to reflect this release, then
|
|
||||||
|
|
|
@ -6,3 +6,4 @@ gradle-wrapper.jar
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
/build/build-attribution/
|
/build/build-attribution/
|
||||||
|
/mobileNebula/mobileNebula.aar
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Autogenerated by fastlane
|
||||||
|
#
|
||||||
|
# Ensure this file is checked in to source control!
|
||||||
|
|
||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem 'fastlane'
|
||||||
|
|
||||||
|
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||||
|
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
|
@ -0,0 +1,220 @@
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.5)
|
||||||
|
rexml
|
||||||
|
addressable (2.8.1)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
artifactory (3.0.15)
|
||||||
|
atomos (0.1.3)
|
||||||
|
aws-eventstream (1.2.0)
|
||||||
|
aws-partitions (1.635.0)
|
||||||
|
aws-sdk-core (3.153.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
aws-partitions (~> 1, >= 1.525.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
aws-sdk-kms (1.58.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sdk-s3 (1.114.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sigv4 (1.5.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
babosa (1.0.4)
|
||||||
|
claide (1.1.0)
|
||||||
|
colored (1.2)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
commander (4.6.0)
|
||||||
|
highline (~> 2.0.0)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.6.4)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
emoji_regex (3.2.3)
|
||||||
|
excon (0.92.5)
|
||||||
|
faraday (1.10.2)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-cookie_jar (0.0.7)
|
||||||
|
faraday (>= 0.8.0)
|
||||||
|
http-cookie (~> 1.0.0)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
faraday_middleware (1.2.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
fastimage (2.2.6)
|
||||||
|
fastlane (2.210.1)
|
||||||
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
artifactory (~> 3.0)
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
|
colored
|
||||||
|
commander (~> 4.6)
|
||||||
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
|
faraday_middleware (~> 1.0)
|
||||||
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-storage (~> 1.31)
|
||||||
|
highline (~> 2.0)
|
||||||
|
json (< 3.0.0)
|
||||||
|
jwt (>= 2.1.0, < 3)
|
||||||
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multipart-post (~> 2.0.0)
|
||||||
|
naturally (~> 2.2)
|
||||||
|
optparse (~> 0.1.1)
|
||||||
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
|
security (= 0.1.3)
|
||||||
|
simctl (~> 1.6.3)
|
||||||
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
|
word_wrap (~> 1.0.0)
|
||||||
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
|
xcpretty (~> 0.3.0)
|
||||||
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
|
fastlane-plugin-match_keystore (0.2.1)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
google-apis-androidpublisher_v3 (0.27.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-core (0.9.0)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
webrick
|
||||||
|
google-apis-iamcredentials_v1 (0.14.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-playcustomapp_v1 (0.10.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.17.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-cloud-core (1.6.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
|
google-cloud-errors (1.3.0)
|
||||||
|
google-cloud-storage (1.42.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
google-apis-storage_v1 (~> 0.17.0)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.2.0)
|
||||||
|
faraday (>= 0.17.3, < 3.a)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
|
highline (2.0.3)
|
||||||
|
http-cookie (1.0.5)
|
||||||
|
domain_name (~> 0.5)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
jmespath (1.6.1)
|
||||||
|
json (2.6.2)
|
||||||
|
jwt (2.5.0)
|
||||||
|
memoist (0.16.2)
|
||||||
|
mini_magick (4.11.0)
|
||||||
|
mini_mime (1.1.2)
|
||||||
|
multi_json (1.15.0)
|
||||||
|
multipart-post (2.0.0)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
naturally (2.2.1)
|
||||||
|
optparse (0.1.1)
|
||||||
|
os (1.1.4)
|
||||||
|
plist (3.6.0)
|
||||||
|
public_suffix (5.0.0)
|
||||||
|
rake (13.0.6)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.1.2)
|
||||||
|
rexml (3.2.5)
|
||||||
|
rouge (2.0.7)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
security (0.1.3)
|
||||||
|
signet (0.17.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
|
simctl (1.6.8)
|
||||||
|
CFPropertyList
|
||||||
|
naturally
|
||||||
|
terminal-notifier (2.0.0)
|
||||||
|
terminal-table (1.8.0)
|
||||||
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
|
tty-cursor (0.7.1)
|
||||||
|
tty-screen (0.8.1)
|
||||||
|
tty-spinner (0.9.3)
|
||||||
|
tty-cursor (~> 0.7)
|
||||||
|
uber (0.1.0)
|
||||||
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.7.0)
|
||||||
|
word_wrap (1.0.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
xcpretty (0.3.0)
|
||||||
|
rouge (~> 2.0.7)
|
||||||
|
xcpretty-travis-formatter (1.0.1)
|
||||||
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
arm64-darwin-21
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
fastlane
|
||||||
|
fastlane-plugin-match_keystore
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.3.11
|
|
@ -25,50 +25,52 @@ apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
android {
|
||||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
namespace "net.defined.mobile_nebula"
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
compileSdkVersion 33
|
||||||
|
ndkVersion flutter.ndkVersion
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility JavaVersion.VERSION_1_8
|
||||||
|
targetCompatibility JavaVersion.VERSION_1_8
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
kotlinOptions {
|
||||||
compileSdkVersion 28
|
jvmTarget = '1.8'
|
||||||
|
}
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
main.java.srcDirs += 'src/main/kotlin'
|
main.java.srcDirs += 'src/main/kotlin'
|
||||||
}
|
}
|
||||||
|
|
||||||
lintOptions {
|
|
||||||
disable 'InvalidPackage'
|
|
||||||
}
|
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "net.defined.mobile_nebula"
|
applicationId "net.defined.mobile_nebula"
|
||||||
minSdkVersion 25
|
minSdkVersion 26 //flutter.minSdkVersion
|
||||||
targetSdkVersion 28
|
targetSdkVersion 33 //flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
keyAlias keystoreProperties['keyAlias']
|
keyAlias 'key'
|
||||||
keyPassword keystoreProperties['password']
|
storeFile System.getenv('GOOGLE_PLAY_KEYSTORE_PATH') ? file(System.getenv('GOOGLE_PLAY_KEYSTORE_PATH')) : null
|
||||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
keyPassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD')
|
||||||
storePassword keystoreProperties['password']
|
storePassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
|
|
||||||
// We are disabling minification and proguard because it wrecks the crypto for storing keys
|
|
||||||
// Ideally we would turn these on. We had issues with gson as well but resolved those with proguardFiles
|
|
||||||
minifyEnabled false
|
|
||||||
useProguard false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
resValue 'string', 'app_name', '"Nebula"'
|
||||||
|
}
|
||||||
|
|
||||||
|
debug {
|
||||||
|
resValue 'string', 'app_name', '"Nebula-DEBUG"'
|
||||||
|
applicationIdSuffix '.debug'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -77,26 +79,13 @@ flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
|
||||||
flatDir {
|
|
||||||
dirs 'src/main/libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
|
||||||
implementation "androidx.security:security-crypto:1.0.0-rc02"
|
implementation "androidx.security:security-crypto:1.0.0"
|
||||||
implementation 'com.google.code.gson:gson:2.8.6'
|
implementation "androidx.work:work-runtime-ktx:$workVersion"
|
||||||
|
implementation 'com.google.code.gson:gson:2.8.9'
|
||||||
|
implementation "com.google.guava:guava:31.0.1-android"
|
||||||
|
implementation project(':mobileNebula')
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.12'
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.1.1'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
|
||||||
implementation (name:'mobileNebula', ext:'aar') {
|
|
||||||
exec {
|
|
||||||
workingDir '../../'
|
|
||||||
environment("ANDROID_NDK_HOME", android.ndkDirectory)
|
|
||||||
environment("ANDROID_HOME", android.sdkDirectory)
|
|
||||||
commandLine './gen-artifacts.sh', 'android'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="net.defined.mobile_nebula">
|
|
||||||
<!-- Flutter needs it to communicate with the running application
|
<!-- Flutter needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#f2c10d</color>
|
||||||
|
</resources>
|
|
@ -1,5 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="net.defined.mobile_nebula">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||||
calls FlutterMain.startInitialization(this); in its onCreate method.
|
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||||
In most cases you can leave this as-is, but you if you want to provide
|
In most cases you can leave this as-is, but you if you want to provide
|
||||||
|
@ -7,20 +7,31 @@
|
||||||
FlutterApplication and put your custom class here. -->
|
FlutterApplication and put your custom class here. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false"
|
||||||
|
tools:replace="required" />
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="mailto" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
<application
|
<application
|
||||||
android:name="io.flutter.app.FlutterApplication"
|
android:name="MyApplication"
|
||||||
android:label="Nebula"
|
android:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<service android:name=".NebulaVpnService"
|
<service android:name=".NebulaVpnService"
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
|
android:exported="false"
|
||||||
android:process=":nebulaVpnBg">
|
android:process=":nebulaVpnBg">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.net.VpnService"/>
|
<action android:name="android.net.VpnService"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||||
|
android:value="false"/>
|
||||||
</service>
|
</service>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
@ -30,8 +41,16 @@
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- App linking -->
|
||||||
|
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="http" android:host="api.defined.net" android:pathPrefix="/v1/mobile-enrollment"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<receiver android:name=".ShareReceiver" android:exported="false"/>
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
@ -41,6 +60,18 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths"/>
|
android:resource="@xml/provider_paths"/>
|
||||||
</provider>
|
</provider>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<!-- If you are using androidx.startup to initialize other components -->
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup"
|
||||||
|
tools:node="remove" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
|
||||||
|
class InvalidCredentialsException: Exception("Invalid credentials")
|
||||||
|
|
||||||
|
class APIClient(context: Context) {
|
||||||
|
private val packageInfo = PackageInfo(context)
|
||||||
|
private val client = mobileNebula.MobileNebula.newAPIClient(
|
||||||
|
"MobileNebula/%s (Android %s)".format(
|
||||||
|
packageInfo.getVersion(),
|
||||||
|
packageInfo.getSystemVersion(),
|
||||||
|
))
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
fun enroll(code: String): IncomingSite {
|
||||||
|
val res = client.enroll(code)
|
||||||
|
return decodeIncomingSite(res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Long, trustedKeys: String): IncomingSite? {
|
||||||
|
val res: mobileNebula.TryUpdateResult
|
||||||
|
try {
|
||||||
|
res = client.tryUpdate(siteName, hostID, privateKey, counter, trustedKeys)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// type information from Go is not available, use string matching instead
|
||||||
|
if (e.message == "invalid credentials") {
|
||||||
|
throw InvalidCredentialsException()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.fetchedUpdate) {
|
||||||
|
return decodeIncomingSite(res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeIncomingSite(jsonSite: String): IncomingSite {
|
||||||
|
return gson.fromJson(jsonSite, IncomingSite::class.java)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
|
||||||
|
class DNUpdateWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DNUpdateWorker"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context = applicationContext
|
||||||
|
private val apiClient: APIClient = APIClient(ctx)
|
||||||
|
private val updater = DNSiteUpdater(context, apiClient)
|
||||||
|
private val sites = SiteList(context)
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
var failed = false
|
||||||
|
|
||||||
|
sites.getSites().values.forEach { site ->
|
||||||
|
try {
|
||||||
|
updateSite(site)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failed = true
|
||||||
|
Log.e(TAG, "Error while updating site ${site.id}: ${e.stackTraceToString()}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (failed) Result.failure() else Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSite(site: Site) {
|
||||||
|
try {
|
||||||
|
DNUpdateLock(site).use {
|
||||||
|
val res = updater.updateSite(site)
|
||||||
|
|
||||||
|
// Reload Nebula if this is the currently active site
|
||||||
|
if (res == DNSiteUpdater.Result.CONFIG_UPDATED) {
|
||||||
|
Intent().also { intent ->
|
||||||
|
intent.action = NebulaVpnService.ACTION_RELOAD
|
||||||
|
intent.putExtra("id", site.id)
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the UI on any change
|
||||||
|
if (res != DNSiteUpdater.Result.NOOP) {
|
||||||
|
Intent().also { intent ->
|
||||||
|
intent.action = MainActivity.ACTION_REFRESH_SITES
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: java.nio.channels.OverlappingFileLockException) {
|
||||||
|
Log.w(TAG, "Can't lock site ${site.name}, skipping it...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DNUpdateLock(site: Site): Closeable {
|
||||||
|
private val fileChannel = FileChannel.open(
|
||||||
|
Paths.get(site.path+"/update.lock"),
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.WRITE,
|
||||||
|
)
|
||||||
|
private val fileLock = fileChannel.tryLock()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
fileLock.close()
|
||||||
|
fileChannel.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DNSiteUpdater(
|
||||||
|
private val context: Context,
|
||||||
|
private val apiClient: APIClient,
|
||||||
|
) {
|
||||||
|
enum class Result {
|
||||||
|
CONFIG_UPDATED, CREDENTIALS_UPDATED, NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSite(site: Site): Result {
|
||||||
|
if (!site.managed) {
|
||||||
|
return Result.NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
val credentials = site.getDNCredentials(context)
|
||||||
|
|
||||||
|
val newSite: IncomingSite?
|
||||||
|
try {
|
||||||
|
newSite = apiClient.tryUpdate(
|
||||||
|
site.name,
|
||||||
|
credentials.hostID,
|
||||||
|
credentials.privateKey,
|
||||||
|
credentials.counter.toLong(),
|
||||||
|
credentials.trustedKeys,
|
||||||
|
)
|
||||||
|
} catch (e: InvalidCredentialsException) {
|
||||||
|
if (!credentials.invalid) {
|
||||||
|
site.invalidateDNCredentials(context)
|
||||||
|
Log.d(TAG, "Invalidated credentials in site ${site.name}")
|
||||||
|
return Result.CREDENTIALS_UPDATED
|
||||||
|
}
|
||||||
|
return Result.NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSite != null) {
|
||||||
|
newSite.save(context)
|
||||||
|
Log.d(TAG, "Updated site ${site.id}: ${site.name}")
|
||||||
|
return Result.CONFIG_UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.invalid) {
|
||||||
|
site.validateDNCredentials(context)
|
||||||
|
Log.d(TAG, "Revalidated credentials in site ${site.id}: ${site.name}")
|
||||||
|
return Result.CREDENTIALS_UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.NOOP
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,57 @@
|
||||||
package net.defined.mobile_nebula
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedFile
|
import androidx.security.crypto.EncryptedFile
|
||||||
import androidx.security.crypto.MasterKeys
|
import androidx.security.crypto.MasterKeys
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
class EncFile(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
// Borrowed from androidx.security.crypto.MasterKeys
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
// Borrowed from androidx.security.crypto.EncryptedFile
|
||||||
|
private const val KEYSET_PREF_NAME = "__androidx_security_crypto_encrypted_file_pref__"
|
||||||
|
}
|
||||||
|
|
||||||
class EncFile(var context: Context) {
|
|
||||||
private val scheme = EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
|
private val scheme = EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
|
||||||
private val master: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
private val spec = MasterKeys.AES256_GCM_SPEC
|
||||||
|
private var master: String = MasterKeys.getOrCreate(spec)
|
||||||
|
|
||||||
fun openRead(file: File): BufferedReader {
|
fun openRead(file: File): BufferedReader {
|
||||||
val eFile = EncryptedFile.Builder(file, context, master, scheme).build()
|
// We may fail to decrypt the file, in which case we'll raise an exception.
|
||||||
return eFile.openFileInput().bufferedReader()
|
// Callers should handle this exception by deleting the invalid file.
|
||||||
|
return build(file).openFileInput().bufferedReader()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openWrite(file: File): BufferedWriter {
|
fun openWrite(file: File): BufferedWriter {
|
||||||
val eFile = EncryptedFile.Builder(file, context, master, scheme).build()
|
return try {
|
||||||
return eFile.openFileOutput().bufferedWriter()
|
build(file).openFileOutput().bufferedWriter()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If we fail to open the file, it's likely because the master key no longer works.
|
||||||
|
// We'll try to reset the master key and try again.
|
||||||
|
resetMasterKey()
|
||||||
|
|
||||||
|
build(file).openFileOutput().bufferedWriter()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun build(file: File): EncryptedFile {
|
||||||
|
return EncryptedFile.Builder(file, context, master, scheme).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetMasterKey() {
|
||||||
|
// Reset the master key
|
||||||
|
KeyStore.getInstance(ANDROID_KEYSTORE).apply {
|
||||||
|
load(null)
|
||||||
|
deleteEntry(master)
|
||||||
|
}
|
||||||
|
// And reset the shared preference containing the file encryption key
|
||||||
|
context.deleteSharedPreferences(KEYSET_PREF_NAME)
|
||||||
|
|
||||||
|
// Re-create the master key now so future calls don't fail
|
||||||
|
master = MasterKeys.getOrCreate(spec)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,57 +1,76 @@
|
||||||
package net.defined.mobile_nebula
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import androidx.annotation.NonNull
|
import android.util.Log
|
||||||
|
import androidx.work.*
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
import io.flutter.plugins.GeneratedPluginRegistrant
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
const val TAG = "nebula"
|
const val TAG = "nebula"
|
||||||
const val VPN_PERMISSIONS_CODE = 0x0F
|
|
||||||
const val VPN_START_CODE = 0x10
|
const val VPN_START_CODE = 0x10
|
||||||
const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService"
|
const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService"
|
||||||
|
const val UPDATE_WORKER = "dnUpdater"
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private var sites: Sites? = null
|
private var ui: MethodChannel? = null
|
||||||
private var permResult: MethodChannel.Result? = null
|
|
||||||
|
|
||||||
private var inMessenger: Messenger? = Messenger(IncomingHandler())
|
private var inMessenger: Messenger? = Messenger(IncomingHandler())
|
||||||
private var outMessenger: Messenger? = null
|
private var outMessenger: Messenger? = null
|
||||||
|
|
||||||
|
private var apiClient: APIClient? = null
|
||||||
|
private var sites: Sites? = null
|
||||||
|
|
||||||
|
// When starting a site we may need to request VPN permissions. These variables help us
|
||||||
|
// maintain state while waiting for a permission result.
|
||||||
|
private var startResult: MethodChannel.Result? = null
|
||||||
|
private var startingSiteContainer: SiteContainer? = null
|
||||||
|
|
||||||
private var activeSiteId: String? = null
|
private var activeSiteId: String? = null
|
||||||
|
|
||||||
|
private val workManager = WorkManager.getInstance(application)
|
||||||
|
private val refreshReceiver: BroadcastReceiver = RefreshReceiver()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val ACTION_REFRESH_SITES = "net.defined.mobileNebula.REFRESH_SITES"
|
||||||
|
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
fun getContext(): Context? { return appContext }
|
fun getContext(): Context? { return appContext }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
appContext = context
|
appContext = context
|
||||||
//TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this
|
//TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this
|
||||||
sites = Sites(flutterEngine)
|
sites = Sites(flutterEngine)
|
||||||
|
|
||||||
// Bind against our service to detect which site is running on app boot
|
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
||||||
val intent = Intent(this, NebulaVpnService::class.java)
|
|
||||||
bindService(intent, connection, 0)
|
|
||||||
|
|
||||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
ui = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
ui!!.setMethodCallHandler { call, result ->
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
|
||||||
when(call.method) {
|
when(call.method) {
|
||||||
"android.requestPermissions" -> androidPermissions(result)
|
"android.registerActiveSite" -> registerActiveSite(result)
|
||||||
|
"android.deviceHasCamera" -> deviceHasCamera(result)
|
||||||
|
|
||||||
"nebula.parseCerts" -> nebulaParseCerts(call, result)
|
"nebula.parseCerts" -> nebulaParseCerts(call, result)
|
||||||
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
|
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
|
||||||
"nebula.renderConfig" -> nebulaRenderConfig(call, result)
|
"nebula.renderConfig" -> nebulaRenderConfig(call, result)
|
||||||
|
"nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result)
|
||||||
|
|
||||||
|
"dn.enroll" -> dnEnroll(call, result)
|
||||||
|
|
||||||
"listSites" -> listSites(result)
|
"listSites" -> listSites(result)
|
||||||
"deleteSite" -> deleteSite(call, result)
|
"deleteSite" -> deleteSite(call, result)
|
||||||
|
@ -65,14 +84,52 @@ class MainActivity: FlutterActivity() {
|
||||||
"active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result)
|
"active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result)
|
||||||
"active.closeTunnel" -> activeCloseTunnel(call, result)
|
"active.closeTunnel" -> activeCloseTunnel(call, result)
|
||||||
|
|
||||||
"share" -> Share.share(call, result)
|
"debug.clearKeys" -> {
|
||||||
"shareFile" -> Share.shareFile(call, result)
|
EncFile(context).resetMasterKey()
|
||||||
|
}
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
apiClient = APIClient(context)
|
||||||
|
|
||||||
|
registerReceiver(refreshReceiver, IntentFilter(ACTION_REFRESH_SITES))
|
||||||
|
|
||||||
|
enqueueDNUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
|
||||||
|
unregisterReceiver(refreshReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enqueueDNUpdater() {
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<DNUpdateWorker>(15, TimeUnit.MINUTES).build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
UPDATE_WORKER,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
workRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is called by the UI _after_ it has finished rendering the site list to avoid a race condition with detecting
|
||||||
|
// the current active site and attaching site specific event channels in the event the UI app was quit
|
||||||
|
private fun registerActiveSite(result: MethodChannel.Result) {
|
||||||
|
// Bind against our service to detect which site is running on app boot
|
||||||
|
val intent = Intent(this, NebulaVpnService::class.java)
|
||||||
|
bindService(intent, connection, 0)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceHasCamera(result: MethodChannel.Result) {
|
||||||
|
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
|
||||||
|
}
|
||||||
|
|
||||||
private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) {
|
private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val certs = call.argument<String>("certs")
|
val certs = call.argument<String>("certs")
|
||||||
if (certs == "") {
|
if (certs == "") {
|
||||||
|
@ -98,6 +155,47 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(yaml)
|
return result.success(yaml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun nebulaVerifyCertAndKey(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val cert = call.argument<String>("cert")
|
||||||
|
if (cert == "") {
|
||||||
|
return result.error("required_argument", "cert is a required argument", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val key = call.argument<String>("key")
|
||||||
|
if (key == "") {
|
||||||
|
return result.error("required_argument", "key is a required argument", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val json = mobileNebula.MobileNebula.verifyCertAndKey(cert, key)
|
||||||
|
result.success(json)
|
||||||
|
} catch (err: Exception) {
|
||||||
|
result.error("unhandled_error", err.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dnEnroll(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val code = call.arguments as String
|
||||||
|
if (code == "") {
|
||||||
|
return result.error("required_argument", "code is a required argument", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val site: IncomingSite
|
||||||
|
val siteDir: File
|
||||||
|
try {
|
||||||
|
site = apiClient!!.enroll(code)
|
||||||
|
siteDir = site.save(context)
|
||||||
|
} catch (err: Exception) {
|
||||||
|
return result.error("unhandled_error", err.message, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateOrDeleteSite(siteDir)) {
|
||||||
|
return result.error("failure", "Enrollment failed due to invalid config", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun listSites(result: MethodChannel.Result) {
|
private fun listSites(result: MethodChannel.Result) {
|
||||||
sites!!.refreshSites(activeSiteId)
|
sites!!.refreshSites(activeSiteId)
|
||||||
val sites = sites!!.getSites()
|
val sites = sites!!.getSites()
|
||||||
|
@ -117,68 +215,66 @@ class MainActivity: FlutterActivity() {
|
||||||
|
|
||||||
private fun saveSite(call: MethodCall, result: MethodChannel.Result) {
|
private fun saveSite(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val site: IncomingSite
|
val site: IncomingSite
|
||||||
|
val siteDir: File
|
||||||
try {
|
try {
|
||||||
val gson = Gson()
|
val gson = Gson()
|
||||||
site = gson.fromJson(call.arguments as String, IncomingSite::class.java)
|
site = gson.fromJson(call.arguments as String, IncomingSite::class.java)
|
||||||
site.save(context)
|
siteDir = site.save(context)
|
||||||
|
|
||||||
} catch (err: Exception) {
|
} catch (err: Exception) {
|
||||||
//TODO: is toString the best or .message?
|
//TODO: is toString the best or .message?
|
||||||
return result.error("failure", err.toString(), null)
|
return result.error("failure", err.toString(), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val siteDir = context.filesDir.resolve("sites").resolve(site.id)
|
if (!validateOrDeleteSite(siteDir)) {
|
||||||
try {
|
|
||||||
// Try to render a full site, if this fails the config was bad somehow
|
|
||||||
Site(siteDir)
|
|
||||||
} catch (err: Exception) {
|
|
||||||
siteDir.deleteRecursively()
|
|
||||||
return result.error("failure", "Site config was incomplete, please review and try again", null)
|
return result.error("failure", "Site config was incomplete, please review and try again", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sites?.refreshSites()
|
||||||
|
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateOrDeleteSite(siteDir: File): Boolean {
|
||||||
|
try {
|
||||||
|
// Try to render a full site, if this fails the config was bad somehow
|
||||||
|
Site(context, siteDir)
|
||||||
|
} catch(err: java.io.FileNotFoundException) {
|
||||||
|
Log.e(TAG, "Site not found at $siteDir")
|
||||||
|
return false
|
||||||
|
} catch(err: Exception) {
|
||||||
|
Log.e(TAG, "Deleting site at $siteDir due to error: $err")
|
||||||
|
siteDir.deleteRecursively()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun startSite(call: MethodCall, result: MethodChannel.Result) {
|
private fun startSite(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val id = call.argument<String>("id")
|
val id = call.argument<String>("id")
|
||||||
if (id == "") {
|
if (id == "") {
|
||||||
return result.error("required_argument", "id is a required argument", null)
|
return result.error("required_argument", "id is a required argument", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var siteContainer: SiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null)
|
startingSiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null)
|
||||||
|
startingSiteContainer!!.updater.setState(true, "Initializing...")
|
||||||
siteContainer.site.connected = true
|
|
||||||
siteContainer.site.status = "Initializing..."
|
|
||||||
|
|
||||||
|
startResult = result
|
||||||
val intent = VpnService.prepare(this)
|
val intent = VpnService.prepare(this)
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
//TODO: ensure this boots the correct bit, I bet it doesn't and we need to go back to the active symlink
|
|
||||||
intent.putExtra("path", siteContainer.site.path)
|
|
||||||
intent.putExtra("id", siteContainer.site.id)
|
|
||||||
startActivityForResult(intent, VPN_START_CODE)
|
startActivityForResult(intent, VPN_START_CODE)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(this, NebulaVpnService::class.java)
|
onActivityResult(VPN_START_CODE, Activity.RESULT_OK, null)
|
||||||
intent.putExtra("path", siteContainer.site.path)
|
|
||||||
intent.putExtra("id", siteContainer.site.id)
|
|
||||||
onActivityResult(VPN_START_CODE, Activity.RESULT_OK, intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.success(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopSite() {
|
private fun stopSite() {
|
||||||
val intent = Intent(this, NebulaVpnService::class.java)
|
val intent = Intent(this, NebulaVpnService::class.java).apply {
|
||||||
intent.putExtra("COMMAND", "STOP")
|
action = NebulaVpnService.ACTION_STOP
|
||||||
|
}
|
||||||
|
|
||||||
//This is odd but stopService goes nowhere in my tests and this is correct
|
// We can't stopService because we have to close the fd first. The service will call stopSelf when ready.
|
||||||
// according to the official example https://android.googlesource.com/platform/development/+/master/samples/ToyVpn/src/com/example/android/toyvpn/ToyVpnClient.java#116
|
// See the official example: https://android.googlesource.com/platform/development/+/master/samples/ToyVpn/src/com/example/android/toyvpn/ToyVpnClient.java#116
|
||||||
startService(intent)
|
startService(intent)
|
||||||
//TODO: why doesn't this work!?!?
|
|
||||||
// if (serviceIntent != null) {
|
|
||||||
// Log.e(TAG, "stopping ${serviceIntent.toString()}")
|
|
||||||
// stopService(serviceIntent)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeListHostmap(call: MethodCall, result: MethodChannel.Result) {
|
private fun activeListHostmap(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -191,9 +287,9 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_LIST_HOSTMAP
|
msg.what = NebulaVpnService.MSG_LIST_HOSTMAP
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -211,9 +307,9 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP
|
msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -238,11 +334,11 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_GET_HOSTINFO
|
msg.what = NebulaVpnService.MSG_GET_HOSTINFO
|
||||||
msg.data.putString("vpnIp", vpnIp)
|
msg.data.putString("vpnIp", vpnIp)
|
||||||
msg.data.putBoolean("pending", pending)
|
msg.data.putBoolean("pending", pending)
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -262,7 +358,7 @@ class MainActivity: FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val addr = call.argument<String>("addr")
|
val addr = call.argument<String>("addr")
|
||||||
if (vpnIp == "") {
|
if (addr == "") {
|
||||||
return result.error("required_argument", "addr is a required argument", null)
|
return result.error("required_argument", "addr is a required argument", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -270,11 +366,11 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_SET_REMOTE_FOR_TUNNEL
|
msg.what = NebulaVpnService.MSG_SET_REMOTE_FOR_TUNNEL
|
||||||
msg.data.putString("vpnIp", vpnIp)
|
msg.data.putString("vpnIp", vpnIp)
|
||||||
msg.data.putString("addr", addr)
|
msg.data.putString("addr", addr)
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -297,10 +393,10 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_CLOSE_TUNNEL
|
msg.what = NebulaVpnService.MSG_CLOSE_TUNNEL
|
||||||
msg.data.putString("vpnIp", vpnIp)
|
msg.data.putString("vpnIp", vpnIp)
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getBoolean("data"))
|
result.success(msg.data.getBoolean("data"))
|
||||||
}
|
}
|
||||||
|
@ -308,52 +404,48 @@ class MainActivity: FlutterActivity() {
|
||||||
outMessenger?.send(msg)
|
outMessenger?.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun androidPermissions(result: MethodChannel.Result) {
|
|
||||||
val intent = VpnService.prepare(this)
|
|
||||||
if (intent != null) {
|
|
||||||
permResult = result
|
|
||||||
return startActivityForResult(intent, VPN_PERMISSIONS_CODE)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We already have the permission
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
// This is where activity results come back to us (startActivityForResult)
|
// This is where activity results come back to us (startActivityForResult)
|
||||||
if (requestCode == VPN_PERMISSIONS_CODE && permResult != null) {
|
if (requestCode == VPN_START_CODE) {
|
||||||
// We are processing a response for vpn permissions and the UI is waiting for feedback
|
// If we are processing a result for VPN permissions and don't get them, let the UI know
|
||||||
//TODO: unlikely we ever register multiple attempts but this could be a trouble spot if we did
|
val result = startResult!!
|
||||||
val result = permResult!!
|
val siteContainer = startingSiteContainer!!
|
||||||
permResult = null
|
startResult = null
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
startingSiteContainer = null
|
||||||
return result.success(null)
|
if (resultCode != Activity.RESULT_OK) {
|
||||||
|
// The user did not grant permissions
|
||||||
|
siteContainer.updater.setState(false, "Disconnected")
|
||||||
|
return result.error("permissions", "Please grant VPN permissions to the app when requested. (If another VPN is running, please disable it now.)", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.error("denied", "User did not grant permission", null)
|
// Start the VPN service
|
||||||
|
val intent = Intent(this, NebulaVpnService::class.java).apply {
|
||||||
} else if (requestCode == VPN_START_CODE) {
|
putExtra("path", siteContainer.site.path)
|
||||||
// We are processing a response for permissions while starting the VPN (or reusing code in the event we already have perms)
|
putExtra("id", siteContainer.site.id)
|
||||||
startService(data)
|
}
|
||||||
|
startService(intent)
|
||||||
if (outMessenger == null) {
|
if (outMessenger == null) {
|
||||||
bindService(data, connection, 0)
|
bindService(intent, connection, 0)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file picker needs us to super
|
// The file picker needs us to super
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Defines callbacks for service binding, passed to bindService() */
|
/** Defines callbacks for service binding, passed to bindService() */
|
||||||
val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||||
outMessenger = Messenger(service)
|
outMessenger = Messenger(service)
|
||||||
|
|
||||||
// We want to monitor the service for as long as we are connected to it.
|
// We want to monitor the service for as long as we are connected to it.
|
||||||
try {
|
try {
|
||||||
val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT)
|
val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT)
|
||||||
msg.replyTo = inMessenger
|
msg.replyTo = inMessenger
|
||||||
outMessenger?.send(msg)
|
outMessenger!!.send(msg)
|
||||||
|
|
||||||
} catch (e: RemoteException) {
|
} catch (e: RemoteException) {
|
||||||
// In this case the service has crashed before we could even
|
// In this case the service has crashed before we could even
|
||||||
|
@ -364,7 +456,7 @@ class MainActivity: FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING)
|
val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING)
|
||||||
outMessenger?.send(msg)
|
outMessenger!!.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||||
|
@ -377,12 +469,12 @@ class MainActivity: FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle and route messages coming from the vpn service
|
// Handle and route messages coming from the vpn service
|
||||||
inner class IncomingHandler: Handler() {
|
inner class IncomingHandler: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
val id = msg.data.getString("id")
|
val id = msg.data.getString("id")
|
||||||
|
|
||||||
//TODO: If the elvis hits then we had a deleted site running, which shouldn't happen
|
//TODO: If the elvis hits then we had a deleted site running, which shouldn't happen
|
||||||
val site = sites!!.getSite(id) ?: return
|
val site = sites!!.getSite(id!!) ?: return
|
||||||
|
|
||||||
when (msg.what) {
|
when (msg.what) {
|
||||||
NebulaVpnService.MSG_IS_RUNNING -> isRunning(site, msg)
|
NebulaVpnService.MSG_IS_RUNNING -> isRunning(site, msg)
|
||||||
|
@ -407,6 +499,32 @@ class MainActivity: FlutterActivity() {
|
||||||
private fun serviceExited(site: SiteContainer, msg: Message) {
|
private fun serviceExited(site: SiteContainer, msg: Message) {
|
||||||
activeSiteId = null
|
activeSiteId = null
|
||||||
site.updater.setState(false, "Disconnected", msg.data.getString("error"))
|
site.updater.setState(false, "Disconnected", msg.data.getString("error"))
|
||||||
|
unbindVpnService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun unbindVpnService() {
|
||||||
|
if (outMessenger != null) {
|
||||||
|
// Unregister ourselves
|
||||||
|
val msg = Message.obtain(null, NebulaVpnService.MSG_UNREGISTER_CLIENT)
|
||||||
|
msg.replyTo = inMessenger
|
||||||
|
outMessenger!!.send(msg)
|
||||||
|
// Unbind
|
||||||
|
unbindService(connection)
|
||||||
|
}
|
||||||
|
outMessenger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class RefreshReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != ACTION_REFRESH_SITES) return
|
||||||
|
if (sites == null) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Refreshing sites in MainActivity")
|
||||||
|
|
||||||
|
sites?.refreshSites(activeSiteId)
|
||||||
|
ui?.invokeMethod("refreshSites", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
|
||||||
|
class MyApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// In order to use the WorkManager from the nebulaVpnBg process (i.e. NebulaVpnService)
|
||||||
|
// we must explicitly initialize this rather than using the default initializer.
|
||||||
|
val myConfig = Configuration.Builder().build()
|
||||||
|
WorkManager.initialize(this, myConfig)
|
||||||
|
|
||||||
|
FlutterLoader().startInitialization(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,19 +1,28 @@
|
||||||
package net.defined.mobile_nebula
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.ConnectivityManager
|
import android.content.IntentFilter
|
||||||
import android.net.VpnService
|
import android.content.pm.PackageManager
|
||||||
|
import android.net.*
|
||||||
import android.os.*
|
import android.os.*
|
||||||
|
import android.system.OsConstants
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import androidx.work.*
|
||||||
import mobileNebula.CIDR
|
import mobileNebula.CIDR
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
|
||||||
class NebulaVpnService : VpnService() {
|
class NebulaVpnService : VpnService() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NebulaVpnService"
|
const val TAG = "NebulaVpnService"
|
||||||
|
|
||||||
|
const val ACTION_STOP = "net.defined.mobile_nebula.STOP"
|
||||||
|
const val ACTION_RELOAD = "net.defined.mobile_nebula.RELOAD"
|
||||||
|
|
||||||
const val MSG_REGISTER_CLIENT = 1
|
const val MSG_REGISTER_CLIENT = 1
|
||||||
const val MSG_UNREGISTER_CLIENT = 2
|
const val MSG_UNREGISTER_CLIENT = 2
|
||||||
const val MSG_IS_RUNNING = 3
|
const val MSG_IS_RUNNING = 3
|
||||||
|
@ -31,30 +40,45 @@ class NebulaVpnService : VpnService() {
|
||||||
private lateinit var messenger: Messenger
|
private lateinit var messenger: Messenger
|
||||||
private val mClients = ArrayList<Messenger>()
|
private val mClients = ArrayList<Messenger>()
|
||||||
|
|
||||||
|
private val reloadReceiver: BroadcastReceiver = ReloadReceiver()
|
||||||
|
private var workManager: WorkManager? = null
|
||||||
|
|
||||||
|
private var path: String? = null
|
||||||
private var running: Boolean = false
|
private var running: Boolean = false
|
||||||
private var site: Site? = null
|
private var site: Site? = null
|
||||||
private var nebula: mobileNebula.Nebula? = null
|
private var nebula: mobileNebula.Nebula? = null
|
||||||
private var vpnInterface: ParcelFileDescriptor? = null
|
private var vpnInterface: ParcelFileDescriptor? = null
|
||||||
|
private var didSleep = false
|
||||||
|
private var networkCallback: NetworkCallback = NetworkCallback()
|
||||||
|
|
||||||
|
override fun onCreate() {
|
||||||
|
workManager = WorkManager.getInstance(this)
|
||||||
|
super.onCreate()
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: bindService seems to be how to do IPC
|
|
||||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
if (intent?.getStringExtra("COMMAND") == "STOP") {
|
if (intent?.action == ACTION_STOP) {
|
||||||
stopVpn()
|
stopVpn()
|
||||||
return Service.START_NOT_STICKY
|
return Service.START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
val path = intent?.getStringExtra("path")
|
|
||||||
val id = intent?.getStringExtra("id")
|
val id = intent?.getStringExtra("id")
|
||||||
|
|
||||||
if (running) {
|
if (running) {
|
||||||
|
// if the UI triggers this twice, check if we are already running the requested site. if not, return an error.
|
||||||
|
// otherwise, just ignore the request since we handled it the first time.
|
||||||
|
if (site!!.id != id) {
|
||||||
announceExit(id, "Trying to run nebula but it is already running")
|
announceExit(id, "Trying to run nebula but it is already running")
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: can we signal failure?
|
//TODO: can we signal failure?
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path = intent!!.getStringExtra("path")!!
|
||||||
//TODO: if we fail to start, android will attempt a restart lacking all the intent data we need.
|
//TODO: if we fail to start, android will attempt a restart lacking all the intent data we need.
|
||||||
// Link active site config in Main to avoid this
|
// Link active site config in Main to avoid this
|
||||||
site = Site(File(path))
|
site = Site(this, File(path!!))
|
||||||
|
|
||||||
if (site!!.cert == null) {
|
if (site!!.cert == null) {
|
||||||
announceExit(id, "Site is missing a certificate")
|
announceExit(id, "Site is missing a certificate")
|
||||||
|
@ -62,13 +86,17 @@ class NebulaVpnService : VpnService() {
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Kick off a site update
|
||||||
|
val workRequest = OneTimeWorkRequestBuilder<DNUpdateWorker>().build()
|
||||||
|
workManager!!.enqueue(workRequest)
|
||||||
|
|
||||||
// We don't actually start here. In order to properly capture boot errors we wait until an IPC connection is made
|
// We don't actually start here. In order to properly capture boot errors we wait until an IPC connection is made
|
||||||
|
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startVpn() {
|
private fun startVpn() {
|
||||||
var ipNet: CIDR
|
val ipNet: CIDR
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0])
|
ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0])
|
||||||
|
@ -81,21 +109,36 @@ class NebulaVpnService : VpnService() {
|
||||||
.addRoute(ipNet.network, ipNet.maskSize.toInt())
|
.addRoute(ipNet.network, ipNet.maskSize.toInt())
|
||||||
.setMtu(site!!.mtu)
|
.setMtu(site!!.mtu)
|
||||||
.setSession(TAG)
|
.setSession(TAG)
|
||||||
|
.allowFamily(OsConstants.AF_INET)
|
||||||
|
.allowFamily(OsConstants.AF_INET6)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
builder.setMetered(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow some common, known-problematic apps
|
||||||
|
// TODO Make this user configurable
|
||||||
|
// Android Auto Wireless (https://github.com/DefinedNet/mobile_nebula/issues/102)
|
||||||
|
disallowApp(builder, "com.google.android.projection.gearhead")
|
||||||
|
// Chromecast (https://github.com/DefinedNet/mobile_nebula/issues/102)
|
||||||
|
disallowApp(builder, "com.google.android.apps.chromecast.app")
|
||||||
|
// RCS / Jibe
|
||||||
|
disallowApp(builder, "com.google.android.apps.messaging")
|
||||||
|
|
||||||
// Add our unsafe routes
|
// Add our unsafe routes
|
||||||
site!!.unsafeRoutes.forEach { unsafeRoute ->
|
site!!.unsafeRoutes.forEach { unsafeRoute ->
|
||||||
val ipNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route)
|
val unsafeIPNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route)
|
||||||
builder.addRoute(ipNet.network, ipNet.maskSize.toInt())
|
builder.addRoute(unsafeIPNet.network, unsafeIPNet.maskSize.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
// Add our DNS resolvers
|
||||||
cm.allNetworks.forEach { network ->
|
site!!.dnsResolvers.forEach { dnsResolver ->
|
||||||
cm.getLinkProperties(network).dnsServers.forEach { builder.addDnsServer(it) }
|
builder.addDnsServer(dnsResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vpnInterface = builder.establish()
|
vpnInterface = builder.establish()
|
||||||
nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.fd.toLong())
|
nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.detachFd().toLong())
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Got an error $e")
|
Log.e(TAG, "Got an error $e")
|
||||||
|
@ -104,16 +147,104 @@ class NebulaVpnService : VpnService() {
|
||||||
return stopSelf()
|
return stopSelf()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerNetworkCallback()
|
||||||
|
registerReloadReceiver()
|
||||||
|
//TODO: There is an open discussion around sleep killing tunnels or just changing mobile to tear down stale tunnels
|
||||||
|
//registerSleep()
|
||||||
|
|
||||||
nebula!!.start()
|
nebula!!.start()
|
||||||
running = true
|
running = true
|
||||||
sendSimple(MSG_IS_RUNNING, if (running) 1 else 0)
|
sendSimple(MSG_IS_RUNNING, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun disallowApp(builder: Builder, name: String) {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(name)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Used to detect network changes (wifi -> cell or vice versa) and rebinds the udp socket/updates LH
|
||||||
|
private fun registerNetworkCallback() {
|
||||||
|
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
|
||||||
|
val builder = NetworkRequest.Builder()
|
||||||
|
builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
|
||||||
|
connectivityManager.registerNetworkCallback(builder.build(), networkCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterNetworkCallback() {
|
||||||
|
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
||||||
|
connectivityManager.unregisterNetworkCallback(networkCallback)
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class NetworkCallback : ConnectivityManager.NetworkCallback () {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
super.onAvailable(network)
|
||||||
|
nebula!!.rebind("network change")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
super.onLost(network)
|
||||||
|
nebula!!.rebind("network change")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private fun registerSleep() {
|
||||||
|
val receiver: BroadcastReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
val pm = context.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||||
|
if (pm.isDeviceIdleMode) {
|
||||||
|
if (!didSleep) {
|
||||||
|
nebula!!.sleep()
|
||||||
|
//TODO: we may want to shut off our network change listener like we do with iOS, I haven't observed any issues with it yet though
|
||||||
|
}
|
||||||
|
didSleep = true
|
||||||
|
} else {
|
||||||
|
nebula!!.rebind("android wake")
|
||||||
|
didSleep = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerReceiver(receiver, IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerReloadReceiver() {
|
||||||
|
registerReceiver(reloadReceiver, IntentFilter(ACTION_RELOAD))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun unregisterReloadReceiver() {
|
||||||
|
unregisterReceiver(reloadReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
site = Site(this, File(path!!))
|
||||||
|
nebula?.reload(site!!.config, site!!.getKey(this))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopVpn() {
|
private fun stopVpn() {
|
||||||
|
if (nebula == null) {
|
||||||
|
return stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterNetworkCallback()
|
||||||
|
unregisterReloadReceiver()
|
||||||
nebula?.stop()
|
nebula?.stop()
|
||||||
vpnInterface?.close()
|
nebula = null
|
||||||
running = false
|
running = false
|
||||||
announceExit(site?.id, null)
|
announceExit(site?.id, null)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRevoke() {
|
||||||
|
stopVpn()
|
||||||
|
//TODO: wait for the thread to exit
|
||||||
|
super.onRevoke()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
override fun onDestroy() {
|
||||||
|
@ -131,10 +262,22 @@ class NebulaVpnService : VpnService() {
|
||||||
send(msg, id)
|
send(msg, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner class ReloadReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != ACTION_RELOAD) return
|
||||||
|
if (!running) return
|
||||||
|
if (intent.getStringExtra("id") != site!!.id) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Reloading Nebula")
|
||||||
|
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler of incoming messages from clients.
|
* Handler of incoming messages from clients.
|
||||||
*/
|
*/
|
||||||
inner class IncomingHandler(context: Context, private val applicationContext: Context = context.applicationContext) : Handler() {
|
inner class IncomingHandler : Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
//TODO: how do we limit what can talk to us?
|
//TODO: how do we limit what can talk to us?
|
||||||
//TODO: Make sure replyTo is actually a messenger
|
//TODO: Make sure replyTo is actually a messenger
|
||||||
|
@ -175,7 +318,7 @@ class NebulaVpnService : VpnService() {
|
||||||
if (protect(msg)) { return }
|
if (protect(msg)) { return }
|
||||||
|
|
||||||
val res = nebula!!.listHostmap(msg.what == MSG_LIST_PENDING_HOSTMAP)
|
val res = nebula!!.listHostmap(msg.what == MSG_LIST_PENDING_HOSTMAP)
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putString("data", res)
|
m.data.putString("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
@ -184,7 +327,7 @@ class NebulaVpnService : VpnService() {
|
||||||
if (protect(msg)) { return }
|
if (protect(msg)) { return }
|
||||||
|
|
||||||
val res = nebula!!.getHostInfoByVpnIp(msg.data.getString("vpnIp"), msg.data.getBoolean("pending"))
|
val res = nebula!!.getHostInfoByVpnIp(msg.data.getString("vpnIp"), msg.data.getBoolean("pending"))
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putString("data", res)
|
m.data.putString("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
@ -193,7 +336,7 @@ class NebulaVpnService : VpnService() {
|
||||||
if (protect(msg)) { return }
|
if (protect(msg)) { return }
|
||||||
|
|
||||||
val res = nebula!!.setRemoteForTunnel(msg.data.getString("vpnIp"), msg.data.getString("addr"))
|
val res = nebula!!.setRemoteForTunnel(msg.data.getString("vpnIp"), msg.data.getString("addr"))
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putString("data", res)
|
m.data.putString("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
@ -202,7 +345,7 @@ class NebulaVpnService : VpnService() {
|
||||||
if (protect(msg)) { return }
|
if (protect(msg)) { return }
|
||||||
|
|
||||||
val res = nebula!!.closeTunnel(msg.data.getString("vpnIp"))
|
val res = nebula!!.closeTunnel(msg.data.getString("vpnIp"))
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putBoolean("data", res)
|
m.data.putBoolean("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
@ -236,7 +379,7 @@ class NebulaVpnService : VpnService() {
|
||||||
return super.onBind(intent)
|
return super.onBind(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
messenger = Messenger(IncomingHandler(this))
|
messenger = Messenger(IncomingHandler())
|
||||||
return messenger.binder
|
return messenger.binder
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,37 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
class PackageInfo(private val context: Context) {
|
||||||
|
private val pInfo: PackageInfo =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0))
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
|
||||||
|
private val appInfo: ApplicationInfo = context.applicationInfo
|
||||||
|
|
||||||
|
fun getVersion(): String {
|
||||||
|
val version: String = pInfo.versionName
|
||||||
|
val build: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
pInfo.longVersionCode
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pInfo.versionCode.toLong()
|
||||||
|
return "%s-%d".format(version, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getName(): String {
|
||||||
|
val stringId = appInfo.labelRes
|
||||||
|
return if (stringId == 0) appInfo.nonLocalizedLabel.toString() else context.getString(stringId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSystemVersion(): String {
|
||||||
|
return Build.VERSION.RELEASE
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,134 +0,0 @@
|
||||||
package net.defined.mobile_nebula
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.*
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ResolveInfo
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Share {
|
|
||||||
companion object {
|
|
||||||
fun share(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val title = call.argument<String>("title")
|
|
||||||
val text = call.argument<String>("text")
|
|
||||||
val filename = call.argument<String>("filename")
|
|
||||||
|
|
||||||
if (filename == null || filename.isEmpty()) {
|
|
||||||
return result.error("filename was not provided", null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = MainActivity!!.getContext()!!
|
|
||||||
val cacheDir = context.cacheDir.resolve("share")
|
|
||||||
cacheDir.deleteRecursively()
|
|
||||||
cacheDir.mkdir()
|
|
||||||
val newFile = cacheDir.resolve(filename!!)
|
|
||||||
newFile.delete()
|
|
||||||
newFile.writeText(text ?: "")
|
|
||||||
pop(title, newFile, result)
|
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
Log.println(Log.ERROR, "", "Share: Error")
|
|
||||||
result.error(err.message, null, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareFile(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val title = call.argument<String>("title")
|
|
||||||
val filename = call.argument<String>("filename")
|
|
||||||
val filePath = call.argument<String>("filePath")
|
|
||||||
|
|
||||||
if (filename == null || filename.isEmpty()) {
|
|
||||||
result.error("filename was not provided", null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filePath == null || filePath.isEmpty()) {
|
|
||||||
result.error("filePath was not provided", null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val file = File(filePath)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = MainActivity!!.getContext()!!
|
|
||||||
val cacheDir = context.cacheDir.resolve("share")
|
|
||||||
cacheDir.deleteRecursively()
|
|
||||||
cacheDir.mkdir()
|
|
||||||
val newFile = cacheDir.resolve(filename!!)
|
|
||||||
newFile.delete()
|
|
||||||
file.copyTo(newFile)
|
|
||||||
|
|
||||||
pop(title, newFile, result)
|
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
Log.println(Log.ERROR, "", "Share: Error")
|
|
||||||
result.error(err.message, null, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pop(title: String?, file: File, result: MethodChannel.Result) {
|
|
||||||
if (title == null || title.isEmpty()) {
|
|
||||||
result.error("title was not provided", null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = MainActivity!!.getContext()!!
|
|
||||||
|
|
||||||
val fileUri = FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", file)
|
|
||||||
val intent = Intent()
|
|
||||||
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
intent.action = Intent.ACTION_SEND
|
|
||||||
intent.type = "text/*"
|
|
||||||
|
|
||||||
intent.putExtra(Intent.EXTRA_SUBJECT, title)
|
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, fileUri)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
|
|
||||||
val receiver = Intent(context, ShareReceiver::class.java)
|
|
||||||
receiver.putExtra(Intent.EXTRA_TEXT, file)
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(context, 0, receiver, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
|
|
||||||
val chooserIntent = Intent.createChooser(intent, title, pendingIntent.intentSender)
|
|
||||||
val resInfoList: List<ResolveInfo> = context.packageManager.queryIntentActivities(chooserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
|
||||||
for (resolveInfo in resInfoList) {
|
|
||||||
val packageName: String = resolveInfo.activityInfo.packageName
|
|
||||||
context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startActivity(chooserIntent)
|
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
Log.println(Log.ERROR, "", "Share: Error")
|
|
||||||
return result.error(err.message, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShareReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
if (intent == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val res = intent.extras.get(Intent.EXTRA_CHOSEN_COMPONENT) as? ComponentName ?: return
|
|
||||||
when (res.className) {
|
|
||||||
"org.chromium.arc.intent_helper.SendTextToClipboardActivity" -> {
|
|
||||||
val file = intent.extras[Intent.EXTRA_TEXT] as? File ?: return
|
|
||||||
val clipboard = context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
|
|
||||||
clipboard.primaryClip = ClipData.newPlainText("", file.readText())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,7 +3,6 @@ package net.defined.mobile_nebula
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.annotations.Expose
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
@ -16,7 +15,7 @@ data class SiteContainer(
|
||||||
)
|
)
|
||||||
|
|
||||||
class Sites(private var engine: FlutterEngine) {
|
class Sites(private var engine: FlutterEngine) {
|
||||||
private var sites: HashMap<String, SiteContainer> = HashMap()
|
private var containers: HashMap<String, SiteContainer> = HashMap()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refreshSites()
|
refreshSites()
|
||||||
|
@ -24,64 +23,115 @@ class Sites(private var engine: FlutterEngine) {
|
||||||
|
|
||||||
fun refreshSites(activeSite: String? = null) {
|
fun refreshSites(activeSite: String? = null) {
|
||||||
val context = MainActivity.getContext()!!
|
val context = MainActivity.getContext()!!
|
||||||
val sitesDir = context.filesDir.resolve("sites")
|
|
||||||
if (!sitesDir.isDirectory) {
|
val sites = SiteList(context)
|
||||||
sitesDir.delete()
|
val containers: HashMap<String, SiteContainer> = HashMap()
|
||||||
sitesDir.mkdir()
|
sites.getSites().values.forEach { site ->
|
||||||
|
// Don't create a new SiteUpdater or we will lose subscribers
|
||||||
|
var updater = this.containers[site.id]?.updater
|
||||||
|
if (updater != null) {
|
||||||
|
updater.setSite(site)
|
||||||
|
} else {
|
||||||
|
updater = SiteUpdater(site, engine)
|
||||||
}
|
}
|
||||||
|
|
||||||
sites = HashMap()
|
|
||||||
sitesDir.listFiles().forEach { siteDir ->
|
|
||||||
try {
|
|
||||||
val site = Site(siteDir)
|
|
||||||
|
|
||||||
// Make sure we can load the private key
|
|
||||||
site.getKey(context)
|
|
||||||
|
|
||||||
val updater = SiteUpdater(site, engine)
|
|
||||||
if (site.id == activeSite) {
|
if (site.id == activeSite) {
|
||||||
updater.setState(true, "Connected")
|
updater.setState(true, "Connected")
|
||||||
}
|
}
|
||||||
|
|
||||||
this.sites[site.id] = SiteContainer(site, updater)
|
containers[site.id] = SiteContainer(site, updater)
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
siteDir.deleteRecursively()
|
|
||||||
Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
this.containers = containers
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSites(): Map<String, Site> {
|
fun getSites(): Map<String, Site> {
|
||||||
return sites.mapValues { it.value.site }
|
return containers.mapValues { it.value.site }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSite(id: String) {
|
fun deleteSite(id: String) {
|
||||||
sites.remove(id)
|
val context = MainActivity.getContext()!!
|
||||||
val siteDir = MainActivity.getContext()!!.filesDir.resolve("sites").resolve(id)
|
val site = containers[id]!!.site
|
||||||
|
|
||||||
|
val baseDir = if(site.managed) context.noBackupFilesDir else context.filesDir
|
||||||
|
val siteDir = baseDir.resolve("sites").resolve(id)
|
||||||
siteDir.deleteRecursively()
|
siteDir.deleteRecursively()
|
||||||
|
refreshSites()
|
||||||
//TODO: make sure you stop the vpn
|
//TODO: make sure you stop the vpn
|
||||||
//TODO: make sure you relink the active site if this is the active site
|
//TODO: make sure you relink the active site if this is the active site
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSite(id: String): SiteContainer? {
|
fun getSite(id: String): SiteContainer? {
|
||||||
return sites[id]
|
return containers[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SiteList(context: Context) {
|
||||||
|
private var sites: Map<String, Site>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val nebulaSites = getSites(context, context.filesDir)
|
||||||
|
val dnSites = getSites(context, context.noBackupFilesDir)
|
||||||
|
|
||||||
|
// In case of a conflict, dnSites will take precedence.
|
||||||
|
sites = nebulaSites + dnSites
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSites(): Map<String, Site> {
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getSites(context: Context, directory: File): HashMap<String, Site> {
|
||||||
|
val sites = HashMap<String, Site>()
|
||||||
|
|
||||||
|
val sitesDir = directory.resolve("sites")
|
||||||
|
|
||||||
|
if (!sitesDir.isDirectory) {
|
||||||
|
sitesDir.delete()
|
||||||
|
sitesDir.mkdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
sitesDir.listFiles()?.forEach { siteDir ->
|
||||||
|
try {
|
||||||
|
val site = Site(context, siteDir)
|
||||||
|
|
||||||
|
// Make sure we can load the private key
|
||||||
|
site.getKey(context)
|
||||||
|
|
||||||
|
// Make sure we can load the DN credentials if managed
|
||||||
|
if (site.managed) {
|
||||||
|
site.getDNCredentials(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
sites[site.id] = site
|
||||||
|
} catch (err: Exception) {
|
||||||
|
siteDir.deleteRecursively()
|
||||||
|
Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler {
|
class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler {
|
||||||
|
private val gson = Gson()
|
||||||
// eventSink is how we send info back up to flutter
|
// eventSink is how we send info back up to flutter
|
||||||
private var eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}")
|
private var eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}")
|
||||||
private var eventSink: EventChannel.EventSink? = null
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
|
fun setSite(site: Site) {
|
||||||
|
this.site = site
|
||||||
|
}
|
||||||
|
|
||||||
fun setState(connected: Boolean, status: String, err: String? = null) {
|
fun setState(connected: Boolean, status: String, err: String? = null) {
|
||||||
site.connected = connected
|
site.connected = connected
|
||||||
site.status = status
|
site.status = status
|
||||||
val d = mapOf("connected" to site.connected, "status" to site.status)
|
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
eventSink?.error("", err, d)
|
eventSink?.error("", err, gson.toJson(site))
|
||||||
} else {
|
} else {
|
||||||
eventSink?.success(d)
|
eventSink?.success(gson.toJson(site))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -129,11 +179,29 @@ data class CertificateValidity(
|
||||||
@SerializedName("Reason") val reason: String
|
@SerializedName("Reason") val reason: String
|
||||||
)
|
)
|
||||||
|
|
||||||
class Site {
|
data class DNCredentials(
|
||||||
|
val hostID: String,
|
||||||
|
val privateKey: String,
|
||||||
|
val counter: Int,
|
||||||
|
val trustedKeys: String,
|
||||||
|
var invalid: Boolean,
|
||||||
|
) {
|
||||||
|
fun save(context: Context, siteDir: File) {
|
||||||
|
val jsonCreds = Gson().toJson(this)
|
||||||
|
|
||||||
|
val credsFile = siteDir.resolve("dnCredentials")
|
||||||
|
credsFile.delete()
|
||||||
|
|
||||||
|
EncFile(context).openWrite(credsFile).use { it.write(jsonCreds) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Site(context: Context, siteDir: File) {
|
||||||
val name: String
|
val name: String
|
||||||
val id: String
|
val id: String
|
||||||
val staticHostmap: HashMap<String, StaticHosts>
|
val staticHostmap: HashMap<String, StaticHosts>
|
||||||
val unsafeRoutes: List<UnsafeRoute>
|
val unsafeRoutes: List<UnsafeRoute>
|
||||||
|
val dnsResolvers: List<String>
|
||||||
var cert: CertificateInfo? = null
|
var cert: CertificateInfo? = null
|
||||||
var ca: Array<CertificateInfo>
|
var ca: Array<CertificateInfo>
|
||||||
val lhDuration: Int
|
val lhDuration: Int
|
||||||
|
@ -141,22 +209,25 @@ class Site {
|
||||||
val mtu: Int
|
val mtu: Int
|
||||||
val cipher: String
|
val cipher: String
|
||||||
val sortKey: Int
|
val sortKey: Int
|
||||||
var logVerbosity: String
|
val logVerbosity: String
|
||||||
var logLocalTZ: Boolean?
|
|
||||||
var connected: Boolean?
|
var connected: Boolean?
|
||||||
var status: String?
|
var status: String?
|
||||||
val logFile: String?
|
val logFile: String?
|
||||||
var errors: ArrayList<String> = ArrayList()
|
var errors: ArrayList<String> = ArrayList()
|
||||||
|
val managed: Boolean
|
||||||
|
// The following fields are present when managed = true
|
||||||
|
val rawConfig: String?
|
||||||
|
val lastManagedUpdate: String?
|
||||||
|
|
||||||
// Path to this site on disk
|
// Path to this site on disk
|
||||||
@Expose(serialize = false)
|
@Transient
|
||||||
val path: String
|
val path: String
|
||||||
|
|
||||||
// Strong representation of the site config
|
// Strong representation of the site config
|
||||||
@Expose(serialize = false)
|
@Transient
|
||||||
val config: String
|
val config: String
|
||||||
|
|
||||||
constructor(siteDir: File) {
|
init {
|
||||||
val gson = Gson()
|
val gson = Gson()
|
||||||
config = siteDir.resolve("config.json").readText()
|
config = siteDir.resolve("config.json").readText()
|
||||||
val incomingSite = gson.fromJson(config, IncomingSite::class.java)
|
val incomingSite = gson.fromJson(config, IncomingSite::class.java)
|
||||||
|
@ -166,6 +237,7 @@ class Site {
|
||||||
id = incomingSite.id
|
id = incomingSite.id
|
||||||
staticHostmap = incomingSite.staticHostmap
|
staticHostmap = incomingSite.staticHostmap
|
||||||
unsafeRoutes = incomingSite.unsafeRoutes ?: ArrayList()
|
unsafeRoutes = incomingSite.unsafeRoutes ?: ArrayList()
|
||||||
|
dnsResolvers = incomingSite.dnsResolvers ?: ArrayList()
|
||||||
lhDuration = incomingSite.lhDuration
|
lhDuration = incomingSite.lhDuration
|
||||||
port = incomingSite.port
|
port = incomingSite.port
|
||||||
mtu = incomingSite.mtu ?: 1300
|
mtu = incomingSite.mtu ?: 1300
|
||||||
|
@ -173,7 +245,9 @@ class Site {
|
||||||
sortKey = incomingSite.sortKey ?: 0
|
sortKey = incomingSite.sortKey ?: 0
|
||||||
logFile = siteDir.resolve("log").absolutePath
|
logFile = siteDir.resolve("log").absolutePath
|
||||||
logVerbosity = incomingSite.logVerbosity ?: "info"
|
logVerbosity = incomingSite.logVerbosity ?: "info"
|
||||||
logLocalTZ = incomingSite.logLocalTZ ?: false
|
rawConfig = incomingSite.rawConfig
|
||||||
|
managed = incomingSite.managed ?: false
|
||||||
|
lastManagedUpdate = incomingSite.lastManagedUpdate
|
||||||
|
|
||||||
connected = false
|
connected = false
|
||||||
status = "Disconnected"
|
status = "Disconnected"
|
||||||
|
@ -203,7 +277,7 @@ class Site {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors && !managed) {
|
||||||
errors.add("There are issues with 1 or more ca certificates")
|
errors.add("There are issues with 1 or more ca certificates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -212,6 +286,10 @@ class Site {
|
||||||
errors.add("Error while loading certificate authorities: ${err.message}")
|
errors.add("Error while loading certificate authorities: ${err.message}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (managed && getDNCredentials(context).invalid) {
|
||||||
|
errors.add("Unable to fetch updates - please re-enroll the device")
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.isEmpty()) {
|
if (errors.isEmpty()) {
|
||||||
try {
|
try {
|
||||||
mobileNebula.MobileNebula.testConfig(config, getKey(MainActivity.getContext()!!))
|
mobileNebula.MobileNebula.testConfig(config, getKey(MainActivity.getContext()!!))
|
||||||
|
@ -221,12 +299,31 @@ class Site {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getKey(context: Context): String? {
|
fun getKey(context: Context): String {
|
||||||
val f = EncFile(context).openRead(File(path).resolve("key"))
|
val f = EncFile(context).openRead(File(path).resolve("key"))
|
||||||
val k = f.readText()
|
val k = f.readText()
|
||||||
f.close()
|
f.close()
|
||||||
return k
|
return k
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun getDNCredentials(context: Context): DNCredentials {
|
||||||
|
val filepath = File(path).resolve("dnCredentials")
|
||||||
|
val f = EncFile(context).openRead(filepath)
|
||||||
|
val cfg = f.use { it.readText() }
|
||||||
|
return Gson().fromJson(cfg, DNCredentials::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun invalidateDNCredentials(context: Context) {
|
||||||
|
val creds = getDNCredentials(context)
|
||||||
|
creds.invalid = true
|
||||||
|
creds.save(context, File(path))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validateDNCredentials(context: Context) {
|
||||||
|
val creds = getDNCredentials(context)
|
||||||
|
creds.invalid = false
|
||||||
|
creds.save(context, File(path))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class StaticHosts(
|
data class StaticHosts(
|
||||||
|
@ -245,6 +342,7 @@ class IncomingSite(
|
||||||
val id: String,
|
val id: String,
|
||||||
val staticHostmap: HashMap<String, StaticHosts>,
|
val staticHostmap: HashMap<String, StaticHosts>,
|
||||||
val unsafeRoutes: List<UnsafeRoute>?,
|
val unsafeRoutes: List<UnsafeRoute>?,
|
||||||
|
val dnsResolvers: List<String>?,
|
||||||
val cert: String,
|
val cert: String,
|
||||||
val ca: String,
|
val ca: String,
|
||||||
val lhDuration: Int,
|
val lhDuration: Int,
|
||||||
|
@ -252,27 +350,37 @@ class IncomingSite(
|
||||||
val mtu: Int?,
|
val mtu: Int?,
|
||||||
val cipher: String,
|
val cipher: String,
|
||||||
val sortKey: Int?,
|
val sortKey: Int?,
|
||||||
var logVerbosity: String?,
|
val logVerbosity: String?,
|
||||||
val logLocalTZ: Boolean?,
|
var key: String?,
|
||||||
@Expose(serialize = false)
|
val managed: Boolean?,
|
||||||
var key: String?
|
// The following fields are present when managed = true
|
||||||
|
val lastManagedUpdate: String?,
|
||||||
|
val rawConfig: String?,
|
||||||
|
var dnCredentials: DNCredentials?,
|
||||||
) {
|
) {
|
||||||
|
fun save(context: Context): File {
|
||||||
fun save(context: Context) {
|
// Don't allow backups of DN-managed sites
|
||||||
val siteDir = context.filesDir.resolve("sites").resolve(id)
|
val baseDir = if(managed == true) context.noBackupFilesDir else context.filesDir
|
||||||
|
val siteDir = baseDir.resolve("sites").resolve(id)
|
||||||
if (!siteDir.exists()) {
|
if (!siteDir.exists()) {
|
||||||
siteDir.mkdir()
|
siteDir.mkdir()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key != null) {
|
if (key != null) {
|
||||||
val f = EncFile(context).openWrite(siteDir.resolve("key"))
|
val keyFile = siteDir.resolve("key")
|
||||||
f.use { it.write(key) }
|
keyFile.delete()
|
||||||
f.close()
|
val encFile = EncFile(context).openWrite(keyFile)
|
||||||
|
encFile.use { it.write(key) }
|
||||||
|
encFile.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
key = null
|
key = null
|
||||||
val gson = Gson()
|
|
||||||
|
dnCredentials?.save(context, siteDir)
|
||||||
|
dnCredentials = null
|
||||||
|
|
||||||
val confFile = siteDir.resolve("config.json")
|
val confFile = siteDir.resolve("config.json")
|
||||||
confFile.writeText(gson.toJson(this))
|
confFile.writeText(Gson().toJson(this))
|
||||||
|
|
||||||
|
return siteDir
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="net.defined.mobile_nebula">
|
|
||||||
<!-- Flutter needs it to communicate with the running application
|
<!-- Flutter needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.3.61'
|
ext {
|
||||||
|
workVersion = "2.7.1"
|
||||||
|
kotlinVersion = '1.7.20'
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:4.0.0'
|
classpath 'com.android.tools.build:gradle:7.3.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +30,6 @@ subprojects {
|
||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
task clean(type: Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
package_name("net.defined.mobile_nebula")
|
||||||
|
json_key_file(ENV['GOOGLE_PLAY_API_JWT_PATH'])
|
|
@ -0,0 +1,50 @@
|
||||||
|
# This file contains the fastlane.tools configuration
|
||||||
|
# You can find the documentation at https://docs.fastlane.tools
|
||||||
|
#
|
||||||
|
# For a list of all available actions, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/actions
|
||||||
|
#
|
||||||
|
# For a list of all available plugins, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/plugins/available-plugins
|
||||||
|
#
|
||||||
|
|
||||||
|
# Uncomment the line if you want fastlane to automatically update itself
|
||||||
|
# update_fastlane
|
||||||
|
|
||||||
|
default_platform(:android)
|
||||||
|
|
||||||
|
platform :android do
|
||||||
|
lane :release_build_number do
|
||||||
|
nextCode = sprintf("%s", latest_googleplay_version_code + 1)
|
||||||
|
File.write("../../release_build_number", nextCode)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Deploy a new version to the Google Play"
|
||||||
|
lane :release do
|
||||||
|
upload_to_play_store(
|
||||||
|
track: 'internal',
|
||||||
|
aab: '../build/app/outputs/bundle/release/app-release.aab'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_googleplay_version_code
|
||||||
|
productionVersionCodes = google_play_track_version_codes(track: 'production')
|
||||||
|
#NOTE: we do not have a beta track right now
|
||||||
|
#betaVersionCodes = google_play_track_version_codes(track: 'beta')
|
||||||
|
alphaVersionCodes = google_play_track_version_codes(track: 'alpha')
|
||||||
|
internalVersionCodes = google_play_track_version_codes(track: 'internal')
|
||||||
|
|
||||||
|
# puts version codes from all tracks into the same array
|
||||||
|
versionCodes = [
|
||||||
|
productionVersionCodes,
|
||||||
|
#betaVersionCodes,
|
||||||
|
alphaVersionCodes,
|
||||||
|
internalVersionCodes
|
||||||
|
].reduce([], :concat)
|
||||||
|
|
||||||
|
# returns the highest version code from array
|
||||||
|
return versionCodes.max
|
||||||
|
end
|
|
@ -0,0 +1,40 @@
|
||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
### android release_build_number
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android release_build_number
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### android release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android release
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy a new version to the Google Play
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
|
@ -1,4 +1,3 @@
|
||||||
org.gradle.jvmargs=-Xmx1536M
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
android.enableR8=true
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
#Fri Jun 05 14:55:48 CDT 2020
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
configurations.maybeCreate("default")
|
||||||
|
exec {
|
||||||
|
workingDir '../../'
|
||||||
|
commandLine './gen-artifacts.sh', 'android'
|
||||||
|
}
|
||||||
|
artifacts.add("default", file('mobileNebula.aar'))
|
|
@ -1,15 +1,11 @@
|
||||||
include ':app'
|
include ':app', ':mobileNebula'
|
||||||
|
|
||||||
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
|
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||||
|
def properties = new Properties()
|
||||||
|
|
||||||
def plugins = new Properties()
|
assert localPropertiesFile.exists()
|
||||||
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
|
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||||
if (pluginsFile.exists()) {
|
|
||||||
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins.each { name, path ->
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
include ":$name"
|
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||||
project(":$name").projectDir = pluginDirectory
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
include ':app'
|
|
|
@ -9,15 +9,16 @@ cd nebula
|
||||||
|
|
||||||
if [ "$1" = "ios" ]; then
|
if [ "$1" = "ios" ]; then
|
||||||
# Build for nebula for iOS
|
# Build for nebula for iOS
|
||||||
make MobileNebula.framework
|
make MobileNebula.xcframework
|
||||||
rm -rf ../ios/NebulaNetworkExtension/MobileNebula.framework
|
rm -rf ../ios/MobileNebula.xcframework
|
||||||
cp -r MobileNebula.framework ../ios/NebulaNetworkExtension/
|
cp -r MobileNebula.xcframework ../ios/
|
||||||
|
|
||||||
elif [ "$1" = "android" ]; then
|
elif [ "$1" = "android" ]; then
|
||||||
# Build nebula for android
|
# Build nebula for android
|
||||||
make mobileNebula.aar
|
make mobileNebula.aar
|
||||||
rm -rf ../android/app/src/main/libs/mobileNebula.aar
|
mkdir -p ../android/mobileNebula
|
||||||
cp mobileNebula.aar ../android/app/src/main/libs/mobileNebula.aar
|
rm -rf ../android/mobileNebula/mobileNebula.aar
|
||||||
|
cp mobileNebula.aar ../android/mobileNebula/mobileNebula.aar
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "Error: unsupported target os $1"
|
echo "Error: unsupported target os $1"
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="53" height="62" viewBox="0 0 53 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M42.1128 61.2016H25.8226C30.4449 55.8553 42.14 32.9921 36.5151 23.1053C32.4774 15.9477 19.5464 12.8338 0 14.1999V0.323899C25.6196 -1.42992 41.6675 3.94663 48.6585 16.2567C57.4851 31.9077 47.3469 52.4022 42.1128 61.2016Z" fill="white"/>
|
||||||
|
<path d="M0 61.2106H13.9245V21.6453L0 14.0424V61.2106Z" fill="#6E7D91"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 421 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="53" height="62" viewBox="0 0 53 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M42.1128 61.2016H25.8226C30.4449 55.8553 42.14 32.9921 36.5151 23.1053C32.4774 15.9477 19.5464 12.8338 0 14.1999V0.323899C25.6196 -1.42992 41.6675 3.94663 48.6585 16.2567C57.4851 31.9077 47.3469 52.4022 42.1128 61.2016Z" fill="#0B0D0F"/>
|
||||||
|
<path d="M0 61.2106H13.9245V21.6453L0 14.0424V61.2106Z" fill="#6E7D91"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 423 B |
|
@ -21,6 +21,6 @@
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>8.0</string>
|
<string>11.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
|
@ -0,0 +1,218 @@
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.5)
|
||||||
|
rexml
|
||||||
|
addressable (2.8.1)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
artifactory (3.0.15)
|
||||||
|
atomos (0.1.3)
|
||||||
|
aws-eventstream (1.2.0)
|
||||||
|
aws-partitions (1.634.0)
|
||||||
|
aws-sdk-core (3.152.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
aws-partitions (~> 1, >= 1.525.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
aws-sdk-kms (1.58.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sdk-s3 (1.114.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sigv4 (1.5.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
babosa (1.0.4)
|
||||||
|
claide (1.1.0)
|
||||||
|
colored (1.2)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
commander (4.6.0)
|
||||||
|
highline (~> 2.0.0)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.6.4)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
emoji_regex (3.2.3)
|
||||||
|
excon (0.92.5)
|
||||||
|
faraday (1.10.2)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-cookie_jar (0.0.7)
|
||||||
|
faraday (>= 0.8.0)
|
||||||
|
http-cookie (~> 1.0.0)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
faraday_middleware (1.2.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
fastimage (2.2.6)
|
||||||
|
fastlane (2.210.1)
|
||||||
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
artifactory (~> 3.0)
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
|
colored
|
||||||
|
commander (~> 4.6)
|
||||||
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
|
faraday_middleware (~> 1.0)
|
||||||
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-storage (~> 1.31)
|
||||||
|
highline (~> 2.0)
|
||||||
|
json (< 3.0.0)
|
||||||
|
jwt (>= 2.1.0, < 3)
|
||||||
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multipart-post (~> 2.0.0)
|
||||||
|
naturally (~> 2.2)
|
||||||
|
optparse (~> 0.1.1)
|
||||||
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
|
security (= 0.1.3)
|
||||||
|
simctl (~> 1.6.3)
|
||||||
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
|
word_wrap (~> 1.0.0)
|
||||||
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
|
xcpretty (~> 0.3.0)
|
||||||
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
google-apis-androidpublisher_v3 (0.27.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-core (0.9.0)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
webrick
|
||||||
|
google-apis-iamcredentials_v1 (0.14.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-playcustomapp_v1 (0.10.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.17.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-cloud-core (1.6.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
|
google-cloud-errors (1.3.0)
|
||||||
|
google-cloud-storage (1.42.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
google-apis-storage_v1 (~> 0.17.0)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.2.0)
|
||||||
|
faraday (>= 0.17.3, < 3.a)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
|
highline (2.0.3)
|
||||||
|
http-cookie (1.0.5)
|
||||||
|
domain_name (~> 0.5)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
jmespath (1.6.1)
|
||||||
|
json (2.6.2)
|
||||||
|
jwt (2.5.0)
|
||||||
|
memoist (0.16.2)
|
||||||
|
mini_magick (4.11.0)
|
||||||
|
mini_mime (1.1.2)
|
||||||
|
multi_json (1.15.0)
|
||||||
|
multipart-post (2.0.0)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
naturally (2.2.1)
|
||||||
|
optparse (0.1.1)
|
||||||
|
os (1.1.4)
|
||||||
|
plist (3.6.0)
|
||||||
|
public_suffix (5.0.0)
|
||||||
|
rake (13.0.6)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.1.2)
|
||||||
|
rexml (3.2.5)
|
||||||
|
rouge (2.0.7)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
security (0.1.3)
|
||||||
|
signet (0.17.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
|
simctl (1.6.8)
|
||||||
|
CFPropertyList
|
||||||
|
naturally
|
||||||
|
terminal-notifier (2.0.0)
|
||||||
|
terminal-table (1.8.0)
|
||||||
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
|
tty-cursor (0.7.1)
|
||||||
|
tty-screen (0.8.1)
|
||||||
|
tty-spinner (0.9.3)
|
||||||
|
tty-cursor (~> 0.7)
|
||||||
|
uber (0.1.0)
|
||||||
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.7.0)
|
||||||
|
word_wrap (1.0.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
xcpretty (0.3.0)
|
||||||
|
rouge (~> 2.0.7)
|
||||||
|
xcpretty-travis-formatter (1.0.1)
|
||||||
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
arm64-darwin-21
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
fastlane
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.3.11
|
|
@ -0,0 +1,16 @@
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/* <sys/kern_control.h> */
|
||||||
|
#define CTLIOCGINFO 0xc0644e03UL
|
||||||
|
struct ctl_info {
|
||||||
|
u_int32_t ctl_id;
|
||||||
|
char ctl_name[96];
|
||||||
|
};
|
||||||
|
struct sockaddr_ctl {
|
||||||
|
u_char sc_len;
|
||||||
|
u_char sc_family;
|
||||||
|
u_int16_t ss_sysaddr;
|
||||||
|
u_int32_t sc_id;
|
||||||
|
u_int32_t sc_unit;
|
||||||
|
u_int32_t sc_reserved[5];
|
||||||
|
};
|
|
@ -3,17 +3,21 @@ import Foundation
|
||||||
let groupName = "group.net.defined.mobileNebula"
|
let groupName = "group.net.defined.mobileNebula"
|
||||||
|
|
||||||
class KeyChain {
|
class KeyChain {
|
||||||
class func save(key: String, data: Data) -> Bool {
|
class func save(key: String, data: Data, managed: Bool) -> Bool {
|
||||||
let query: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String : kSecClassGenericPassword as String,
|
kSecClass as String : kSecClassGenericPassword as String,
|
||||||
kSecAttrAccount as String : key,
|
kSecAttrAccount as String : key,
|
||||||
kSecValueData as String : data,
|
kSecValueData as String : data,
|
||||||
kSecAttrAccessGroup as String: groupName,
|
kSecAttrAccessGroup as String: groupName,
|
||||||
]
|
]
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
if (managed) {
|
||||||
let val = SecItemAdd(query as CFDictionary, nil)
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
return val == 0
|
}
|
||||||
|
|
||||||
|
// Attempt to delete an existing key to allow for an overwrite
|
||||||
|
_ = self.delete(key: key)
|
||||||
|
return SecItemAdd(query as CFDictionary, nil) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
class func load(key: String) -> Data? {
|
class func load(key: String) -> Data? {
|
||||||
|
@ -38,10 +42,8 @@ class KeyChain {
|
||||||
|
|
||||||
class func delete(key: String) -> Bool {
|
class func delete(key: String) -> Bool {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String : kSecClassGenericPassword,
|
kSecClass as String : kSecClassGenericPassword as String,
|
||||||
kSecAttrAccount as String : key,
|
kSecAttrAccount as String : key,
|
||||||
kSecReturnData as String : kCFBooleanTrue!,
|
|
||||||
kSecMatchLimit as String : kSecMatchLimitOne,
|
|
||||||
kSecAttrAccessGroup as String: groupName,
|
kSecAttrAccessGroup as String: groupName,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -1,64 +1,59 @@
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import MobileNebula
|
import MobileNebula
|
||||||
import os.log
|
import os.log
|
||||||
import MMWormhole
|
import SwiftyJSON
|
||||||
|
|
||||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
private var networkMonitor: NWPathMonitor?
|
private var networkMonitor: NWPathMonitor?
|
||||||
private var ifname: String?
|
|
||||||
|
|
||||||
private var site: Site?
|
private var site: Site?
|
||||||
private var _log = OSLog(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
|
private var log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
|
||||||
private var wormhole = MMWormhole(applicationGroupIdentifier: "group.net.defined.mobileNebula", optionalDirectory: "ipc")
|
|
||||||
private var nebula: MobileNebulaNebula?
|
private var nebula: MobileNebulaNebula?
|
||||||
|
private var dnUpdater = DNUpdater()
|
||||||
private func log(_ message: StaticString, _ args: CVarArg...) {
|
private var didSleep = false
|
||||||
os_log(message, log: _log, args)
|
private var cachedRouteDescription: String?
|
||||||
}
|
|
||||||
|
|
||||||
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||||
NSKeyedUnarchiver.setClass(IPCRequest.classForKeyedUnarchiver(), forClassName: "Runner.IPCRequest")
|
// There is currently no way to get initialization errors back to the UI via completionHandler here
|
||||||
|
// `expectStart` is sent only via the UI which means we should wait for the real start command which has another completion handler the UI can intercept
|
||||||
|
if options?["expectStart"] != nil {
|
||||||
|
// The system completion handler must be called before IPC will work
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// VPN is being booted out of band of the UI. Use the system completion handler as there will be nothing to route initialization errors to but we still need to report
|
||||||
|
// success/fail by the presence of an error or nil
|
||||||
|
start(completionHandler: completionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func start(completionHandler: @escaping (Error?) -> Void) {
|
||||||
let proto = self.protocolConfiguration as! NETunnelProviderProtocol
|
let proto = self.protocolConfiguration as! NETunnelProviderProtocol
|
||||||
var config: Data
|
var config: Data
|
||||||
var key: String
|
var key: String
|
||||||
|
|
||||||
do {
|
do {
|
||||||
config = proto.providerConfiguration?["config"] as! Data
|
|
||||||
site = try Site(proto: proto)
|
site = try Site(proto: proto)
|
||||||
|
config = try site!.getConfig()
|
||||||
} catch {
|
} catch {
|
||||||
//TODO: need a way to notify the app
|
//TODO: need a way to notify the app
|
||||||
log("Failed to render config from vpn object")
|
log.error("Failed to render config from vpn object")
|
||||||
return completionHandler(error)
|
return completionHandler(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let _site = site!
|
let _site = site!
|
||||||
_log = OSLog(subsystem: "net.defined.mobileNebula:\(_site.name)", category: "PacketTunnelProvider")
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
key = try _site.getKey()
|
key = try _site.getKey()
|
||||||
} catch {
|
} catch {
|
||||||
wormhole.passMessageObject(IPCMessage(id: _site.id, type: "error", message: error.localizedDescription), identifier: "nebula")
|
|
||||||
return completionHandler(error)
|
return completionHandler(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
self.networkMonitor = NWPathMonitor()
|
let fileDescriptor = tunnelFileDescriptor
|
||||||
self.networkMonitor!.pathUpdateHandler = self.pathUpdate
|
if fileDescriptor == nil {
|
||||||
self.networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor"))
|
return completionHandler("Unable to locate the tun file descriptor")
|
||||||
|
|
||||||
let fileDescriptor = (self.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32) ?? -1
|
|
||||||
if fileDescriptor < 0 {
|
|
||||||
let msg = IPCMessage(id: _site.id, type: "error", message: "Starting tunnel failed: Could not determine file descriptor")
|
|
||||||
wormhole.passMessageObject(msg, identifier: "nebula")
|
|
||||||
return completionHandler(NSError())
|
|
||||||
}
|
}
|
||||||
|
let tunFD = Int(fileDescriptor!)
|
||||||
var ifnameSize = socklen_t(IFNAMSIZ)
|
|
||||||
let ifnamePtr = UnsafeMutablePointer<CChar>.allocate(capacity: Int(ifnameSize))
|
|
||||||
ifnamePtr.initialize(repeating: 0, count: Int(ifnameSize))
|
|
||||||
if getsockopt(fileDescriptor, 2 /* SYSPROTO_CONTROL */, 2 /* UTUN_OPT_IFNAME */, ifnamePtr, &ifnameSize) == 0 {
|
|
||||||
self.ifname = String(cString: ifnamePtr)
|
|
||||||
}
|
|
||||||
ifnamePtr.deallocate()
|
|
||||||
|
|
||||||
// This is set to 127.0.0.1 because it has to be something..
|
// This is set to 127.0.0.1 because it has to be something..
|
||||||
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
let tunnelNetworkSettings = NEPacketTunnelNetworkSettings(tunnelRemoteAddress: "127.0.0.1")
|
||||||
|
@ -67,9 +62,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
|
let ipNet = MobileNebulaParseCIDR(_site.cert!.cert.details.ips[0], &err)
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
let msg = IPCMessage(id: _site.id, type: "error", message: err?.localizedDescription ?? "Unknown error from go MobileNebula.ParseCIDR - certificate")
|
return completionHandler(err!)
|
||||||
self.wormhole.passMessageObject(msg, identifier: "nebula")
|
|
||||||
return completionHandler(err)
|
|
||||||
}
|
}
|
||||||
tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR])
|
tunnelNetworkSettings.ipv4Settings = NEIPv4Settings(addresses: [ipNet!.ip], subnetMasks: [ipNet!.maskCIDR])
|
||||||
var routes: [NEIPv4Route] = [NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)]
|
var routes: [NEIPv4Route] = [NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR)]
|
||||||
|
@ -78,9 +71,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
_site.unsafeRoutes.forEach { unsafeRoute in
|
_site.unsafeRoutes.forEach { unsafeRoute in
|
||||||
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err)
|
let ipNet = MobileNebulaParseCIDR(unsafeRoute.route, &err)
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
let msg = IPCMessage(id: _site.id, type: "error", message: err?.localizedDescription ?? "Unknown error from go MobileNebula.ParseCIDR - unsafe routes")
|
return completionHandler(err!)
|
||||||
self.wormhole.passMessageObject(msg, identifier: "nebula")
|
|
||||||
return completionHandler(err)
|
|
||||||
}
|
}
|
||||||
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
|
routes.append(NEIPv4Route(destinationAddress: ipNet!.network, subnetMask: ipNet!.maskCIDR))
|
||||||
}
|
}
|
||||||
|
@ -88,49 +79,128 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
|
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
|
||||||
tunnelNetworkSettings.mtu = _site.mtu as NSNumber
|
tunnelNetworkSettings.mtu = _site.mtu as NSNumber
|
||||||
|
|
||||||
wormhole.listenForMessage(withIdentifier: "app", listener: self.wormholeListener)
|
if !_site.dnsResolvers.isEmpty {
|
||||||
|
let dnsSettings = NEDNSSettings(servers: _site.dnsResolvers)
|
||||||
|
tunnelNetworkSettings.dnsSettings = dnsSettings
|
||||||
|
}
|
||||||
|
|
||||||
self.setTunnelNetworkSettings(tunnelNetworkSettings, completionHandler: {(error:Error?) in
|
self.setTunnelNetworkSettings(tunnelNetworkSettings, completionHandler: {(error:Error?) in
|
||||||
if (error != nil) {
|
if (error != nil) {
|
||||||
let msg = IPCMessage(id: _site.id, type: "error", message: error?.localizedDescription ?? "Unknown setTunnelNetworkSettings error")
|
return completionHandler(error!)
|
||||||
self.wormhole.passMessageObject(msg, identifier: "nebula")
|
|
||||||
return completionHandler(error)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, Int(fileDescriptor), &err)
|
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &err)
|
||||||
|
self.startNetworkMonitor()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
let msg = IPCMessage(id: _site.id, type: "error", message: err?.localizedDescription ?? "Unknown error from go MobileNebula.Main")
|
self.log.error("We had an error starting up: \(err, privacy: .public)")
|
||||||
self.wormhole.passMessageObject(msg, identifier: "nebula")
|
return completionHandler(err!)
|
||||||
return completionHandler(err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self.nebula!.start()
|
self.nebula!.start()
|
||||||
|
self.dnUpdater.updateSingleLoop(site: self.site!, onUpdate: self.handleDNUpdate)
|
||||||
|
|
||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func handleDNUpdate(newSite: Site) {
|
||||||
|
do {
|
||||||
|
self.site = newSite
|
||||||
|
try self.nebula?.reload(String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey())
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
self.log.error("Got an error while updating nebula \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately
|
||||||
|
// override func sleep(completionHandler: @escaping () -> Void) {
|
||||||
|
// nebula!.sleep()
|
||||||
|
// completionHandler()
|
||||||
|
// }
|
||||||
|
|
||||||
|
private func startNetworkMonitor() {
|
||||||
|
networkMonitor = NWPathMonitor()
|
||||||
|
networkMonitor!.pathUpdateHandler = self.pathUpdate
|
||||||
|
networkMonitor!.start(queue: DispatchQueue(label: "NetworkMonitor"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func stopNetworkMonitor() {
|
||||||
|
self.networkMonitor?.cancel()
|
||||||
|
networkMonitor = nil
|
||||||
|
}
|
||||||
|
|
||||||
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
override func stopTunnel(with reason: NEProviderStopReason, completionHandler: @escaping () -> Void) {
|
||||||
nebula?.stop()
|
nebula?.stop()
|
||||||
networkMonitor?.cancel()
|
stopNetworkMonitor()
|
||||||
networkMonitor = nil
|
|
||||||
completionHandler()
|
completionHandler()
|
||||||
}
|
}
|
||||||
|
|
||||||
private func pathUpdate(path: Network.NWPath) {
|
private func pathUpdate(path: Network.NWPath) {
|
||||||
nebula?.rebind()
|
let routeDescription = collectAddresses(endpoints: path.gateways)
|
||||||
|
if routeDescription != cachedRouteDescription {
|
||||||
|
// Don't bother to rebind if we don't have any gateways
|
||||||
|
if routeDescription != "" {
|
||||||
|
nebula?.rebind("network change to: \(routeDescription); from: \(cachedRouteDescription ?? "none")")
|
||||||
|
}
|
||||||
|
cachedRouteDescription = routeDescription
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func wormholeListener(msg: Any?) {
|
private func collectAddresses(endpoints: [Network.NWEndpoint]) -> String {
|
||||||
guard let call = msg as? IPCRequest else {
|
var str: [String] = []
|
||||||
log("Failed to decode IPCRequest from network extension")
|
endpoints.forEach{ endpoint in
|
||||||
|
switch endpoint {
|
||||||
|
case let .hostPort(.ipv6(host), port):
|
||||||
|
str.append("[\(host)]:\(port)")
|
||||||
|
case let .hostPort(.ipv4(host), port):
|
||||||
|
str.append("\(host):\(port)")
|
||||||
|
default:
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return str.sorted().joined(separator: ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) {
|
||||||
|
guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else {
|
||||||
|
log.error("Failed to decode IPCRequest from network extension")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var error: Error?
|
var error: Error?
|
||||||
var data: Any?
|
var data: JSON?
|
||||||
|
|
||||||
|
// start command has special treatment due to needing to call two completers
|
||||||
|
if call.command == "start" {
|
||||||
|
self.start() { error in
|
||||||
|
// Notify the UI if we have a completionHandler
|
||||||
|
if completionHandler != nil {
|
||||||
|
if error == nil {
|
||||||
|
// No response data, this is expected on a clean start
|
||||||
|
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// We failed, notify and shutdown
|
||||||
|
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error!.localizedDescription))))
|
||||||
|
self.cancelTunnelWithError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if nebula == nil {
|
||||||
|
// Respond with an empty success message in the event a command comes in before we've truly started
|
||||||
|
log.warning("Received command but do not have a nebula instance")
|
||||||
|
return completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: try catch over all this
|
//TODO: try catch over all this
|
||||||
switch call.type {
|
switch call.command {
|
||||||
case "listHostmap": (data, error) = listHostmap(pending: false)
|
case "listHostmap": (data, error) = listHostmap(pending: false)
|
||||||
case "listPendingHostmap": (data, error) = listHostmap(pending: true)
|
case "listPendingHostmap": (data, error) = listHostmap(pending: true)
|
||||||
case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!)
|
case "getHostInfo": (data, error) = getHostInfo(args: call.arguments!)
|
||||||
|
@ -138,37 +208,69 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!)
|
case "closeTunnel": (data, error) = closeTunnel(args: call.arguments!)
|
||||||
|
|
||||||
default:
|
default:
|
||||||
error = "Unknown IPC message type \(call.type)"
|
error = "Unknown IPC message type \(call.command)"
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error != nil) {
|
if (error != nil) {
|
||||||
self.wormhole.passMessageObject(IPCMessage(id: "", type: "error", message: error!.localizedDescription), identifier: call.callbackId)
|
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error?.localizedDescription ?? "Unknown error"))))
|
||||||
} else {
|
} else {
|
||||||
self.wormhole.passMessageObject(IPCMessage(id: "", type: "success", message: data), identifier: call.callbackId)
|
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: data)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func listHostmap(pending: Bool) -> (String?, Error?) {
|
private func listHostmap(pending: Bool) -> (JSON?, Error?) {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
let res = nebula!.listHostmap(pending, error: &err)
|
let res = nebula!.listHostmap(pending, error: &err)
|
||||||
return (res, err)
|
return (JSON(res), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func getHostInfo(args: Dictionary<String, Any>) -> (String?, Error?) {
|
private func getHostInfo(args: JSON) -> (JSON?, Error?) {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
let res = nebula!.getHostInfo(byVpnIp: args["vpnIp"] as? String, pending: args["pending"] as! Bool, error: &err)
|
let res = nebula!.getHostInfo(byVpnIp: args["vpnIp"].string, pending: args["pending"].boolValue, error: &err)
|
||||||
return (res, err)
|
return (JSON(res), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setRemoteForTunnel(args: Dictionary<String, Any>) -> (String?, Error?) {
|
private func setRemoteForTunnel(args: JSON) -> (JSON?, Error?) {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
let res = nebula!.setRemoteForTunnel(args["vpnIp"] as? String, addr: args["addr"] as? String, error: &err)
|
let res = nebula!.setRemoteForTunnel(args["vpnIp"].string, addr: args["addr"].string, error: &err)
|
||||||
return (res, err)
|
return (JSON(res), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func closeTunnel(args: Dictionary<String, Any>) -> (Bool?, Error?) {
|
private func closeTunnel(args: JSON) -> (JSON?, Error?) {
|
||||||
let res = nebula!.closeTunnel(args["vpnIp"] as? String)
|
let res = nebula!.closeTunnel(args["vpnIp"].string)
|
||||||
return (res, nil)
|
return (JSON(res), nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tunnelFileDescriptor: Int32? {
|
||||||
|
var ctlInfo = ctl_info()
|
||||||
|
withUnsafeMutablePointer(to: &ctlInfo.ctl_name) {
|
||||||
|
$0.withMemoryRebound(to: CChar.self, capacity: MemoryLayout.size(ofValue: $0.pointee)) {
|
||||||
|
_ = strcpy($0, "com.apple.net.utun_control")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for fd: Int32 in 0...1024 {
|
||||||
|
var addr = sockaddr_ctl()
|
||||||
|
var ret: Int32 = -1
|
||||||
|
var len = socklen_t(MemoryLayout.size(ofValue: addr))
|
||||||
|
withUnsafeMutablePointer(to: &addr) {
|
||||||
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
||||||
|
ret = getpeername(fd, $0, &len)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ret != 0 || addr.sc_family != AF_SYSTEM {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ctlInfo.ctl_id == 0 {
|
||||||
|
ret = ioctl(fd, CTLIOCGINFO, &ctlInfo)
|
||||||
|
if ret != 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if addr.sc_id == ctlInfo.ctl_id {
|
||||||
|
return fd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,58 +1,36 @@
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import MobileNebula
|
import MobileNebula
|
||||||
|
import SwiftyJSON
|
||||||
|
|
||||||
extension String: Error {}
|
extension String: Error {}
|
||||||
|
|
||||||
class IPCMessage: NSObject, NSCoding {
|
enum IPCResponseType: String, Codable {
|
||||||
var id: String
|
case error = "error"
|
||||||
var type: String
|
case success = "success"
|
||||||
var message: Any?
|
|
||||||
|
|
||||||
func encode(with aCoder: NSCoder) {
|
|
||||||
aCoder.encode(id, forKey: "id")
|
|
||||||
aCoder.encode(type, forKey: "type")
|
|
||||||
aCoder.encode(message, forKey: "message")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
required init(coder aDecoder: NSCoder) {
|
class IPCResponse: Codable {
|
||||||
id = aDecoder.decodeObject(forKey: "id") as! String
|
var type: IPCResponseType
|
||||||
type = aDecoder.decodeObject(forKey: "type") as! String
|
//TODO: change message to data?
|
||||||
message = aDecoder.decodeObject(forKey: "message") as Any?
|
var message: JSON?
|
||||||
}
|
|
||||||
|
|
||||||
init(id: String, type: String, message: Any) {
|
init(type: IPCResponseType, message: JSON?) {
|
||||||
self.id = id
|
|
||||||
self.type = type
|
self.type = type
|
||||||
self.message = message
|
self.message = message
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class IPCRequest: NSObject, NSCoding {
|
class IPCRequest: Codable {
|
||||||
var type: String
|
var command: String
|
||||||
var callbackId: String
|
var arguments: JSON?
|
||||||
var arguments: Dictionary<String, Any>?
|
|
||||||
|
|
||||||
func encode(with aCoder: NSCoder) {
|
init(command: String, arguments: JSON?) {
|
||||||
aCoder.encode(type, forKey: "type")
|
self.command = command
|
||||||
aCoder.encode(arguments, forKey: "arguments")
|
|
||||||
aCoder.encode(callbackId, forKey: "callbackId")
|
|
||||||
}
|
|
||||||
|
|
||||||
required init(coder aDecoder: NSCoder) {
|
|
||||||
callbackId = aDecoder.decodeObject(forKey: "callbackId") as! String
|
|
||||||
type = aDecoder.decodeObject(forKey: "type") as! String
|
|
||||||
arguments = aDecoder.decodeObject(forKey: "arguments") as? Dictionary<String, Any>
|
|
||||||
}
|
|
||||||
|
|
||||||
init(callbackId: String, type: String, arguments: Dictionary<String, Any>?) {
|
|
||||||
self.callbackId = callbackId
|
|
||||||
self.type = type
|
|
||||||
self.arguments = arguments
|
self.arguments = arguments
|
||||||
}
|
}
|
||||||
|
|
||||||
init(callbackId: String, type: String) {
|
init(command: String) {
|
||||||
self.callbackId = callbackId
|
self.command = command
|
||||||
self.type = type
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,7 +51,7 @@ struct Certificate: Codable {
|
||||||
var signature: String
|
var signature: String
|
||||||
var details: CertificateDetails
|
var details: CertificateDetails
|
||||||
|
|
||||||
/// An empty initilizer to make error reporting easier
|
/// An empty initializer to make error reporting easier
|
||||||
init() {
|
init() {
|
||||||
fingerprint = ""
|
fingerprint = ""
|
||||||
signature = ""
|
signature = ""
|
||||||
|
@ -92,7 +70,7 @@ struct CertificateDetails: Codable {
|
||||||
var isCa: Bool
|
var isCa: Bool
|
||||||
var issuer: String
|
var issuer: String
|
||||||
|
|
||||||
/// An empty initilizer to make error reporting easier
|
/// An empty initializer to make error reporting easier
|
||||||
init() {
|
init() {
|
||||||
name = ""
|
name = ""
|
||||||
notBefore = ""
|
notBefore = ""
|
||||||
|
@ -119,7 +97,7 @@ struct CertificateValidity: Codable {
|
||||||
let statusMap: Dictionary<NEVPNStatus, Bool> = [
|
let statusMap: Dictionary<NEVPNStatus, Bool> = [
|
||||||
NEVPNStatus.invalid: false,
|
NEVPNStatus.invalid: false,
|
||||||
NEVPNStatus.disconnected: false,
|
NEVPNStatus.disconnected: false,
|
||||||
NEVPNStatus.connecting: true,
|
NEVPNStatus.connecting: false,
|
||||||
NEVPNStatus.connected: true,
|
NEVPNStatus.connected: true,
|
||||||
NEVPNStatus.reasserting: true,
|
NEVPNStatus.reasserting: true,
|
||||||
NEVPNStatus.disconnecting: true,
|
NEVPNStatus.disconnecting: true,
|
||||||
|
@ -135,7 +113,7 @@ let statusString: Dictionary<NEVPNStatus, String> = [
|
||||||
]
|
]
|
||||||
|
|
||||||
// Represents a site that was pulled out of the system configuration
|
// Represents a site that was pulled out of the system configuration
|
||||||
struct Site: Codable {
|
class Site: Codable {
|
||||||
// Stored in manager
|
// Stored in manager
|
||||||
var name: String
|
var name: String
|
||||||
var id: String
|
var id: String
|
||||||
|
@ -143,6 +121,7 @@ struct Site: Codable {
|
||||||
// Stored in proto
|
// Stored in proto
|
||||||
var staticHostmap: Dictionary<String, StaticHosts>
|
var staticHostmap: Dictionary<String, StaticHosts>
|
||||||
var unsafeRoutes: [UnsafeRoute]
|
var unsafeRoutes: [UnsafeRoute]
|
||||||
|
var dnsResolvers: [String]
|
||||||
var cert: CertificateInfo?
|
var cert: CertificateInfo?
|
||||||
var ca: [CertificateInfo]
|
var ca: [CertificateInfo]
|
||||||
var lhDuration: Int
|
var lhDuration: Int
|
||||||
|
@ -150,20 +129,27 @@ struct Site: Codable {
|
||||||
var mtu: Int
|
var mtu: Int
|
||||||
var cipher: String
|
var cipher: String
|
||||||
var sortKey: Int
|
var sortKey: Int
|
||||||
var logLocalTZ: Bool?
|
|
||||||
var logVerbosity: String
|
var logVerbosity: String
|
||||||
var connected: Bool?
|
var connected: Bool? //TODO: active is a better name
|
||||||
var status: String?
|
var status: String?
|
||||||
var logFile: String?
|
var logFile: String?
|
||||||
|
var managed: Bool
|
||||||
|
// The following fields are present if managed = true
|
||||||
|
var lastManagedUpdate: String?
|
||||||
|
var rawConfig: String?
|
||||||
|
|
||||||
|
/// If true then this site needs to be migrated to the filesystem. Should be handled by the initiator of the site
|
||||||
|
var needsToMigrateToFS: Bool = false
|
||||||
|
|
||||||
// A list of error encountered when trying to rehydrate a site from config
|
// A list of error encountered when trying to rehydrate a site from config
|
||||||
var errors: [String]
|
var errors: [String]
|
||||||
|
|
||||||
// We initialize to avoid an error with Codable, there is probably a better way since manager must be present for a Site but is not codable
|
var manager: NETunnelProviderManager?
|
||||||
var manager: NETunnelProviderManager = NETunnelProviderManager()
|
|
||||||
|
|
||||||
// Creates a new site from a vpn manager instance
|
var incomingSite: IncomingSite?
|
||||||
init(manager: NETunnelProviderManager) throws {
|
|
||||||
|
/// Creates a new site from a vpn manager instance. Mainly used by the UI. A manager is required to be able to edit the system profile
|
||||||
|
convenience init(manager: NETunnelProviderManager) throws {
|
||||||
//TODO: Throw an error and have Sites delete the site, notify the user instead of using !
|
//TODO: Throw an error and have Sites delete the site, notify the user instead of using !
|
||||||
let proto = manager.protocolConfiguration as! NETunnelProviderProtocol
|
let proto = manager.protocolConfiguration as! NETunnelProviderProtocol
|
||||||
try self.init(proto: proto)
|
try self.init(proto: proto)
|
||||||
|
@ -172,22 +158,53 @@ struct Site: Codable {
|
||||||
self.status = statusString[manager.connection.status]
|
self.status = statusString[manager.connection.status]
|
||||||
}
|
}
|
||||||
|
|
||||||
init(proto: NETunnelProviderProtocol) throws {
|
convenience init(proto: NETunnelProviderProtocol) throws {
|
||||||
let dict = proto.providerConfiguration
|
let dict = proto.providerConfiguration
|
||||||
|
|
||||||
|
if dict?["config"] != nil {
|
||||||
let config = dict?["config"] as? Data ?? Data()
|
let config = dict?["config"] as? Data ?? Data()
|
||||||
let decoder = JSONDecoder()
|
let decoder = JSONDecoder()
|
||||||
let incoming = try decoder.decode(IncomingSite.self, from: config)
|
let incoming = try decoder.decode(IncomingSite.self, from: config)
|
||||||
self.init(incoming: incoming)
|
self.init(incoming: incoming)
|
||||||
|
self.needsToMigrateToFS = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let id = dict?["id"] as? String ?? nil
|
||||||
|
if id == nil {
|
||||||
|
throw("Non-conforming site \(String(describing: dict))")
|
||||||
|
}
|
||||||
|
|
||||||
|
try self.init(path: SiteList.getSiteConfigFile(id: id!, createDir: false))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Creates a new site from a path on the filesystem. Mainly ussed by the VPN process or when in simulator where we lack a NEVPNManager
|
||||||
|
convenience init(path: URL) throws {
|
||||||
|
let config = try Data(contentsOf: path)
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let incoming = try decoder.decode(IncomingSite.self, from: config)
|
||||||
|
self.init(incoming: incoming)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(incoming: IncomingSite) {
|
init(incoming: IncomingSite) {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
|
|
||||||
|
incomingSite = incoming
|
||||||
errors = []
|
errors = []
|
||||||
name = incoming.name
|
name = incoming.name
|
||||||
id = incoming.id
|
id = incoming.id
|
||||||
staticHostmap = incoming.staticHostmap
|
staticHostmap = incoming.staticHostmap
|
||||||
unsafeRoutes = incoming.unsafeRoutes ?? []
|
unsafeRoutes = incoming.unsafeRoutes ?? []
|
||||||
|
dnsResolvers = incoming.dnsResolvers ?? []
|
||||||
|
lhDuration = incoming.lhDuration
|
||||||
|
port = incoming.port
|
||||||
|
cipher = incoming.cipher
|
||||||
|
sortKey = incoming.sortKey ?? 0
|
||||||
|
logVerbosity = incoming.logVerbosity ?? "info"
|
||||||
|
mtu = incoming.mtu ?? 1300
|
||||||
|
managed = incoming.managed ?? false
|
||||||
|
lastManagedUpdate = incoming.lastManagedUpdate
|
||||||
|
rawConfig = incoming.rawConfig
|
||||||
|
|
||||||
do {
|
do {
|
||||||
let rawCert = incoming.cert
|
let rawCert = incoming.cert
|
||||||
|
@ -226,7 +243,7 @@ struct Site: Codable {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors && !managed) {
|
||||||
errors.append("There are issues with 1 or more ca certificates")
|
errors.append("There are issues with 1 or more ca certificates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -235,14 +252,16 @@ struct Site: Codable {
|
||||||
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
|
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
lhDuration = incoming.lhDuration
|
do {
|
||||||
port = incoming.port
|
logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path
|
||||||
cipher = incoming.cipher
|
} catch {
|
||||||
sortKey = incoming.sortKey ?? 0
|
logFile = nil
|
||||||
logLocalTZ = incoming.logLocalTZ ?? false
|
errors.append("Unable to create the site directory: \(error.localizedDescription)")
|
||||||
logVerbosity = incoming.logVerbosity ?? "info"
|
}
|
||||||
mtu = incoming.mtu ?? 1300
|
|
||||||
logFile = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")?.appendingPathComponent(id).appendingPathExtension("log").path
|
if (managed && (try? getDNCredentials())?.invalid != false) {
|
||||||
|
errors.append("Unable to fetch managed updates - please re-enroll the device")
|
||||||
|
}
|
||||||
|
|
||||||
if (errors.isEmpty) {
|
if (errors.isEmpty) {
|
||||||
do {
|
do {
|
||||||
|
@ -251,6 +270,7 @@ struct Site: Codable {
|
||||||
let key = try getKey()
|
let key = try getKey()
|
||||||
let strConfig = String(data: rawConfig, encoding: .utf8)
|
let strConfig = String(data: rawConfig, encoding: .utf8)
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
|
|
||||||
MobileNebulaTestConfig(strConfig, key, &err)
|
MobileNebulaTestConfig(strConfig, key, &err)
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
throw err!
|
throw err!
|
||||||
|
@ -264,13 +284,49 @@ struct Site: Codable {
|
||||||
// Gets the private key from the keystore, we don't always need it in memory
|
// Gets the private key from the keystore, we don't always need it in memory
|
||||||
func getKey() throws -> String {
|
func getKey() throws -> String {
|
||||||
guard let keyData = KeyChain.load(key: "\(id).key") else {
|
guard let keyData = KeyChain.load(key: "\(id).key") else {
|
||||||
throw "failed to get key material from keychain"
|
throw "failed to get key from keychain"
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: make sure this is valid on return!
|
//TODO: make sure this is valid on return!
|
||||||
return String(decoding: keyData, as: UTF8.self)
|
return String(decoding: keyData, as: UTF8.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDNCredentials() throws -> DNCredentials {
|
||||||
|
if (!managed) {
|
||||||
|
throw "unmanaged site has no dn credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials")
|
||||||
|
if rawDNCredentials == nil {
|
||||||
|
throw "failed to find dn credentials in keychain"
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
return try decoder.decode(DNCredentials.self, from: rawDNCredentials!)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateDNCredentials() throws {
|
||||||
|
let creds = try getDNCredentials()
|
||||||
|
creds.invalid = true
|
||||||
|
|
||||||
|
if (!(try creds.save(siteID: self.id))) {
|
||||||
|
throw "failed to store dn credentials in keychain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDNCredentials() throws {
|
||||||
|
let creds = try getDNCredentials()
|
||||||
|
creds.invalid = false
|
||||||
|
|
||||||
|
if (!(try creds.save(siteID: self.id))) {
|
||||||
|
throw "failed to store dn credentials in keychain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfig() throws -> Data {
|
||||||
|
return try self.incomingSite!.getConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// Limits what we export to the UI
|
// Limits what we export to the UI
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
|
@ -286,10 +342,13 @@ struct Site: Codable {
|
||||||
case status
|
case status
|
||||||
case logFile
|
case logFile
|
||||||
case unsafeRoutes
|
case unsafeRoutes
|
||||||
case logLocalTZ
|
case dnsResolvers
|
||||||
case logVerbosity
|
case logVerbosity
|
||||||
case errors
|
case errors
|
||||||
case mtu
|
case mtu
|
||||||
|
case managed
|
||||||
|
case lastManagedUpdate
|
||||||
|
case rawConfig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -304,12 +363,41 @@ class UnsafeRoute: Codable {
|
||||||
var mtu: Int?
|
var mtu: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DNCredentials: Codable {
|
||||||
|
var hostID: String
|
||||||
|
var privateKey: String
|
||||||
|
var counter: Int
|
||||||
|
var trustedKeys: String
|
||||||
|
var invalid: Bool {
|
||||||
|
get { return _invalid ?? false }
|
||||||
|
set { _invalid = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _invalid: Bool?
|
||||||
|
|
||||||
|
func save(siteID: String) throws -> Bool {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let rawDNCredentials = try encoder.encode(self)
|
||||||
|
|
||||||
|
return KeyChain.save(key: "\(siteID).dnCredentials", data: rawDNCredentials, managed: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case hostID
|
||||||
|
case privateKey
|
||||||
|
case counter
|
||||||
|
case trustedKeys
|
||||||
|
case _invalid = "invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site
|
// This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site
|
||||||
struct IncomingSite: Codable {
|
struct IncomingSite: Codable {
|
||||||
var name: String
|
var name: String
|
||||||
var id: String
|
var id: String
|
||||||
var staticHostmap: Dictionary<String, StaticHosts>
|
var staticHostmap: Dictionary<String, StaticHosts>
|
||||||
var unsafeRoutes: [UnsafeRoute]?
|
var unsafeRoutes: [UnsafeRoute]?
|
||||||
|
vat dnsResolvers: [String]?
|
||||||
var cert: String
|
var cert: String
|
||||||
var ca: String
|
var ca: String
|
||||||
var lhDuration: Int
|
var lhDuration: Int
|
||||||
|
@ -317,27 +405,72 @@ struct IncomingSite: Codable {
|
||||||
var mtu: Int?
|
var mtu: Int?
|
||||||
var cipher: String
|
var cipher: String
|
||||||
var sortKey: Int?
|
var sortKey: Int?
|
||||||
var logLocalTZ: Bool?
|
|
||||||
var logVerbosity: String?
|
var logVerbosity: String?
|
||||||
var key: String?
|
var key: String?
|
||||||
|
var managed: Bool?
|
||||||
|
// The following fields are present if managed = true
|
||||||
|
var dnCredentials: DNCredentials?
|
||||||
|
var lastManagedUpdate: String?
|
||||||
|
var rawConfig: String?
|
||||||
|
|
||||||
func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
|
func getConfig() throws -> Data {
|
||||||
#if targetEnvironment(simulator)
|
|
||||||
let fileManager = FileManager.default
|
|
||||||
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id)
|
|
||||||
let encoder = JSONEncoder()
|
let encoder = JSONEncoder()
|
||||||
|
var config = self
|
||||||
|
|
||||||
|
config.key = nil
|
||||||
|
config.dnCredentials = nil
|
||||||
|
|
||||||
|
return try encoder.encode(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
func save(manager: NETunnelProviderManager?, saveToManager: Bool = true, callback: @escaping (Error?) -> ()) {
|
||||||
|
let configPath: URL
|
||||||
|
|
||||||
do {
|
do {
|
||||||
var config = self
|
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true)
|
||||||
config.key = nil
|
|
||||||
let rawConfig = try encoder.encode(config)
|
} catch {
|
||||||
try rawConfig.write(to: sitePath)
|
callback(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Saving to \(configPath)")
|
||||||
|
do {
|
||||||
|
if (self.key != nil) {
|
||||||
|
let data = self.key!.data(using: .utf8)
|
||||||
|
if (!KeyChain.save(key: "\(self.id).key", data: data!, managed: self.managed ?? false)) {
|
||||||
|
return callback("failed to store key material in keychain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if ((try self.dnCredentials?.save(siteID: self.id)) == false) {
|
||||||
|
return callback("failed to store dn credentials in keychain")
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return callback(error)
|
return callback(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try self.getConfig().write(to: configPath)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
return callback(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
// We are on a simulator and there is no NEVPNManager for us to interact with
|
||||||
callback(nil)
|
callback(nil)
|
||||||
#else
|
#else
|
||||||
|
if saveToManager {
|
||||||
|
self.saveToManager(manager: manager, callback: callback)
|
||||||
|
} else {
|
||||||
|
callback(nil)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private func saveToManager(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
|
||||||
if (manager != nil) {
|
if (manager != nil) {
|
||||||
// We need to refresh our settings to properly update config
|
// We need to refresh our settings to properly update config
|
||||||
manager?.loadFromPreferences { error in
|
manager?.loadFromPreferences { error in
|
||||||
|
@ -345,43 +478,19 @@ struct IncomingSite: Codable {
|
||||||
return callback(error)
|
return callback(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
return self.finish(manager: manager!, callback: callback)
|
return self.finishSaveToManager(manager: manager!, callback: callback)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
return finish(manager: NETunnelProviderManager(), callback: callback)
|
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private func finish(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) {
|
private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) {
|
||||||
var config = self
|
|
||||||
|
|
||||||
// Store the private key if it was provided
|
|
||||||
if (config.key != nil) {
|
|
||||||
//TODO: should we ensure the resulting data is big enough? (conversion didn't fail)
|
|
||||||
let data = config.key!.data(using: .utf8)
|
|
||||||
if (!KeyChain.save(key: "\(config.id).key", data: data!)) {
|
|
||||||
return callback("failed to store key material in keychain")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Zero out the key so that we don't save it in the profile
|
|
||||||
config.key = nil
|
|
||||||
|
|
||||||
// Stuff our details in the protocol
|
// Stuff our details in the protocol
|
||||||
let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol()
|
let proto = manager.protocolConfiguration as? NETunnelProviderProtocol ?? NETunnelProviderProtocol()
|
||||||
let encoder = JSONEncoder()
|
|
||||||
let rawConfig: Data
|
|
||||||
|
|
||||||
// We tried using NSSecureCoder but that was obnoxious and didn't work so back to JSON
|
proto.providerConfiguration = ["id": self.id]
|
||||||
do {
|
|
||||||
rawConfig = try encoder.encode(config)
|
|
||||||
} catch {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
proto.providerConfiguration = ["config": rawConfig]
|
|
||||||
proto.serverAddress = "Nebula"
|
proto.serverAddress = "Nebula"
|
||||||
|
|
||||||
// Finish up the manager, this is what stores everything at the system level
|
// Finish up the manager, this is what stores everything at the system level
|
||||||
|
@ -389,7 +498,7 @@ struct IncomingSite: Codable {
|
||||||
//TODO: cert name? manager.protocolConfiguration?.username
|
//TODO: cert name? manager.protocolConfiguration?.username
|
||||||
|
|
||||||
//TODO: This is what is shown on the vpn page. We should add more identifying details in
|
//TODO: This is what is shown on the vpn page. We should add more identifying details in
|
||||||
manager.localizedDescription = config.name
|
manager.localizedDescription = self.name
|
||||||
manager.isEnabled = true
|
manager.isEnabled = true
|
||||||
|
|
||||||
manager.saveToPreferences{ error in
|
manager.saveToPreferences{ error in
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
import NetworkExtension
|
||||||
|
|
||||||
|
class SiteList {
|
||||||
|
private var sites = [String: Site]()
|
||||||
|
|
||||||
|
/// Gets the root directory that can be used to share files between the UI and VPN process. Does ensure the directory exists
|
||||||
|
static func getRootDir() throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let rootDir = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")!
|
||||||
|
|
||||||
|
if (!fileManager.fileExists(atPath: rootDir.absoluteString)) {
|
||||||
|
try fileManager.createDirectory(at: rootDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the directory where all sites live, $rootDir/sites. Does ensure the directory exists
|
||||||
|
static func getSitesDir() throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let sitesDir = try getRootDir().appendingPathComponent("sites", isDirectory: true)
|
||||||
|
if (!fileManager.fileExists(atPath: sitesDir.absoluteString)) {
|
||||||
|
try fileManager.createDirectory(at: sitesDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return sitesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the directory where a single site would live, $rootDir/sites/$siteID
|
||||||
|
static func getSiteDir(id: String, create: Bool = false) throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let siteDir = try getSitesDir().appendingPathComponent(id, isDirectory: true)
|
||||||
|
if (create && !fileManager.fileExists(atPath: siteDir.absoluteString)) {
|
||||||
|
try fileManager.createDirectory(at: siteDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return siteDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the file that represents the site configuration, $rootDir/sites/$siteID/config.json
|
||||||
|
static func getSiteConfigFile(id: String, createDir: Bool) throws -> URL {
|
||||||
|
return try getSiteDir(id: id, create: createDir).appendingPathComponent("config", isDirectory: false).appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the file that represents the site log output, $rootDir/sites/$siteID/log
|
||||||
|
static func getSiteLogFile(id: String, createDir: Bool) throws -> URL {
|
||||||
|
return try getSiteDir(id: id, create: createDir).appendingPathComponent("logs", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
SiteList.loadAllFromFS { sites, err in
|
||||||
|
if sites != nil {
|
||||||
|
self.sites = sites!
|
||||||
|
}
|
||||||
|
completion(sites, err)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
SiteList.loadAllFromNETPM { sites, err in
|
||||||
|
if sites != nil {
|
||||||
|
self.sites = sites!
|
||||||
|
}
|
||||||
|
completion(sites, err)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadAllFromFS(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
var siteDirs: [URL]
|
||||||
|
var sites = [String: Site]()
|
||||||
|
|
||||||
|
do {
|
||||||
|
siteDirs = try fileManager.contentsOfDirectory(at: getSitesDir(), includingPropertiesForKeys: nil)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
completion(nil, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siteDirs.forEach { path in
|
||||||
|
do {
|
||||||
|
let site = try Site(path: path.appendingPathComponent("config").appendingPathExtension("json"))
|
||||||
|
sites[site.id] = site
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
try? fileManager.removeItem(at: path)
|
||||||
|
print("Deleted non conforming site \(path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(sites, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadAllFromNETPM(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
|
var sites = [String: Site]()
|
||||||
|
|
||||||
|
// dispatchGroup is used to ensure we have migrated all sites before returning them
|
||||||
|
// If there are no sites to migrate, there are never any entrants
|
||||||
|
let dispatchGroup = DispatchGroup()
|
||||||
|
|
||||||
|
NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in
|
||||||
|
if (err != nil) {
|
||||||
|
return completion(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newManagers?.forEach { manager in
|
||||||
|
do {
|
||||||
|
let site = try Site(manager: manager)
|
||||||
|
if site.needsToMigrateToFS {
|
||||||
|
dispatchGroup.enter()
|
||||||
|
site.incomingSite?.save(manager: manager) { error in
|
||||||
|
if error != nil {
|
||||||
|
print("Error while migrating site to fs: \(error!.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Migrated site to fs: \(site.name)")
|
||||||
|
site.needsToMigrateToFS = false
|
||||||
|
dispatchGroup.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sites[site.id] = site
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
//TODO: notify the user about this
|
||||||
|
print("Deleted non conforming site \(manager) \(error)")
|
||||||
|
manager.removeFromPreferences()
|
||||||
|
//TODO: delete from disk, we need to try and discover the site id though
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchGroup.notify(queue: .main) {
|
||||||
|
completion(sites, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSites() -> [String: Site] {
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
}
|
14
ios/Podfile
14
ios/Podfile
|
@ -32,15 +32,25 @@ target 'Runner' do
|
||||||
use_modular_headers!
|
use_modular_headers!
|
||||||
|
|
||||||
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
|
||||||
pod 'MMWormhole', '~> 2.0.0'
|
pod 'SwiftyJSON', '~> 5.0'
|
||||||
end
|
end
|
||||||
|
|
||||||
target 'NebulaNetworkExtension' do
|
target 'NebulaNetworkExtension' do
|
||||||
use_frameworks!
|
use_frameworks!
|
||||||
pod 'MMWormhole', '~> 2.0.0'
|
pod 'SwiftyJSON', '~> 5.0'
|
||||||
end
|
end
|
||||||
|
|
||||||
post_install do |installer|
|
post_install do |installer|
|
||||||
|
installer.generated_projects.each do |project|
|
||||||
|
project.targets.each do |target|
|
||||||
|
target.build_configurations.each do |config|
|
||||||
|
if Gem::Version.new('11.0') > Gem::Version.new(config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'])
|
||||||
|
config.build_settings['IPHONEOS_DEPLOYMENT_TARGET'] = '11.0'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
installer.pods_project.targets.each do |target|
|
installer.pods_project.targets.each do |target|
|
||||||
flutter_additional_ios_build_settings(target)
|
flutter_additional_ios_build_settings(target)
|
||||||
end
|
end
|
||||||
|
|
123
ios/Podfile.lock
123
ios/Podfile.lock
|
@ -1,112 +1,103 @@
|
||||||
PODS:
|
PODS:
|
||||||
- barcode_scan (0.0.1):
|
- DKImagePickerController/Core (4.3.4):
|
||||||
- Flutter
|
|
||||||
- MTBBarcodeScanner
|
|
||||||
- SwiftProtobuf
|
|
||||||
- DKImagePickerController/Core (4.3.0):
|
|
||||||
- DKImagePickerController/ImageDataManager
|
- DKImagePickerController/ImageDataManager
|
||||||
- DKImagePickerController/Resource
|
- DKImagePickerController/Resource
|
||||||
- DKImagePickerController/ImageDataManager (4.3.0)
|
- DKImagePickerController/ImageDataManager (4.3.4)
|
||||||
- DKImagePickerController/PhotoGallery (4.3.0):
|
- DKImagePickerController/PhotoGallery (4.3.4):
|
||||||
- DKImagePickerController/Core
|
- DKImagePickerController/Core
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- DKImagePickerController/Resource (4.3.0)
|
- DKImagePickerController/Resource (4.3.4)
|
||||||
- DKPhotoGallery (0.0.15):
|
- DKPhotoGallery (0.0.17):
|
||||||
- DKPhotoGallery/Core (= 0.0.15)
|
- DKPhotoGallery/Core (= 0.0.17)
|
||||||
- DKPhotoGallery/Model (= 0.0.15)
|
- DKPhotoGallery/Model (= 0.0.17)
|
||||||
- DKPhotoGallery/Preview (= 0.0.15)
|
- DKPhotoGallery/Preview (= 0.0.17)
|
||||||
- DKPhotoGallery/Resource (= 0.0.15)
|
- DKPhotoGallery/Resource (= 0.0.17)
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Core (0.0.15):
|
- DKPhotoGallery/Core (0.0.17):
|
||||||
- DKPhotoGallery/Model
|
- DKPhotoGallery/Model
|
||||||
- DKPhotoGallery/Preview
|
- DKPhotoGallery/Preview
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Model (0.0.15):
|
- DKPhotoGallery/Model (0.0.17):
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Preview (0.0.15):
|
- DKPhotoGallery/Preview (0.0.17):
|
||||||
- DKPhotoGallery/Model
|
- DKPhotoGallery/Model
|
||||||
- DKPhotoGallery/Resource
|
- DKPhotoGallery/Resource
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Resource (0.0.15):
|
- DKPhotoGallery/Resource (0.0.17):
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- FLAnimatedImage (1.0.12)
|
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- MMWormhole (2.0.0):
|
- flutter_barcode_scanner (2.0.0):
|
||||||
- MMWormhole/Core (= 2.0.0)
|
- Flutter
|
||||||
- MMWormhole/Core (2.0.0)
|
|
||||||
- MTBBarcodeScanner (5.0.11)
|
|
||||||
- package_info (0.0.1):
|
- package_info (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider (0.0.1):
|
- path_provider_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- SDWebImage (5.8.0):
|
- SDWebImage (5.15.5):
|
||||||
- SDWebImage/Core (= 5.8.0)
|
- SDWebImage/Core (= 5.15.5)
|
||||||
- SDWebImage/Core (5.8.0)
|
- SDWebImage/Core (5.15.5)
|
||||||
- SDWebImageFLPlugin (0.4.0):
|
- share_plus (0.0.1):
|
||||||
- FLAnimatedImage (>= 1.0.11)
|
- Flutter
|
||||||
- SDWebImage/Core (~> 5.6)
|
- SwiftyGif (5.4.4)
|
||||||
- SwiftProtobuf (1.8.0)
|
- SwiftyJSON (5.0.1)
|
||||||
- url_launcher (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- barcode_scan (from `.symlinks/plugins/barcode_scan/ios`)
|
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- MMWormhole (~> 2.0.0)
|
- flutter_barcode_scanner (from `.symlinks/plugins/flutter_barcode_scanner/ios`)
|
||||||
- package_info (from `.symlinks/plugins/package_info/ios`)
|
- package_info (from `.symlinks/plugins/package_info/ios`)
|
||||||
- path_provider (from `.symlinks/plugins/path_provider/ios`)
|
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||||
- url_launcher (from `.symlinks/plugins/url_launcher/ios`)
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
|
- SwiftyJSON (~> 5.0)
|
||||||
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- FLAnimatedImage
|
|
||||||
- MMWormhole
|
|
||||||
- MTBBarcodeScanner
|
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- SwiftProtobuf
|
- SwiftyJSON
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
barcode_scan:
|
|
||||||
:path: ".symlinks/plugins/barcode_scan/ios"
|
|
||||||
file_picker:
|
file_picker:
|
||||||
:path: ".symlinks/plugins/file_picker/ios"
|
:path: ".symlinks/plugins/file_picker/ios"
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
flutter_barcode_scanner:
|
||||||
|
:path: ".symlinks/plugins/flutter_barcode_scanner/ios"
|
||||||
package_info:
|
package_info:
|
||||||
:path: ".symlinks/plugins/package_info/ios"
|
:path: ".symlinks/plugins/package_info/ios"
|
||||||
path_provider:
|
path_provider_ios:
|
||||||
:path: ".symlinks/plugins/path_provider/ios"
|
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||||
url_launcher:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/url_launcher/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
barcode_scan: a5c27959edfafaa0c771905bad0b29d6d39e4479
|
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||||
DKImagePickerController: 397702a3590d4958fad336e9a77079935c500ddb
|
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||||
DKPhotoGallery: e880aef16c108333240e1e7327896f2ea380f4f0
|
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95
|
||||||
file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
|
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||||
FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31
|
flutter_barcode_scanner: 7a1144744c28dc0c57a8de7218ffe5ec59a9e4bf
|
||||||
Flutter: 0e3d915762c693b495b44d77113d4970485de6ec
|
|
||||||
MMWormhole: 0cd3fd35a9118b2e2d762b499f54eeaace0be791
|
|
||||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
|
||||||
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
|
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
|
||||||
path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
|
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
|
||||||
SDWebImage: 84000f962cbfa70c07f19d2234cbfcf5d779b5dc
|
SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe
|
||||||
SDWebImageFLPlugin: 6c2295fb1242d44467c6c87dc5db6b0a13228fd8
|
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||||
SwiftProtobuf: 2cbd9409689b7df170d82a92a33443c8e3e14a70
|
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
||||||
url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
|
SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e
|
||||||
|
url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af
|
||||||
|
|
||||||
PODFILE CHECKSUM: e8d4fb1ed5b0713de2623a28dfae2585e15c0d00
|
PODFILE CHECKSUM: b4b37a776e1b487bf31fc5e5014fa5a74f5a022a
|
||||||
|
|
||||||
COCOAPODS: 1.10.0
|
COCOAPODS: 1.11.3
|
||||||
|
|
|
@ -3,30 +3,37 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 46;
|
objectVersion = 52;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
432D0E3E291C562200752563 /* SiteList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432D0E3D291C562200752563 /* SiteList.swift */; };
|
||||||
|
432D0E3F291C562200752563 /* SiteList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432D0E3D291C562200752563 /* SiteList.swift */; };
|
||||||
|
43498725289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; };
|
||||||
|
43498726289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; };
|
||||||
437F72592469AAC500A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
437F72592469AAC500A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
||||||
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
||||||
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
||||||
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
||||||
43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43871C9A2444DD39004F9075 /* MobileNebula.framework */; };
|
|
||||||
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43871C9C2444E2EC004F9075 /* Sites.swift */; };
|
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43871C9C2444E2EC004F9075 /* Sites.swift */; };
|
||||||
43871C9E2444E61F004F9075 /* MobileNebula.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43871C9A2444DD39004F9075 /* MobileNebula.framework */; };
|
|
||||||
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
||||||
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */; };
|
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */; };
|
||||||
43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
||||||
43AD63F424EB3802000FB47E /* Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD63F324EB3802000FB47E /* Share.swift */; };
|
43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; };
|
||||||
|
43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; };
|
||||||
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; };
|
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
78E28476711DF3A9D186C429 /* Pods_NebulaNetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8EFDD7248CAE56012FE2608C /* Pods_NebulaNetworkExtension.framework */; };
|
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
|
||||||
|
BE45F626291AEAB300902884 /* PackageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE45F625291AEAB300902884 /* PackageInfo.swift */; };
|
||||||
|
BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5BC105291C41E600B6FE5B /* APIClient.swift */; };
|
||||||
|
BEC5939E291C502F00709118 /* APIClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE5BC105291C41E600B6FE5B /* APIClient.swift */; };
|
||||||
|
BEC5939F291C503D00709118 /* PackageInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE45F625291AEAB300902884 /* PackageInfo.swift */; };
|
||||||
|
E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
|
@ -64,14 +71,16 @@
|
||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
137DCAF9F91CD7AF6438A183 /* Pods-NebulaNetworkExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.debug.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.debug.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
384887B4785D38431E800D3A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
384887B4785D38431E800D3A /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
|
||||||
|
41927814D2E140A347A01067 /* Pods-NebulaNetworkExtension.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.debug.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
432D0E3D291C562200752563 /* SiteList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SiteList.swift; sourceTree = "<group>"; };
|
||||||
|
43498724289B484C00476B19 /* MobileNebula.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = MobileNebula.xcframework; sourceTree = SOURCE_ROOT; };
|
||||||
|
436DE7A226EFF18500BB2950 /* CtlInfo.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = CtlInfo.h; sourceTree = "<group>"; };
|
||||||
437F72582469AAC500A0C4B9 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
|
437F72582469AAC500A0C4B9 /* Site.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Site.swift; sourceTree = "<group>"; };
|
||||||
437F725C2469AC5700A0C4B9 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
|
437F725C2469AC5700A0C4B9 /* Keychain.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Keychain.swift; sourceTree = "<group>"; };
|
||||||
43871C9A2444DD39004F9075 /* MobileNebula.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; path = MobileNebula.framework; sourceTree = "<group>"; };
|
|
||||||
43871C9C2444E2EC004F9075 /* Sites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sites.swift; sourceTree = "<group>"; };
|
43871C9C2444E2EC004F9075 /* Sites.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sites.swift; sourceTree = "<group>"; };
|
||||||
43AA894C2444D8BC00EDC39C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
43AA894C2444D8BC00EDC39C /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = "<group>"; };
|
||||||
43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
|
43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = NetworkExtension.framework; path = System/Library/Frameworks/NetworkExtension.framework; sourceTree = SDKROOT; };
|
||||||
|
@ -79,17 +88,17 @@
|
||||||
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
||||||
43AA89582444DA6500EDC39C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
43AA89582444DA6500EDC39C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NebulaNetworkExtension.entitlements; sourceTree = "<group>"; };
|
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NebulaNetworkExtension.entitlements; sourceTree = "<group>"; };
|
||||||
43AD63F324EB3802000FB47E /* Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Share.swift; sourceTree = "<group>"; };
|
|
||||||
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
||||||
43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
|
43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
|
||||||
43B828DA249C08DC00CA229C /* MMWormhole.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MMWormhole.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNUpdate.swift; sourceTree = "<group>"; };
|
||||||
43E9BBD0251450C5000BFB8C /* MMWormhole.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MMWormhole.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
53C42258A2092B55937DCF53 /* Pods-NebulaNetworkExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.profile.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NebulaNetworkExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
6E7A71D8C71BF965D042667D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
6E7A71D8C71BF965D042667D /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
|
||||||
8E4961BE2F06B97C8C693530 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
8E4961BE2F06B97C8C693530 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
8EFDD7248CAE56012FE2608C /* Pods_NebulaNetworkExtension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_NebulaNetworkExtension.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
9169E2D0D49FAF5172A6E7B8 /* Pods-NebulaNetworkExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.release.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = "<group>"; };
|
||||||
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
@ -97,9 +106,9 @@
|
||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
BE45F625291AEAB300902884 /* PackageInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PackageInfo.swift; sourceTree = "<group>"; };
|
||||||
|
BE5BC105291C41E600B6FE5B /* APIClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = APIClient.swift; sourceTree = "<group>"; };
|
||||||
C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
E346A0DC829EBFB76D581AAD /* Pods-NebulaNetworkExtension.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.release.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.release.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
FA7B03A7901388BC39329544 /* Pods-NebulaNetworkExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.profile.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
@ -108,8 +117,8 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */,
|
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */,
|
||||||
43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */,
|
43498726289B484C00476B19 /* MobileNebula.xcframework in Frameworks */,
|
||||||
78E28476711DF3A9D186C429 /* Pods_NebulaNetworkExtension.framework in Frameworks */,
|
E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -118,8 +127,8 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */,
|
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */,
|
||||||
|
43498725289B484C00476B19 /* MobileNebula.xcframework in Frameworks */,
|
||||||
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */,
|
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */,
|
||||||
43871C9E2444E61F004F9075 /* MobileNebula.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -129,13 +138,11 @@
|
||||||
43AA894D2444D8BC00EDC39C /* Frameworks */ = {
|
43AA894D2444D8BC00EDC39C /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
43E9BBD0251450C5000BFB8C /* MMWormhole.framework */,
|
|
||||||
43B828DA249C08DC00CA229C /* MMWormhole.framework */,
|
|
||||||
43B66ECC245A146300B18C36 /* Foundation.framework */,
|
43B66ECC245A146300B18C36 /* Foundation.framework */,
|
||||||
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */,
|
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */,
|
||||||
43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */,
|
43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */,
|
||||||
384887B4785D38431E800D3A /* Pods_Runner.framework */,
|
384887B4785D38431E800D3A /* Pods_Runner.framework */,
|
||||||
8EFDD7248CAE56012FE2608C /* Pods_NebulaNetworkExtension.framework */,
|
5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -144,11 +151,13 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
437F725C2469AC5700A0C4B9 /* Keychain.swift */,
|
437F725C2469AC5700A0C4B9 /* Keychain.swift */,
|
||||||
43871C9A2444DD39004F9075 /* MobileNebula.framework */,
|
|
||||||
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */,
|
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */,
|
||||||
|
43498724289B484C00476B19 /* MobileNebula.xcframework */,
|
||||||
43AA89582444DA6500EDC39C /* Info.plist */,
|
43AA89582444DA6500EDC39C /* Info.plist */,
|
||||||
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */,
|
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */,
|
||||||
437F72582469AAC500A0C4B9 /* Site.swift */,
|
437F72582469AAC500A0C4B9 /* Site.swift */,
|
||||||
|
436DE7A226EFF18500BB2950 /* CtlInfo.h */,
|
||||||
|
432D0E3D291C562200752563 /* SiteList.swift */,
|
||||||
);
|
);
|
||||||
path = NebulaNetworkExtension;
|
path = NebulaNetworkExtension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -199,7 +208,9 @@
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
43871C9C2444E2EC004F9075 /* Sites.swift */,
|
43871C9C2444E2EC004F9075 /* Sites.swift */,
|
||||||
43AD63F324EB3802000FB47E /* Share.swift */,
|
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */,
|
||||||
|
BE45F625291AEAB300902884 /* PackageInfo.swift */,
|
||||||
|
BE5BC105291C41E600B6FE5B /* APIClient.swift */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -217,9 +228,9 @@
|
||||||
C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */,
|
C2D5198CF6975BF93E8A6F93 /* Pods-Runner.debug.xcconfig */,
|
||||||
6E7A71D8C71BF965D042667D /* Pods-Runner.release.xcconfig */,
|
6E7A71D8C71BF965D042667D /* Pods-Runner.release.xcconfig */,
|
||||||
8E4961BE2F06B97C8C693530 /* Pods-Runner.profile.xcconfig */,
|
8E4961BE2F06B97C8C693530 /* Pods-Runner.profile.xcconfig */,
|
||||||
137DCAF9F91CD7AF6438A183 /* Pods-NebulaNetworkExtension.debug.xcconfig */,
|
41927814D2E140A347A01067 /* Pods-NebulaNetworkExtension.debug.xcconfig */,
|
||||||
E346A0DC829EBFB76D581AAD /* Pods-NebulaNetworkExtension.release.xcconfig */,
|
9169E2D0D49FAF5172A6E7B8 /* Pods-NebulaNetworkExtension.release.xcconfig */,
|
||||||
FA7B03A7901388BC39329544 /* Pods-NebulaNetworkExtension.profile.xcconfig */,
|
53C42258A2092B55937DCF53 /* Pods-NebulaNetworkExtension.profile.xcconfig */,
|
||||||
);
|
);
|
||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -231,8 +242,7 @@
|
||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 43AA895D2444DA6500EDC39C /* Build configuration list for PBXNativeTarget "NebulaNetworkExtension" */;
|
buildConfigurationList = 43AA895D2444DA6500EDC39C /* Build configuration list for PBXNativeTarget "NebulaNetworkExtension" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
D39D78EE128AD494ACEF8DC0 /* [CP] Check Pods Manifest.lock */,
|
2C0A52E24BC9F327251CBAD2 /* [CP] Check Pods Manifest.lock */,
|
||||||
43AA89632444DAD100EDC39C /* ShellScript */,
|
|
||||||
43AA89502444DA6500EDC39C /* Sources */,
|
43AA89502444DA6500EDC39C /* Sources */,
|
||||||
43AA89512444DA6500EDC39C /* Frameworks */,
|
43AA89512444DA6500EDC39C /* Frameworks */,
|
||||||
43AA89522444DA6500EDC39C /* Resources */,
|
43AA89522444DA6500EDC39C /* Resources */,
|
||||||
|
@ -277,7 +287,7 @@
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastSwiftUpdateCheck = 1140;
|
LastSwiftUpdateCheck = 1140;
|
||||||
LastUpgradeCheck = 1020;
|
LastUpgradeCheck = 1300;
|
||||||
ORGANIZATIONNAME = "The Chromium Authors";
|
ORGANIZATIONNAME = "The Chromium Authors";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
43AA89532444DA6500EDC39C = {
|
43AA89532444DA6500EDC39C = {
|
||||||
|
@ -342,88 +352,36 @@
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh",
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh",
|
||||||
"${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework",
|
"${BUILT_PRODUCTS_DIR}/DKImagePickerController/DKImagePickerController.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/DKPhotoGallery/DKPhotoGallery.framework",
|
"${BUILT_PRODUCTS_DIR}/DKPhotoGallery/DKPhotoGallery.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/FLAnimatedImage/FLAnimatedImage.framework",
|
|
||||||
"${PODS_ROOT}/../Flutter/Flutter.framework",
|
|
||||||
"${BUILT_PRODUCTS_DIR}/MMWormhole/MMWormhole.framework",
|
|
||||||
"${BUILT_PRODUCTS_DIR}/MTBBarcodeScanner/MTBBarcodeScanner.framework",
|
|
||||||
"${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework",
|
"${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/SDWebImageFLPlugin/SDWebImageFLPlugin.framework",
|
"${BUILT_PRODUCTS_DIR}/SwiftyGif/SwiftyGif.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/SwiftProtobuf/SwiftProtobuf.framework",
|
"${BUILT_PRODUCTS_DIR}/SwiftyJSON/SwiftyJSON.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/barcode_scan/barcode_scan.framework",
|
|
||||||
"${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework",
|
"${BUILT_PRODUCTS_DIR}/file_picker/file_picker.framework",
|
||||||
|
"${BUILT_PRODUCTS_DIR}/flutter_barcode_scanner/flutter_barcode_scanner.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/package_info/package_info.framework",
|
"${BUILT_PRODUCTS_DIR}/package_info/package_info.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework",
|
"${BUILT_PRODUCTS_DIR}/path_provider_ios/path_provider_ios.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/url_launcher/url_launcher.framework",
|
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
|
||||||
|
"${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework",
|
||||||
);
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FLAnimatedImage.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Flutter.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MMWormhole.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MTBBarcodeScanner.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageFLPlugin.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyGif.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftProtobuf.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/barcode_scan.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework",
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_barcode_scanner.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_ios.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
2C0A52E24BC9F327251CBAD2 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Thin Binary";
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n";
|
|
||||||
};
|
|
||||||
43AA89632444DAD100EDC39C /* ShellScript */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputFileListPaths = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
outputFileListPaths = (
|
|
||||||
);
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "cd ..\n./gen-artifacts.sh ios\n";
|
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
|
||||||
buildActionMask = 2147483647;
|
|
||||||
files = (
|
|
||||||
);
|
|
||||||
inputPaths = (
|
|
||||||
);
|
|
||||||
name = "Run Script";
|
|
||||||
outputPaths = (
|
|
||||||
);
|
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
|
||||||
shellPath = /bin/sh;
|
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
|
|
||||||
};
|
|
||||||
D39D78EE128AD494ACEF8DC0 /* [CP] Check Pods Manifest.lock */ = {
|
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
@ -445,6 +403,34 @@
|
||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Thin Binary";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed\n/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" thin\n";
|
||||||
|
};
|
||||||
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
);
|
||||||
|
name = "Run Script";
|
||||||
|
outputPaths = (
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n";
|
||||||
|
};
|
||||||
FF0E0EB9A684F086443A8FBA /* [CP] Check Pods Manifest.lock */ = {
|
FF0E0EB9A684F086443A8FBA /* [CP] Check Pods Manifest.lock */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
|
@ -474,8 +460,12 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
432D0E3F291C562200752563 /* SiteList.swift in Sources */,
|
||||||
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */,
|
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */,
|
||||||
437F72592469AAC500A0C4B9 /* Site.swift in Sources */,
|
437F72592469AAC500A0C4B9 /* Site.swift in Sources */,
|
||||||
|
43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */,
|
||||||
|
BEC5939E291C502F00709118 /* APIClient.swift in Sources */,
|
||||||
|
BEC5939F291C503D00709118 /* PackageInfo.swift in Sources */,
|
||||||
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */,
|
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -485,11 +475,14 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
43AD63F424EB3802000FB47E /* Share.swift in Sources */,
|
432D0E3E291C562200752563 /* SiteList.swift in Sources */,
|
||||||
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */,
|
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */,
|
||||||
|
BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */,
|
||||||
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */,
|
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */,
|
||||||
|
BE45F626291AEAB300902884 /* PackageInfo.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */,
|
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */,
|
||||||
|
43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -581,7 +574,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -591,12 +584,15 @@
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Flutter",
|
"$(PROJECT_DIR)/Flutter",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.38;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
@ -607,7 +603,7 @@
|
||||||
};
|
};
|
||||||
43AA895E2444DA6500EDC39C /* Debug */ = {
|
43AA895E2444DA6500EDC39C /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = 137DCAF9F91CD7AF6438A183 /* Pods-NebulaNetworkExtension.debug.xcconfig */;
|
baseConfigurationReference = 41927814D2E140A347A01067 /* Pods-NebulaNetworkExtension.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
@ -616,7 +612,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -626,8 +622,12 @@
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 0.0.38;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
|
@ -635,6 +635,7 @@
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = NebulaNetworkExtension/CtlInfo.h;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
@ -643,7 +644,7 @@
|
||||||
};
|
};
|
||||||
43AA895F2444DA6500EDC39C /* Release */ = {
|
43AA895F2444DA6500EDC39C /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = E346A0DC829EBFB76D581AAD /* Pods-NebulaNetworkExtension.release.xcconfig */;
|
baseConfigurationReference = 9169E2D0D49FAF5172A6E7B8 /* Pods-NebulaNetworkExtension.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
@ -652,7 +653,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -662,13 +663,18 @@
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 0.0.38;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = NebulaNetworkExtension/CtlInfo.h;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
|
@ -676,7 +682,7 @@
|
||||||
};
|
};
|
||||||
43AA89602444DA6500EDC39C /* Profile */ = {
|
43AA89602444DA6500EDC39C /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
baseConfigurationReference = FA7B03A7901388BC39329544 /* Pods-NebulaNetworkExtension.profile.xcconfig */;
|
baseConfigurationReference = 53C42258A2092B55937DCF53 /* Pods-NebulaNetworkExtension.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
@ -685,7 +691,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -695,13 +701,18 @@
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 0.0.38;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = NebulaNetworkExtension/CtlInfo.h;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
|
@ -821,7 +832,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -831,12 +842,15 @@
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Flutter",
|
"$(PROJECT_DIR)/Flutter",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.38;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
@ -854,7 +868,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 2;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -864,12 +878,15 @@
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Flutter",
|
"$(PROJECT_DIR)/Flutter",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.38;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
<Workspace
|
<Workspace
|
||||||
version = "1.0">
|
version = "1.0">
|
||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "self:">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|
|
@ -1,10 +1,28 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1300"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Script"
|
||||||
|
scriptText = "cd "$PROJECT_DIR"/.. ./gen-artifacts.sh ios ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import MobileNebula
|
||||||
|
|
||||||
|
enum APIClientError: Error {
|
||||||
|
case invalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
class APIClient {
|
||||||
|
let apiClient: MobileNebulaAPIClient
|
||||||
|
let json = JSONDecoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let packageInfo = PackageInfo()
|
||||||
|
apiClient = MobileNebulaNewAPIClient("MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")!
|
||||||
|
}
|
||||||
|
|
||||||
|
func enroll(code: String) throws -> IncomingSite {
|
||||||
|
let res = try apiClient.enroll(code)
|
||||||
|
return try decodeIncomingSite(jsonSite: res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String) throws -> IncomingSite? {
|
||||||
|
let res: MobileNebulaTryUpdateResult
|
||||||
|
do {
|
||||||
|
res = try apiClient.tryUpdate(
|
||||||
|
siteName,
|
||||||
|
hostID: hostID,
|
||||||
|
privateKey: privateKey,
|
||||||
|
counter: counter,
|
||||||
|
trustedKeys: trustedKeys)
|
||||||
|
} catch {
|
||||||
|
// type information from Go is not available, use string matching instead
|
||||||
|
if (error.localizedDescription == "invalid credentials") {
|
||||||
|
throw APIClientError.invalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.fetchedUpdate) {
|
||||||
|
return try decodeIncomingSite(jsonSite: res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
|
||||||
|
do {
|
||||||
|
return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
|
||||||
|
} catch {
|
||||||
|
print("decodeIncomingSite: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,7 +2,7 @@ import UIKit
|
||||||
import Flutter
|
import Flutter
|
||||||
import MobileNebula
|
import MobileNebula
|
||||||
import NetworkExtension
|
import NetworkExtension
|
||||||
import MMWormhole
|
import SwiftyJSON
|
||||||
|
|
||||||
enum ChannelName {
|
enum ChannelName {
|
||||||
static let vpn = "net.defined.mobileNebula/NebulaVpnService"
|
static let vpn = "net.defined.mobileNebula/NebulaVpnService"
|
||||||
|
@ -14,8 +14,11 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
private let dnUpdater = DNUpdater()
|
||||||
|
private let apiClient = APIClient()
|
||||||
private var sites: Sites?
|
private var sites: Sites?
|
||||||
private var wormhole = MMWormhole(applicationGroupIdentifier: "group.net.defined.mobileNebula", optionalDirectory: "ipc")
|
private var ui: FlutterMethodChannel?
|
||||||
|
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
|
@ -23,21 +26,35 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
|
|
||||||
|
dnUpdater.updateAllLoop { site in
|
||||||
|
// Signal the site has changed in case the current site details screen is active
|
||||||
|
let container = self.sites?.getContainer(id: site.id)
|
||||||
|
if (container != nil) {
|
||||||
|
// Update references to the site with the new site config
|
||||||
|
container!.site = site
|
||||||
|
container!.updater.update(connected: site.connected ?? false, replaceSite: site)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal to the main screen to reload
|
||||||
|
self.ui?.invokeMethod("refreshSites", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
guard let controller = window?.rootViewController as? FlutterViewController else {
|
guard let controller = window?.rootViewController as? FlutterViewController else {
|
||||||
fatalError("rootViewController is not type FlutterViewController")
|
fatalError("rootViewController is not type FlutterViewController")
|
||||||
}
|
}
|
||||||
|
|
||||||
sites = Sites(messenger: controller.binaryMessenger)
|
sites = Sites(messenger: controller.binaryMessenger)
|
||||||
let channel = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
|
ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
|
||||||
|
|
||||||
NSKeyedUnarchiver.setClass(IPCMessage.classForKeyedUnarchiver(), forClassName: "NebulaNetworkExtension.IPCMessage")
|
ui!.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
||||||
wormhole.listenForMessage(withIdentifier: "nebula", listener: self.wormholeListener)
|
|
||||||
|
|
||||||
channel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
|
||||||
switch call.method {
|
switch call.method {
|
||||||
case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result)
|
case "nebula.parseCerts": return self.nebulaParseCerts(call: call, result: result)
|
||||||
case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result)
|
case "nebula.generateKeyPair": return self.nebulaGenerateKeyPair(result: result)
|
||||||
case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result)
|
case "nebula.renderConfig": return self.nebulaRenderConfig(call: call, result: result)
|
||||||
|
case "nebula.verifyCertAndKey": return self.nebulaVerifyCertAndKey(call: call, result: result)
|
||||||
|
|
||||||
|
case "dn.enroll": return self.dnEnroll(call: call, result: result)
|
||||||
|
|
||||||
case "listSites": return self.listSites(result: result)
|
case "listSites": return self.listSites(result: result)
|
||||||
case "deleteSite": return self.deleteSite(call: call, result: result)
|
case "deleteSite": return self.deleteSite(call: call, result: result)
|
||||||
|
@ -45,14 +62,11 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
case "startSite": return self.startSite(call: call, result: result)
|
case "startSite": return self.startSite(call: call, result: result)
|
||||||
case "stopSite": return self.stopSite(call: call, result: result)
|
case "stopSite": return self.stopSite(call: call, result: result)
|
||||||
|
|
||||||
case "active.listHostmap": self.activeListHostmap(call: call, result: result)
|
case "active.listHostmap": self.vpnRequest(command: "listHostmap", arguments: call.arguments, result: result)
|
||||||
case "active.listPendingHostmap": self.activeListPendingHostmap(call: call, result: result)
|
case "active.listPendingHostmap": self.vpnRequest(command: "listPendingHostmap", arguments: call.arguments, result: result)
|
||||||
case "active.getHostInfo": self.activeGetHostInfo(call: call, result: result)
|
case "active.getHostInfo": self.vpnRequest(command: "getHostInfo", arguments: call.arguments, result: result)
|
||||||
case "active.setRemoteForTunnel": self.activeSetRemoteForTunnel(call: call, result: result)
|
case "active.setRemoteForTunnel": self.vpnRequest(command: "setRemoteForTunnel", arguments: call.arguments, result: result)
|
||||||
case "active.closeTunnel": self.activeCloseTunnel(call: call, result: result)
|
case "active.closeTunnel": self.vpnRequest(command: "closeTunnel", arguments: call.arguments, result: result)
|
||||||
|
|
||||||
case "share": Share.share(call: call, result: result)
|
|
||||||
case "shareFile": Share.shareFile(call: call, result: result)
|
|
||||||
|
|
||||||
default:
|
default:
|
||||||
result(FlutterMethodNotImplemented)
|
result(FlutterMethodNotImplemented)
|
||||||
|
@ -75,6 +89,21 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
return result(json)
|
return result(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) {
|
||||||
|
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
||||||
|
guard let cert = args["cert"] else { return result(MissingArgumentError(message: "cert is a required argument")) }
|
||||||
|
guard let key = args["key"] else { return result(MissingArgumentError(message: "key is a required argument")) }
|
||||||
|
|
||||||
|
var err: NSError?
|
||||||
|
var validd: ObjCBool = false
|
||||||
|
let valid = MobileNebulaVerifyCertAndKey(cert, key, &validd, &err)
|
||||||
|
if (err != nil) {
|
||||||
|
return result(CallFailedError(message: "Error while verifying certificate and private key", details: err!.localizedDescription))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result(valid)
|
||||||
|
}
|
||||||
|
|
||||||
func nebulaGenerateKeyPair(result: FlutterResult) {
|
func nebulaGenerateKeyPair(result: FlutterResult) {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
let kp = MobileNebulaGenerateKeyPair(&err)
|
let kp = MobileNebulaGenerateKeyPair(&err)
|
||||||
|
@ -97,6 +126,25 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
return result(yaml)
|
return result(yaml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dnEnroll(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
guard let code = call.arguments as? String else { return result(NoArgumentsError()) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let site = try apiClient.enroll(code: code)
|
||||||
|
|
||||||
|
let oldSite = self.sites?.getSite(id: site.id)
|
||||||
|
site.save(manager: oldSite?.manager) { error in
|
||||||
|
if (error != nil) {
|
||||||
|
return result(CallFailedError(message: "Failed to enroll", details: error!.localizedDescription))
|
||||||
|
}
|
||||||
|
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return result(CallFailedError(message: "Error from DN api", details: error.localizedDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func listSites(result: @escaping FlutterResult) {
|
func listSites(result: @escaping FlutterResult) {
|
||||||
self.sites?.loadSites { (sites, err) -> () in
|
self.sites?.loadSites { (sites, err) -> () in
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
|
@ -136,19 +184,23 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
return result(CallFailedError(message: "Failed to save site", details: error!.localizedDescription))
|
return result(CallFailedError(message: "Failed to save site", details: error!.localizedDescription))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.sites?.loadSites { _, _ in
|
||||||
result(nil)
|
result(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
func startSite(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
||||||
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
|
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
|
||||||
|
|
||||||
#if targetEnvironment(simulator)
|
#if targetEnvironment(simulator)
|
||||||
let updater = self.sites?.getUpdater(id: id)
|
let updater = self.sites?.getUpdater(id: id)
|
||||||
updater?.update(connected: true)
|
updater?.update(connected: true)
|
||||||
|
|
||||||
#else
|
#else
|
||||||
let manager = self.sites?.getSite(id: id)?.manager
|
let container = self.sites?.getContainer(id: id)
|
||||||
|
let manager = container?.site.manager
|
||||||
|
|
||||||
manager?.loadFromPreferences{ error in
|
manager?.loadFromPreferences{ error in
|
||||||
//TODO: Handle load error
|
//TODO: Handle load error
|
||||||
// This is silly but we need to enable the site each time to avoid situations where folks have multiple sites
|
// This is silly but we need to enable the site each time to avoid situations where folks have multiple sites
|
||||||
|
@ -158,12 +210,13 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
manager?.loadFromPreferences{ error in
|
manager?.loadFromPreferences{ error in
|
||||||
//TODO: Handle load error
|
//TODO: Handle load error
|
||||||
do {
|
do {
|
||||||
try manager?.connection.startVPNTunnel()
|
container?.updater.startFunc = {() -> Void in
|
||||||
|
return self.vpnRequest(command: "start", arguments: args, result: result)
|
||||||
|
}
|
||||||
|
try manager?.connection.startVPNTunnel(options: ["expectStart": NSNumber(1)])
|
||||||
} catch {
|
} catch {
|
||||||
return result(CallFailedError(message: "Could not start site", details: error.localizedDescription))
|
return result(CallFailedError(message: "Could not start site", details: error.localizedDescription))
|
||||||
}
|
}
|
||||||
|
|
||||||
return result(nil)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -188,120 +241,46 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
func activeListHostmap(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
func vpnRequest(command: String, arguments: Any?, result: @escaping FlutterResult) {
|
||||||
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
guard let args = arguments as? Dictionary<String, Any> else { return result(NoArgumentsError()) }
|
||||||
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
|
|
||||||
//TODO: match id for safety?
|
|
||||||
wormholeRequestWithCallback(type: "listHostmap", arguments: nil) { (data, err) -> () in
|
|
||||||
if (err != nil) {
|
|
||||||
return result(CallFailedError(message: err!.localizedDescription))
|
|
||||||
}
|
|
||||||
|
|
||||||
result(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func activeListPendingHostmap(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
|
||||||
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
|
|
||||||
//TODO: match id for safety?
|
|
||||||
wormholeRequestWithCallback(type: "listPendingHostmap", arguments: nil) { (data, err) -> () in
|
|
||||||
if (err != nil) {
|
|
||||||
return result(CallFailedError(message: err!.localizedDescription))
|
|
||||||
}
|
|
||||||
|
|
||||||
result(data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func activeGetHostInfo(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
guard let args = call.arguments as? Dictionary<String, Any> else { return result(NoArgumentsError()) }
|
|
||||||
guard let id = args["id"] as? String else { return result(MissingArgumentError(message: "id is a required argument")) }
|
guard let id = args["id"] as? String else { return result(MissingArgumentError(message: "id is a required argument")) }
|
||||||
guard let vpnIp = args["vpnIp"] as? String else { return result(MissingArgumentError(message: "vpnIp is a required argument")) }
|
let container = sites?.getContainer(id: id)
|
||||||
let pending = args["pending"] as? Bool ?? false
|
|
||||||
|
|
||||||
//TODO: match id for safety?
|
if container == nil {
|
||||||
wormholeRequestWithCallback(type: "getHostInfo", arguments: ["vpnIp": vpnIp, "pending": pending]) { (data, err) -> () in
|
// No site for this id
|
||||||
if (err != nil) {
|
return result(nil)
|
||||||
return result(CallFailedError(message: err!.localizedDescription))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result(data)
|
if !(container!.site.connected ?? false) {
|
||||||
}
|
// Site isn't connected, no point in sending a command
|
||||||
|
return result(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
func activeSetRemoteForTunnel(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
if let session = container!.site.manager?.connection as? NETunnelProviderSession {
|
||||||
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
do {
|
||||||
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
|
try session.sendProviderMessage(try JSONEncoder().encode(IPCRequest(command: command, arguments: JSON(args)))) { data in
|
||||||
guard let vpnIp = args["vpnIp"] else { return result(MissingArgumentError(message: "vpnIp is a required argument")) }
|
if data == nil {
|
||||||
guard let addr = args["addr"] else { return result(MissingArgumentError(message: "addr is a required argument")) }
|
return result(nil)
|
||||||
|
|
||||||
//TODO: match id for safety?
|
|
||||||
wormholeRequestWithCallback(type: "setRemoteForTunnel", arguments: ["vpnIp": vpnIp, "addr": addr]) { (data, err) -> () in
|
|
||||||
if (err != nil) {
|
|
||||||
return result(CallFailedError(message: err!.localizedDescription))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result(data)
|
//print(String(decoding: data!, as: UTF8.self))
|
||||||
}
|
guard let res = try? JSONDecoder().decode(IPCResponse.self, from: data!) else {
|
||||||
|
return result(CallFailedError(message: "Failed to decode response"))
|
||||||
}
|
}
|
||||||
|
|
||||||
func activeCloseTunnel(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
if res.type == .success {
|
||||||
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
return result(res.message?.object)
|
||||||
guard let id = args["id"] else { return result(MissingArgumentError(message: "id is a required argument")) }
|
|
||||||
guard let vpnIp = args["vpnIp"] else { return result(MissingArgumentError(message: "vpnIp is a required argument")) }
|
|
||||||
|
|
||||||
//TODO: match id for safety?
|
|
||||||
wormholeRequestWithCallback(type: "closeTunnel", arguments: ["vpnIp": vpnIp]) { (data, err) -> () in
|
|
||||||
if (err != nil) {
|
|
||||||
return result(CallFailedError(message: err!.localizedDescription))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result(data as? Bool ?? false)
|
return result(CallFailedError(message: res.message?.debugDescription ?? "Failed to convert error"))
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
return result(CallFailedError(message: error.localizedDescription))
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
func wormholeListener(msg: Any?) {
|
//TODO: we have a site without a manager, things have gone weird. How to handle since this shouldn't happen?
|
||||||
guard let call = msg as? IPCMessage else {
|
result(nil)
|
||||||
print("Failed to decode IPCMessage from network extension")
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
switch call.type {
|
|
||||||
case "error":
|
|
||||||
guard let updater = self.sites?.getUpdater(id: call.id) else {
|
|
||||||
return print("Could not find site to deliver error to \(call.id): \(String(describing: call.message))")
|
|
||||||
}
|
|
||||||
updater.setError(err: call.message as! String)
|
|
||||||
|
|
||||||
default:
|
|
||||||
print("Unknown IPC message type \(call.type)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func wormholeRequestWithCallback(type: String, arguments: Dictionary<String, Any>?, completion: @escaping (Any?, Error?) -> ()) {
|
|
||||||
let uuid = UUID().uuidString
|
|
||||||
|
|
||||||
wormhole.listenForMessage(withIdentifier: uuid) { msg -> () in
|
|
||||||
self.wormhole.stopListeningForMessage(withIdentifier: uuid)
|
|
||||||
|
|
||||||
guard let call = msg as? IPCMessage else {
|
|
||||||
completion("", "Failed to decode IPCMessage callback from network extension")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch call.type {
|
|
||||||
case "error":
|
|
||||||
completion("", call.message as? String ?? "Failed to convert error")
|
|
||||||
case "success":
|
|
||||||
completion(call.message, nil)
|
|
||||||
|
|
||||||
default:
|
|
||||||
completion("", "Unknown IPC message type \(call.type)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wormhole.passMessageObject(IPCRequest(callbackId: uuid, type: type, arguments: arguments), identifier: "app")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<device id="retina6_0" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<scenes>
|
<scenes>
|
||||||
<!--Flutter View Controller-->
|
<!--Flutter View Controller-->
|
||||||
|
@ -14,13 +16,14 @@
|
||||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
</layoutGuides>
|
</layoutGuides>
|
||||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
<rect key="frame" x="0.0" y="0.0" width="390" height="844"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
|
<point key="canvasLocation" x="-16" y="-40"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class DNUpdater {
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes
|
||||||
|
|
||||||
|
func updateAll(onUpdate: @escaping (Site) -> ()) {
|
||||||
|
_ = SiteList{ (sites, _) -> () in
|
||||||
|
// NEVPN seems to force us onto the main thread and we are about to make network calls that
|
||||||
|
// could block for a while. Push ourselves onto another thread to avoid blocking the UI.
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
sites?.values.forEach { site in
|
||||||
|
if (site.connected == true) {
|
||||||
|
// The vpn service is in charge of updating the currently connected site
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.updateSite(site: site, onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllLoop(onUpdate: @escaping (Site) -> ()) {
|
||||||
|
timer.eventHandler = {
|
||||||
|
self.updateAll(onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> ()) {
|
||||||
|
timer.eventHandler = {
|
||||||
|
self.updateSite(site: site, onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSite(site: Site, onUpdate: @escaping (Site) -> ()) {
|
||||||
|
do {
|
||||||
|
if (!site.managed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials = try site.getDNCredentials()
|
||||||
|
|
||||||
|
let newSite: IncomingSite?
|
||||||
|
do {
|
||||||
|
newSite = try apiClient.tryUpdate(
|
||||||
|
siteName: site.name,
|
||||||
|
hostID: credentials.hostID,
|
||||||
|
privateKey: credentials.privateKey,
|
||||||
|
counter: credentials.counter,
|
||||||
|
trustedKeys: credentials.trustedKeys
|
||||||
|
)
|
||||||
|
} catch (APIClientError.invalidCredentials) {
|
||||||
|
if (!credentials.invalid) {
|
||||||
|
try site.invalidateDNCredentials()
|
||||||
|
print("Invalidated credentials in site \(site.name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newSite?.save(manager: nil) { error in
|
||||||
|
if (error != nil) {
|
||||||
|
print("failed to save update: \(error!.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
onUpdate(Site(incoming: newSite!))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.invalid) {
|
||||||
|
try site.validateDNCredentials()
|
||||||
|
print("Revalidated credentials in site \(site.name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Error while updating \(site.name): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9
|
||||||
|
class RepeatingTimer {
|
||||||
|
|
||||||
|
let timeInterval: TimeInterval
|
||||||
|
|
||||||
|
init(timeInterval: TimeInterval) {
|
||||||
|
self.timeInterval = timeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var timer: DispatchSourceTimer = {
|
||||||
|
let t = DispatchSource.makeTimerSource()
|
||||||
|
t.schedule(deadline: .now(), repeating: self.timeInterval)
|
||||||
|
t.setEventHandler(handler: { [weak self] in
|
||||||
|
self?.eventHandler?()
|
||||||
|
})
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
|
||||||
|
var eventHandler: (() -> Void)?
|
||||||
|
|
||||||
|
private enum State {
|
||||||
|
case suspended
|
||||||
|
case resumed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var state: State = .suspended
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer.setEventHandler {}
|
||||||
|
timer.cancel()
|
||||||
|
/*
|
||||||
|
If the timer is suspended, calling cancel without resuming
|
||||||
|
triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902
|
||||||
|
*/
|
||||||
|
resume()
|
||||||
|
eventHandler = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
if state == .resumed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .resumed
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func suspend() {
|
||||||
|
if state == .suspended {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .suspended
|
||||||
|
timer.suspend()
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
@ -18,8 +20,23 @@
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>mailto</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>mailto</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>FlutterDeepLinkingEnabled</key>
|
||||||
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PackageInfo {
|
||||||
|
func getVersion() -> String {
|
||||||
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ??
|
||||||
|
"unknown"
|
||||||
|
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
|
||||||
|
|
||||||
|
if (buildNumber == nil) {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(version)-\(buildNumber!)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getName() -> String {
|
||||||
|
return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ??
|
||||||
|
Bundle.main.infoDictionary?["CFBundleName"] as? String ??
|
||||||
|
"Nebula"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSystemVersion() -> String {
|
||||||
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,10 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:api.defined.net</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.networking.networkextension</key>
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
<array>
|
<array>
|
||||||
<string>packet-tunnel-provider</string>
|
<string>packet-tunnel-provider</string>
|
||||||
|
|
|
@ -1,150 +0,0 @@
|
||||||
// Basis of this code comes from https://github.com/lubritto/flutter_share
|
|
||||||
|
|
||||||
import Flutter
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class Share {
|
|
||||||
public static func share(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
let args = call.arguments as? [String: Any?]
|
|
||||||
|
|
||||||
let title = args!["title"] as? String
|
|
||||||
let text = args!["text"] as? String
|
|
||||||
let filename = args!["filename"] as? String
|
|
||||||
let tmpDirURL = FileManager.default.temporaryDirectory
|
|
||||||
|
|
||||||
if (filename == nil || filename!.isEmpty) {
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmpFile = tmpDirURL.appendingPathComponent(filename!)
|
|
||||||
do {
|
|
||||||
try text?.write(to: tmpFile, atomically: true, encoding: .utf8)
|
|
||||||
} catch {
|
|
||||||
//TODO: return error
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(title: title, file: tmpFile) { pass in
|
|
||||||
let fm = FileManager()
|
|
||||||
do {
|
|
||||||
try fm.removeItem(at: tmpFile)
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return result(pass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func shareFile(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
let args = call.arguments as? [String: Any?]
|
|
||||||
|
|
||||||
let title = args!["title"] as? String
|
|
||||||
let filePath = args!["filePath"] as? String
|
|
||||||
let filename = args!["filename"] as? String
|
|
||||||
|
|
||||||
if (filePath == nil || filePath!.isEmpty) {
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmpFile: URL?
|
|
||||||
let fm = FileManager()
|
|
||||||
var realPath = URL(fileURLWithPath: filePath!)
|
|
||||||
|
|
||||||
if (filename != nil && !filename!.isEmpty) {
|
|
||||||
tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename!)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try fm.linkItem(at: URL(fileURLWithPath: filePath!), to: tmpFile!)
|
|
||||||
} catch {
|
|
||||||
//TODO: return error
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
realPath = tmpFile!
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(title: title, file: realPath) { pass in
|
|
||||||
if (tmpFile != nil) {
|
|
||||||
do {
|
|
||||||
try fm.removeItem(at: tmpFile!)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
result(pass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func pop(title: String?, file: URL, completion: @escaping ((Bool) -> Void)) {
|
|
||||||
if (title == nil || title!.isEmpty) {
|
|
||||||
return completion(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
let activityViewController = UIActivityViewController(activityItems: [ShareCopy(file: file)], applicationActivities: nil)
|
|
||||||
|
|
||||||
activityViewController.completionWithItemsHandler = {(activityType: UIActivity.ActivityType?, completed: Bool, returnedItems: [Any]?, error: Error?) in
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject
|
|
||||||
activityViewController.setValue(title, forKeyPath: "subject")
|
|
||||||
|
|
||||||
// For iPads, fix issue where Exception is thrown by using a popup instead
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
activityViewController.popoverPresentationController?.sourceView = UIApplication.topViewController()?.view
|
|
||||||
if let view = UIApplication.topViewController()?.view {
|
|
||||||
activityViewController.popoverPresentationController?.permittedArrowDirections = []
|
|
||||||
activityViewController.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.topViewController()?.present(activityViewController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIApplication {
|
|
||||||
class func topViewController(controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
|
|
||||||
if let navigationController = controller as? UINavigationController {
|
|
||||||
return topViewController(controller: navigationController.visibleViewController)
|
|
||||||
}
|
|
||||||
if let tabController = controller as? UITabBarController {
|
|
||||||
if let selected = tabController.selectedViewController {
|
|
||||||
return topViewController(controller: selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let presented = controller?.presentedViewController {
|
|
||||||
return topViewController(controller: presented)
|
|
||||||
}
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShareCopy: UIActivityItemProvider {
|
|
||||||
private let file: URL
|
|
||||||
private let content: String
|
|
||||||
|
|
||||||
init(file: URL) {
|
|
||||||
self.file = file
|
|
||||||
do {
|
|
||||||
self.content = try String.init(contentsOf: file)
|
|
||||||
} catch {
|
|
||||||
self.content = "Error"
|
|
||||||
}
|
|
||||||
|
|
||||||
// the type of the placeholder item is used to
|
|
||||||
// display correct activity types by UIActivityControler
|
|
||||||
super.init(placeholderItem: self.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
override var item: Any {
|
|
||||||
get {
|
|
||||||
guard let activityType = activityType else {
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
switch activityType {
|
|
||||||
case .copyToPasteboard: return content
|
|
||||||
default: return file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,7 @@ class SiteContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Sites {
|
class Sites {
|
||||||
private var sites = [String: SiteContainer]()
|
private var containers = [String: SiteContainer]()
|
||||||
private var messenger: FlutterBinaryMessenger?
|
private var messenger: FlutterBinaryMessenger?
|
||||||
|
|
||||||
init(messenger: FlutterBinaryMessenger?) {
|
init(messenger: FlutterBinaryMessenger?) {
|
||||||
|
@ -20,77 +20,44 @@ class Sites {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
#if targetEnvironment(simulator)
|
_ = SiteList { (sites, err) in
|
||||||
let fileManager = FileManager.default
|
|
||||||
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites")
|
|
||||||
var configPaths: [URL]
|
|
||||||
|
|
||||||
do {
|
|
||||||
if (!fileManager.fileExists(atPath: documentsURL.absoluteString)) {
|
|
||||||
try fileManager.createDirectory(at: documentsURL, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
configPaths = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
|
||||||
} catch {
|
|
||||||
return completion(nil, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
configPaths.forEach { path in
|
|
||||||
do {
|
|
||||||
let config = try Data(contentsOf: path)
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let incoming = try decoder.decode(IncomingSite.self, from: config)
|
|
||||||
let site = try Site(incoming: incoming)
|
|
||||||
let updater = SiteUpdater(messenger: self.messenger!, site: site)
|
|
||||||
self.sites[site.id] = SiteContainer(site: site, updater: updater)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
// try? fileManager.removeItem(at: path)
|
|
||||||
print("Deleted non conforming site \(path)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let justSites = self.sites.mapValues {
|
|
||||||
return $0.site
|
|
||||||
}
|
|
||||||
completion(justSites, nil)
|
|
||||||
|
|
||||||
#else
|
|
||||||
NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in
|
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
return completion(nil, err)
|
return completion(nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newManagers?.forEach { manager in
|
sites?.values.forEach{ site in
|
||||||
do {
|
var updater = self.containers[site.id]?.updater
|
||||||
let site = try Site(manager: manager)
|
if (updater != nil) {
|
||||||
// Load the private key to make sure we can
|
updater!.setSite(site: site)
|
||||||
_ = try site.getKey()
|
} else {
|
||||||
let updater = SiteUpdater(messenger: self.messenger!, site: site)
|
updater = SiteUpdater(messenger: self.messenger!, site: site)
|
||||||
self.sites[site.id] = SiteContainer(site: site, updater: updater)
|
|
||||||
} catch {
|
|
||||||
//TODO: notify the user about this
|
|
||||||
print("Deleted non conforming site \(manager) \(error)")
|
|
||||||
manager.removeFromPreferences()
|
|
||||||
}
|
}
|
||||||
|
self.containers[site.id] = SiteContainer(site: site, updater: updater!)
|
||||||
}
|
}
|
||||||
|
|
||||||
let justSites = self.sites.mapValues {
|
let justSites = self.containers.mapValues {
|
||||||
return $0.site
|
return $0.site
|
||||||
}
|
}
|
||||||
completion(justSites, nil)
|
completion(justSites, nil)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
|
func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
|
||||||
if let site = self.sites.removeValue(forKey: id) {
|
if let site = self.containers.removeValue(forKey: id) {
|
||||||
#if targetEnvironment(simulator)
|
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
|
||||||
|
_ = KeyChain.delete(key: "\(site.site.id).key")
|
||||||
|
|
||||||
|
do {
|
||||||
let fileManager = FileManager.default
|
let fileManager = FileManager.default
|
||||||
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(site.site.id)
|
let siteDir = try SiteList.getSiteDir(id: site.site.id)
|
||||||
try? fileManager.removeItem(at: sitePath)
|
try fileManager.removeItem(at: siteDir)
|
||||||
#else
|
} catch {
|
||||||
_ = KeyChain.delete(key: site.site.id)
|
print("Failed to delete site from fs: \(error.localizedDescription)")
|
||||||
site.site.manager.removeFromPreferences(completionHandler: callback)
|
}
|
||||||
|
|
||||||
|
#if !targetEnvironment(simulator)
|
||||||
|
site.site.manager!.removeFromPreferences(completionHandler: callback)
|
||||||
|
return
|
||||||
#endif
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,11 +66,15 @@ class Sites {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSite(id: String) -> Site? {
|
func getSite(id: String) -> Site? {
|
||||||
return self.sites[id]?.site
|
return self.containers[id]?.site
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUpdater(id: String) -> SiteUpdater? {
|
func getUpdater(id: String) -> SiteUpdater? {
|
||||||
return self.sites[id]?.updater
|
return self.containers[id]?.updater
|
||||||
|
}
|
||||||
|
|
||||||
|
func getContainer(id: String) -> SiteContainer? {
|
||||||
|
return self.containers[id]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -112,40 +83,74 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
|
||||||
private var eventChannel: FlutterEventChannel;
|
private var eventChannel: FlutterEventChannel;
|
||||||
private var site: Site
|
private var site: Site
|
||||||
private var notification: Any?
|
private var notification: Any?
|
||||||
|
public var startFunc: (() -> Void)?
|
||||||
|
private var configFd: Int32? = nil
|
||||||
|
private var configObserver: DispatchSourceFileSystemObject? = nil
|
||||||
|
|
||||||
init(messenger: FlutterBinaryMessenger, site: Site) {
|
init(messenger: FlutterBinaryMessenger, site: Site) {
|
||||||
|
do {
|
||||||
|
let configPath = try SiteList.getSiteConfigFile(id: site.id, createDir: false)
|
||||||
|
self.configFd = open(configPath.path, O_EVTONLY)
|
||||||
|
self.configObserver = DispatchSource.makeFileSystemObjectSource(
|
||||||
|
fileDescriptor: self.configFd!,
|
||||||
|
eventMask: .write
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
// SiteList.getSiteConfigFile should never throw because we are not creating it here
|
||||||
|
self.configObserver = nil
|
||||||
|
}
|
||||||
|
|
||||||
eventChannel = FlutterEventChannel(name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
|
eventChannel = FlutterEventChannel(name: "net.defined.nebula/\(site.id)", binaryMessenger: messenger)
|
||||||
self.site = site
|
self.site = site
|
||||||
super.init()
|
super.init()
|
||||||
|
|
||||||
eventChannel.setStreamHandler(self)
|
eventChannel.setStreamHandler(self)
|
||||||
|
|
||||||
|
self.configObserver?.setEventHandler(handler: self.configUpdated)
|
||||||
|
self.configObserver?.setCancelHandler {
|
||||||
|
if self.configFd != nil {
|
||||||
|
close(self.configFd!)
|
||||||
|
}
|
||||||
|
self.configObserver = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.configObserver?.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func setSite(site: Site) {
|
||||||
|
self.site = site
|
||||||
|
}
|
||||||
|
|
||||||
|
/// onListen is called when flutter code attaches an event listener
|
||||||
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
|
||||||
eventSink = events;
|
eventSink = events;
|
||||||
|
|
||||||
self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager.connection , queue: nil) { _ in
|
#if !targetEnvironment(simulator)
|
||||||
|
if site.manager == nil {
|
||||||
self.site.status = statusString[self.site.manager.connection.status]
|
//TODO: The dn updater path seems to race to build a site that lacks a manager. The UI does not display this error
|
||||||
self.site.connected = statusMap[self.site.manager.connection.status]
|
// and a another listen should occur and succeed.
|
||||||
|
return FlutterError(code: "Internal Error", message: "Flutter manager was not present", details: nil)
|
||||||
let d: Dictionary<String, Any> = [
|
|
||||||
"connected": self.site.connected!,
|
|
||||||
"status": self.site.status!,
|
|
||||||
]
|
|
||||||
self.eventSink?(d)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in
|
||||||
|
let oldConnected = self.site.connected
|
||||||
|
self.site.status = statusString[self.site.manager!.connection.status]
|
||||||
|
self.site.connected = statusMap[self.site.manager!.connection.status]
|
||||||
|
|
||||||
|
// Check to see if we just moved to connected and if we have a start function to call when that happens
|
||||||
|
if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
|
||||||
|
self.startFunc!()
|
||||||
|
self.startFunc = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update(connected: self.site.connected!)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func setError(err: String) {
|
/// onCancel is called when the flutter listener stops listening
|
||||||
let d: Dictionary<String, Any> = [
|
|
||||||
"connected": self.site.connected!,
|
|
||||||
"status": self.site.status!,
|
|
||||||
]
|
|
||||||
self.eventSink?(FlutterError(code: "", message: err, details: d))
|
|
||||||
}
|
|
||||||
|
|
||||||
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
func onCancel(withArguments arguments: Any?) -> FlutterError? {
|
||||||
if (self.notification != nil) {
|
if (self.notification != nil) {
|
||||||
NotificationCenter.default.removeObserver(self.notification!)
|
NotificationCenter.default.removeObserver(self.notification!)
|
||||||
|
@ -153,11 +158,28 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func update(connected: Bool) {
|
/// update is a way to send information to the flutter listener and generally should not be used directly
|
||||||
let d: Dictionary<String, Any> = [
|
func update(connected: Bool, replaceSite: Site? = nil) {
|
||||||
"connected": connected,
|
if (replaceSite != nil) {
|
||||||
"status": connected ? "Connected" : "Disconnected",
|
site = replaceSite!
|
||||||
]
|
}
|
||||||
self.eventSink?(d)
|
site.connected = connected
|
||||||
|
site.status = connected ? "Connected" : "Disconnected"
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try! encoder.encode(site)
|
||||||
|
self.eventSink?(String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configUpdated() {
|
||||||
|
if self.site.connected != true {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let newSite = try? Site(manager: self.site.manager!) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update(connected: newSite.connected ?? false, replaceSite: newSite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
app_identifier("net.defined.mobileNebula") # The bundle identifier of your app
|
||||||
|
itc_team_id("633953") # App Store Connect Team ID
|
||||||
|
team_id("576H3XS7FP") # Developer Portal Team ID
|
||||||
|
|
||||||
|
# For more information about the Appfile, see:
|
||||||
|
# https://docs.fastlane.tools/advanced/#appfile
|
|
@ -0,0 +1,84 @@
|
||||||
|
# This file contains the fastlane.tools configuration
|
||||||
|
# You can find the documentation at https://docs.fastlane.tools
|
||||||
|
#
|
||||||
|
# For a list of all available actions, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/actions
|
||||||
|
#
|
||||||
|
# For a list of all available plugins, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/plugins/available-plugins
|
||||||
|
#
|
||||||
|
|
||||||
|
# Uncomment the line if you want fastlane to automatically update itself
|
||||||
|
# update_fastlane
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Push a new beta build to TestFlight"
|
||||||
|
|
||||||
|
lane :build do
|
||||||
|
# Do some things like setting up a temporary keystore to host secrets in CI
|
||||||
|
setup_ci
|
||||||
|
|
||||||
|
# # Authenticate with Apple app store connect
|
||||||
|
# app_store_connect_api_key
|
||||||
|
|
||||||
|
# Change signing behavior to work in CI
|
||||||
|
update_code_signing_settings(
|
||||||
|
# Automatic signing seems to be a good thing to have on in dev but will not work in CI
|
||||||
|
use_automatic_signing: false,
|
||||||
|
# The default value for this is iOS Development which is not appropriate for release
|
||||||
|
code_sign_identity: "Apple Distribution",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find our signing certs and profiles, these come from a private repository and managed by `fastlane match`
|
||||||
|
match(type: 'appstore', app_identifier: ["net.defined.mobileNebula","net.defined.mobileNebula.NebulaNetworkExtension"], readonly: true)
|
||||||
|
|
||||||
|
# Update our main program to have the correct provisioning profile from Apple
|
||||||
|
update_project_provisioning(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
target_filter: "Runner",
|
||||||
|
# This comes from match() above
|
||||||
|
profile:ENV["sigh_net.defined.mobileNebula_appstore_profile-path"],
|
||||||
|
build_configuration: "Release"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update our network extension to have the correct provisioning profile from Apple
|
||||||
|
update_project_provisioning(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
target_filter: "NebulaNetworkExtension",
|
||||||
|
# This comes from match() above
|
||||||
|
profile:ENV["sigh_net.defined.mobileNebula.NebulaNetworkExtension_appstore_profile-path"],
|
||||||
|
build_configuration: "Release"
|
||||||
|
)
|
||||||
|
|
||||||
|
increment_build_number(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
build_number: ENV['BUILD_NUMBER']
|
||||||
|
)
|
||||||
|
|
||||||
|
increment_version_number(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
version_number: ENV['BUILD_NAME']
|
||||||
|
)
|
||||||
|
|
||||||
|
build_app(
|
||||||
|
output_name: "MobileNebula.ipa",
|
||||||
|
workspace: "Runner.xcworkspace",
|
||||||
|
scheme: "Runner",
|
||||||
|
export_method: "app-store",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
lane :release do
|
||||||
|
# Do some things like setting up a temporary keystore to host secrets in CI
|
||||||
|
setup_ci
|
||||||
|
|
||||||
|
# Authenticate with Apple app store connect
|
||||||
|
app_store_connect_api_key
|
||||||
|
|
||||||
|
upload_to_testflight(skip_waiting_for_build_processing: true)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,14 @@
|
||||||
|
git_url("https://github.com/DefinedNet/mobile_nebula_match.git")
|
||||||
|
|
||||||
|
storage_mode("git")
|
||||||
|
|
||||||
|
type("appstore") # The default type, can be: appstore, adhoc, enterprise or development
|
||||||
|
|
||||||
|
app_identifier(["net.defined.mobileNebula", "net.defined.mobileNebula.NebulaNetworkExtension"])
|
||||||
|
|
||||||
|
# username("user@fastlane.tools") # Your Apple Developer Portal username
|
||||||
|
|
||||||
|
# For all available options run `fastlane match --help`
|
||||||
|
# Remove the # in the beginning of the line to enable the other options
|
||||||
|
|
||||||
|
# The docs are available on https://docs.fastlane.tools/actions/match
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
import 'package:mobile_nebula/models/CIDR.dart';
|
import 'package:mobile_nebula/models/CIDR.dart';
|
||||||
import '../services/utils.dart';
|
import '../services/utils.dart';
|
||||||
|
@ -10,7 +8,7 @@ import 'IPField.dart';
|
||||||
//TODO: Support initialValue
|
//TODO: Support initialValue
|
||||||
class CIDRField extends StatefulWidget {
|
class CIDRField extends StatefulWidget {
|
||||||
const CIDRField({
|
const CIDRField({
|
||||||
Key key,
|
Key? key,
|
||||||
this.ipHelp = "ip address",
|
this.ipHelp = "ip address",
|
||||||
this.autoFocus = false,
|
this.autoFocus = false,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
|
@ -23,12 +21,12 @@ class CIDRField extends StatefulWidget {
|
||||||
|
|
||||||
final String ipHelp;
|
final String ipHelp;
|
||||||
final bool autoFocus;
|
final bool autoFocus;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final ValueChanged<CIDR> onChanged;
|
final ValueChanged<CIDR>? onChanged;
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController bitsController;
|
final TextEditingController? bitsController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_CIDRFieldState createState() => _CIDRFieldState();
|
_CIDRFieldState createState() => _CIDRFieldState();
|
||||||
|
@ -46,7 +44,7 @@ class _CIDRFieldState extends State<CIDRField> {
|
||||||
void initState() {
|
void initState() {
|
||||||
//TODO: this won't track external controller changes appropriately
|
//TODO: this won't track external controller changes appropriately
|
||||||
cidr.ip = widget.ipController?.text ?? "";
|
cidr.ip = widget.ipController?.text ?? "";
|
||||||
cidr.bits = int.tryParse(widget.bitsController?.text ?? "");
|
cidr.bits = int.tryParse(widget.bitsController?.text ?? "") ?? 0;
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,8 +66,12 @@ class _CIDRFieldState extends State<CIDRField> {
|
||||||
focusNode: widget.focusNode,
|
focusNode: widget.focusNode,
|
||||||
nextFocusNode: bitsFocus,
|
nextFocusNode: bitsFocus,
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
|
if (widget.onChanged == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
cidr.ip = val;
|
cidr.ip = val;
|
||||||
widget.onChanged(cidr);
|
widget.onChanged!(cidr);
|
||||||
},
|
},
|
||||||
controller: widget.ipController,
|
controller: widget.ipController,
|
||||||
))),
|
))),
|
||||||
|
@ -83,11 +85,15 @@ class _CIDRFieldState extends State<CIDRField> {
|
||||||
nextFocusNode: widget.nextFocusNode,
|
nextFocusNode: widget.nextFocusNode,
|
||||||
controller: widget.bitsController,
|
controller: widget.bitsController,
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
cidr.bits = int.tryParse(val ?? "");
|
if (widget.onChanged == null) {
|
||||||
widget.onChanged(cidr);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cidr.bits = int.tryParse(val) ?? 0;
|
||||||
|
widget.onChanged!(cidr);
|
||||||
},
|
},
|
||||||
maxLength: 2,
|
maxLength: 2,
|
||||||
inputFormatters: [WhitelistingTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
textInputAction: widget.textInputAction ?? TextInputAction.done,
|
textInputAction: widget.textInputAction ?? TextInputAction.done,
|
||||||
placeholder: 'bits',
|
placeholder: 'bits',
|
||||||
))
|
))
|
||||||
|
|
|
@ -1,20 +1,20 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/CIDRField.dart';
|
import 'package:mobile_nebula/components/CIDRField.dart';
|
||||||
import 'package:mobile_nebula/models/CIDR.dart';
|
import 'package:mobile_nebula/models/CIDR.dart';
|
||||||
import 'package:mobile_nebula/validators/ipValidator.dart';
|
import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
|
|
||||||
class CIDRFormField extends FormField<CIDR> {
|
class CIDRFormField extends FormField<CIDR> {
|
||||||
//TODO: onSaved, validator, autovalidate, enabled?
|
//TODO: onSaved, validator, auto-validate, enabled?
|
||||||
CIDRFormField({
|
CIDRFormField({
|
||||||
Key key,
|
Key? key,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
|
enableIPV6 = false,
|
||||||
focusNode,
|
focusNode,
|
||||||
nextFocusNode,
|
nextFocusNode,
|
||||||
ValueChanged<CIDR> onChanged,
|
ValueChanged<CIDR>? onChanged,
|
||||||
FormFieldSetter<CIDR> onSaved,
|
FormFieldSetter<CIDR>? onSaved,
|
||||||
textInputAction,
|
textInputAction,
|
||||||
CIDR initialValue,
|
CIDR? initialValue,
|
||||||
this.ipController,
|
this.ipController,
|
||||||
this.bitsController,
|
this.bitsController,
|
||||||
}) : super(
|
}) : super(
|
||||||
|
@ -26,18 +26,18 @@ class CIDRFormField extends FormField<CIDR> {
|
||||||
return "Please fill out this field";
|
return "Please fill out this field";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ipValidator(cidr.ip)) {
|
if (!ipValidator(cidr.ip, enableIPV6)) {
|
||||||
return 'Please enter a valid ip address';
|
return 'Please enter a valid ip address';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cidr.bits == null || cidr.bits > 32 || cidr.bits < 0) {
|
if (cidr.bits > 32 || cidr.bits < 0) {
|
||||||
return "Please enter a valid number of bits";
|
return "Please enter a valid number of bits";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<CIDR> field) {
|
builder: (FormFieldState<CIDR> field) {
|
||||||
final _CIDRFormField state = field;
|
final _CIDRFormField state = field as _CIDRFormField;
|
||||||
|
|
||||||
void onChangedHandler(CIDR value) {
|
void onChangedHandler(CIDR value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -57,50 +57,50 @@ class CIDRFormField extends FormField<CIDR> {
|
||||||
bitsController: state._effectiveBitsController,
|
bitsController: state._effectiveBitsController,
|
||||||
),
|
),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(field.errorText,
|
? Text(field.errorText ?? "Unknown error",
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
||||||
textAlign: TextAlign.end)
|
textAlign: TextAlign.end)
|
||||||
: Container(height: 0)
|
: Container(height: 0)
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController bitsController;
|
final TextEditingController? bitsController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_CIDRFormField createState() => _CIDRFormField();
|
_CIDRFormField createState() => _CIDRFormField();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CIDRFormField extends FormFieldState<CIDR> {
|
class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
TextEditingController _ipController;
|
TextEditingController? _ipController = TextEditingController();
|
||||||
TextEditingController _bitsController;
|
TextEditingController? _bitsController = TextEditingController();
|
||||||
|
|
||||||
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController;
|
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController!;
|
||||||
TextEditingController get _effectiveBitsController => widget.bitsController ?? _bitsController;
|
TextEditingController get _effectiveBitsController => widget.bitsController ?? _bitsController!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CIDRFormField get widget => super.widget;
|
CIDRFormField get widget => super.widget as CIDRFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.ipController == null) {
|
if (widget.ipController == null) {
|
||||||
_ipController = TextEditingController(text: widget.initialValue.ip);
|
_ipController = TextEditingController(text: widget.initialValue?.ip);
|
||||||
} else {
|
} else {
|
||||||
widget.ipController.addListener(_handleControllerChanged);
|
widget.ipController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.bitsController == null) {
|
if (widget.bitsController == null) {
|
||||||
_bitsController = TextEditingController(text: widget.initialValue?.bits?.toString() ?? "");
|
_bitsController = TextEditingController(text: widget.initialValue?.bits.toString() ?? "");
|
||||||
} else {
|
} else {
|
||||||
widget.bitsController.addListener(_handleControllerChanged);
|
widget.bitsController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(CIDRFormField oldWidget) {
|
void didUpdateWidget(CIDRFormField oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
var update = CIDR(ip: widget.ipController?.text, bits: int.tryParse(widget.bitsController?.text ?? "") ?? null);
|
var update = CIDR(ip: widget.ipController?.text ?? "", bits: int.tryParse(widget.bitsController?.text ?? "") ?? 0);
|
||||||
bool shouldUpdate = false;
|
bool shouldUpdate = false;
|
||||||
|
|
||||||
if (widget.ipController != oldWidget.ipController) {
|
if (widget.ipController != oldWidget.ipController) {
|
||||||
|
@ -108,12 +108,12 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
widget.ipController?.addListener(_handleControllerChanged);
|
widget.ipController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.ipController != null && widget.ipController == null) {
|
if (oldWidget.ipController != null && widget.ipController == null) {
|
||||||
_ipController = TextEditingController.fromValue(oldWidget.ipController.value);
|
_ipController = TextEditingController.fromValue(oldWidget.ipController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.ipController != null) {
|
if (widget.ipController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.ip = widget.ipController.text;
|
update.ip = widget.ipController!.text;
|
||||||
if (oldWidget.ipController == null) _ipController = null;
|
if (oldWidget.ipController == null) _ipController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -123,12 +123,12 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
widget.bitsController?.addListener(_handleControllerChanged);
|
widget.bitsController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.bitsController != null && widget.bitsController == null) {
|
if (oldWidget.bitsController != null && widget.bitsController == null) {
|
||||||
_bitsController = TextEditingController.fromValue(oldWidget.bitsController.value);
|
_bitsController = TextEditingController.fromValue(oldWidget.bitsController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.bitsController != null) {
|
if (widget.bitsController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.bits = int.parse(widget.bitsController.text);
|
update.bits = int.parse(widget.bitsController!.text);
|
||||||
if (oldWidget.bitsController == null) _bitsController = null;
|
if (oldWidget.bitsController == null) _bitsController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -149,8 +149,8 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveIPController.text = widget.initialValue.ip;
|
_effectiveIPController.text = widget.initialValue?.ip ?? "";
|
||||||
_effectiveBitsController.text = widget.initialValue.bits.toString();
|
_effectiveBitsController.text = widget.initialValue?.bits.toString() ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -163,7 +163,11 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
// example, the reset() method. In such cases, the FormField value will
|
// example, the reset() method. In such cases, the FormField value will
|
||||||
// already have been set.
|
// already have been set.
|
||||||
final effectiveBits = int.parse(_effectiveBitsController.text);
|
final effectiveBits = int.parse(_effectiveBitsController.text);
|
||||||
if (_effectiveIPController.text != value.ip || effectiveBits != value.bits) {
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_effectiveIPController.text != value!.ip || effectiveBits != value!.bits) {
|
||||||
didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits));
|
didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,19 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
/// SimplePage with a form and built in validation and confirmation to discard changes if any are made
|
/// SimplePage with a form and built in validation and confirmation to discard changes if any are made
|
||||||
class FormPage extends StatefulWidget {
|
class FormPage extends StatefulWidget {
|
||||||
const FormPage(
|
const FormPage(
|
||||||
{Key key, this.title, @required this.child, @required this.onSave, @required this.changed, this.hideSave = false})
|
{Key? key,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
required this.onSave,
|
||||||
|
required this.changed,
|
||||||
|
this.hideSave = false,
|
||||||
|
this.scrollController})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final Function onSave;
|
final Function onSave;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
/// If you need the page to progress to a certain point before saving, control it here
|
/// If you need the page to progress to a certain point before saving, control it here
|
||||||
final bool hideSave;
|
final bool hideSave;
|
||||||
|
@ -50,7 +57,8 @@ class _FormPageState extends State<FormPage> {
|
||||||
child: SimplePage(
|
child: SimplePage(
|
||||||
leadingAction: _buildLeader(context),
|
leadingAction: _buildLeader(context),
|
||||||
trailingActions: _buildTrailer(context),
|
trailingActions: _buildTrailer(context),
|
||||||
title: widget.title,
|
scrollController: widget.scrollController,
|
||||||
|
title: Text(widget.title),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
onChanged: () => setState(() {
|
onChanged: () => setState(() {
|
||||||
|
@ -82,11 +90,15 @@ class _FormPageState extends State<FormPage> {
|
||||||
Utils.trailingSaveWidget(
|
Utils.trailingSaveWidget(
|
||||||
context,
|
context,
|
||||||
() {
|
() {
|
||||||
if (!_formKey.currentState.validate()) {
|
if (_formKey.currentState == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_formKey.currentState.save();
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formKey.currentState!.save();
|
||||||
widget.onSave();
|
widget.onSave();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
import 'package:mobile_nebula/models/IPAndPort.dart';
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import '../services/utils.dart';
|
import '../services/utils.dart';
|
||||||
|
@ -10,13 +8,13 @@ import 'IPField.dart';
|
||||||
//TODO: Support initialValue
|
//TODO: Support initialValue
|
||||||
class IPAndPortField extends StatefulWidget {
|
class IPAndPortField extends StatefulWidget {
|
||||||
const IPAndPortField({
|
const IPAndPortField({
|
||||||
Key key,
|
Key? key,
|
||||||
this.ipOnly = false,
|
this.ipOnly = false,
|
||||||
this.ipHelp = "ip address",
|
this.ipHelp = "ip address",
|
||||||
this.autoFocus = false,
|
this.autoFocus = false,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
this.nextFocusNode,
|
this.nextFocusNode,
|
||||||
this.onChanged,
|
required this.onChanged,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
this.noBorder = false,
|
this.noBorder = false,
|
||||||
this.ipTextAlign,
|
this.ipTextAlign,
|
||||||
|
@ -27,14 +25,14 @@ class IPAndPortField extends StatefulWidget {
|
||||||
final String ipHelp;
|
final String ipHelp;
|
||||||
final bool ipOnly;
|
final bool ipOnly;
|
||||||
final bool autoFocus;
|
final bool autoFocus;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final ValueChanged<IPAndPort> onChanged;
|
final ValueChanged<IPAndPort> onChanged;
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final bool noBorder;
|
final bool noBorder;
|
||||||
final TextAlign ipTextAlign;
|
final TextAlign? ipTextAlign;
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController portController;
|
final TextEditingController? portController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_IPAndPortFieldState createState() => _IPAndPortFieldState();
|
_IPAndPortFieldState createState() => _IPAndPortFieldState();
|
||||||
|
@ -89,11 +87,11 @@ class _IPAndPortFieldState extends State<IPAndPortField> {
|
||||||
nextFocusNode: widget.nextFocusNode,
|
nextFocusNode: widget.nextFocusNode,
|
||||||
controller: widget.portController,
|
controller: widget.portController,
|
||||||
onChanged: (val) {
|
onChanged: (val) {
|
||||||
_ipAndPort.port = int.tryParse(val ?? "");
|
_ipAndPort.port = int.tryParse(val);
|
||||||
widget.onChanged(_ipAndPort);
|
widget.onChanged(_ipAndPort);
|
||||||
},
|
},
|
||||||
maxLength: 5,
|
maxLength: 5,
|
||||||
inputFormatters: [WhitelistingTextInputFormatter.digitsOnly],
|
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
placeholder: 'port',
|
placeholder: 'port',
|
||||||
))
|
))
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/models/IPAndPort.dart';
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
||||||
import 'package:mobile_nebula/validators/ipValidator.dart';
|
import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
|
@ -7,18 +6,19 @@ import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
import 'IPAndPortField.dart';
|
import 'IPAndPortField.dart';
|
||||||
|
|
||||||
class IPAndPortFormField extends FormField<IPAndPort> {
|
class IPAndPortFormField extends FormField<IPAndPort> {
|
||||||
//TODO: onSaved, validator, autovalidate, enabled?
|
//TODO: onSaved, validator, auto-validate, enabled?
|
||||||
IPAndPortFormField({
|
IPAndPortFormField({
|
||||||
Key key,
|
Key? key,
|
||||||
ipOnly = false,
|
ipOnly = false,
|
||||||
|
enableIPV6 = false,
|
||||||
ipHelp = "ip address",
|
ipHelp = "ip address",
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
focusNode,
|
focusNode,
|
||||||
nextFocusNode,
|
nextFocusNode,
|
||||||
ValueChanged<IPAndPort> onChanged,
|
ValueChanged<IPAndPort>? onChanged,
|
||||||
FormFieldSetter<IPAndPort> onSaved,
|
FormFieldSetter<IPAndPort>? onSaved,
|
||||||
textInputAction,
|
textInputAction,
|
||||||
IPAndPort initialValue,
|
IPAndPort? initialValue,
|
||||||
noBorder,
|
noBorder,
|
||||||
ipTextAlign = TextAlign.center,
|
ipTextAlign = TextAlign.center,
|
||||||
this.ipController,
|
this.ipController,
|
||||||
|
@ -32,18 +32,18 @@ class IPAndPortFormField extends FormField<IPAndPort> {
|
||||||
return "Please fill out this field";
|
return "Please fill out this field";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ipValidator(ipAndPort.ip) && (!ipOnly && !dnsValidator(ipAndPort.ip))) {
|
if (!ipValidator(ipAndPort.ip, enableIPV6) && (!ipOnly && !dnsValidator(ipAndPort.ip))) {
|
||||||
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
|
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ipAndPort.port == null || ipAndPort.port > 65535 || ipAndPort.port < 0) {
|
if (ipAndPort.port == null || ipAndPort.port! > 65535 || ipAndPort.port! < 0) {
|
||||||
return "Please enter a valid port";
|
return "Please enter a valid port";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<IPAndPort> field) {
|
builder: (FormFieldState<IPAndPort> field) {
|
||||||
final _IPAndPortFormField state = field;
|
final _IPAndPortFormField state = field as _IPAndPortFormField;
|
||||||
|
|
||||||
void onChangedHandler(IPAndPort value) {
|
void onChangedHandler(IPAndPort value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -67,42 +67,42 @@ class IPAndPortFormField extends FormField<IPAndPort> {
|
||||||
ipTextAlign: ipTextAlign,
|
ipTextAlign: ipTextAlign,
|
||||||
),
|
),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(field.errorText,
|
? Text(field.errorText!,
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13))
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13))
|
||||||
: Container(height: 0)
|
: Container(height: 0)
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController portController;
|
final TextEditingController? portController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_IPAndPortFormField createState() => _IPAndPortFormField();
|
_IPAndPortFormField createState() => _IPAndPortFormField();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
TextEditingController _ipController;
|
TextEditingController? _ipController;
|
||||||
TextEditingController _portController;
|
TextEditingController? _portController;
|
||||||
|
|
||||||
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController;
|
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController!;
|
||||||
TextEditingController get _effectivePortController => widget.portController ?? _portController;
|
TextEditingController get _effectivePortController => widget.portController ?? _portController!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IPAndPortFormField get widget => super.widget;
|
IPAndPortFormField get widget => super.widget as IPAndPortFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.ipController == null) {
|
if (widget.ipController == null) {
|
||||||
_ipController = TextEditingController(text: widget.initialValue.ip);
|
_ipController = TextEditingController(text: widget.initialValue?.ip ?? "");
|
||||||
} else {
|
} else {
|
||||||
widget.ipController.addListener(_handleControllerChanged);
|
widget.ipController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.portController == null) {
|
if (widget.portController == null) {
|
||||||
_portController = TextEditingController(text: widget.initialValue?.port?.toString() ?? "");
|
_portController = TextEditingController(text: widget.initialValue?.port?.toString() ?? "");
|
||||||
} else {
|
} else {
|
||||||
widget.portController.addListener(_handleControllerChanged);
|
widget.portController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -118,12 +118,12 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
widget.ipController?.addListener(_handleControllerChanged);
|
widget.ipController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.ipController != null && widget.ipController == null) {
|
if (oldWidget.ipController != null && widget.ipController == null) {
|
||||||
_ipController = TextEditingController.fromValue(oldWidget.ipController.value);
|
_ipController = TextEditingController.fromValue(oldWidget.ipController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.ipController != null) {
|
if (widget.ipController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.ip = widget.ipController.text;
|
update.ip = widget.ipController!.text;
|
||||||
if (oldWidget.ipController == null) _ipController = null;
|
if (oldWidget.ipController == null) _ipController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -133,12 +133,12 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
widget.portController?.addListener(_handleControllerChanged);
|
widget.portController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.portController != null && widget.portController == null) {
|
if (oldWidget.portController != null && widget.portController == null) {
|
||||||
_portController = TextEditingController.fromValue(oldWidget.portController.value);
|
_portController = TextEditingController.fromValue(oldWidget.portController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.portController != null) {
|
if (widget.portController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.port = int.parse(widget.portController.text);
|
update.port = int.parse(widget.portController!.text);
|
||||||
if (oldWidget.portController == null) _portController = null;
|
if (oldWidget.portController == null) _portController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -159,8 +159,8 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveIPController.text = widget.initialValue.ip;
|
_effectiveIPController.text = widget.initialValue?.ip ?? "";
|
||||||
_effectivePortController.text = widget.initialValue.port.toString();
|
_effectivePortController.text = widget.initialValue?.port?.toString() ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -173,7 +173,11 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
// example, the reset() method. In such cases, the FormField value will
|
// example, the reset() method. In such cases, the FormField value will
|
||||||
// already have been set.
|
// already have been set.
|
||||||
final effectivePort = int.parse(_effectivePortController.text);
|
final effectivePort = int.parse(_effectivePortController.text);
|
||||||
if (_effectiveIPController.text != value.ip || effectivePort != value.port) {
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_effectiveIPController.text != value!.ip || effectivePort != value!.port) {
|
||||||
didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort));
|
didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,25 +1,23 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
|
|
||||||
import '../services/utils.dart';
|
import '../services/utils.dart';
|
||||||
|
|
||||||
class IPField extends StatelessWidget {
|
class IPField extends StatelessWidget {
|
||||||
final String help;
|
final String help;
|
||||||
final bool ipOnly;
|
final bool ipOnly;
|
||||||
final bool autoFocus;
|
final bool autoFocus;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
final EdgeInsetsGeometry textPadding;
|
final EdgeInsetsGeometry textPadding;
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final controller;
|
final controller;
|
||||||
final textAlign;
|
final textAlign;
|
||||||
|
|
||||||
const IPField(
|
const IPField(
|
||||||
{Key key,
|
{Key? key,
|
||||||
this.ipOnly = false,
|
this.ipOnly = false,
|
||||||
this.help = "ip address",
|
this.help = "ip address",
|
||||||
this.autoFocus = false,
|
this.autoFocus = false,
|
||||||
|
@ -35,12 +33,12 @@ class IPField extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
|
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
|
||||||
final double ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null;
|
final double? ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: ipWidth,
|
width: ipWidth,
|
||||||
child: SpecialTextField(
|
child: SpecialTextField(
|
||||||
keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true) : null,
|
keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true, signed: true) : null,
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
autofocus: autoFocus,
|
autofocus: autoFocus,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
|
@ -48,10 +46,8 @@ class IPField extends StatelessWidget {
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
maxLength: ipOnly ? 15 : null,
|
maxLength: ipOnly ? 15 : null,
|
||||||
maxLengthEnforced: ipOnly ? true : false,
|
maxLengthEnforcement: ipOnly ? MaxLengthEnforcement.enforced : MaxLengthEnforcement.none,
|
||||||
inputFormatters: ipOnly
|
inputFormatters: ipOnly ? [IPTextInputFormatter()] : [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))],
|
||||||
? [IPTextInputFormatter()]
|
|
||||||
: [WhitelistingTextInputFormatter(RegExp(r'[^\s]+'))],
|
|
||||||
textInputAction: this.textInputAction,
|
textInputAction: this.textInputAction,
|
||||||
placeholder: help,
|
placeholder: help,
|
||||||
));
|
));
|
||||||
|
@ -68,8 +64,9 @@ class IPTextInputFormatter extends TextInputFormatter {
|
||||||
(String substring) {
|
(String substring) {
|
||||||
return whitelistedPattern
|
return whitelistedPattern
|
||||||
.allMatches(substring)
|
.allMatches(substring)
|
||||||
.map<String>((Match match) => match.group(0))
|
.map<String>((Match match) => match.group(0)!)
|
||||||
.join().replaceAll(RegExp(r','), '.');
|
.join()
|
||||||
|
.replaceAll(RegExp(r','), '.');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -82,19 +79,13 @@ TextEditingValue _selectionAwareTextManipulation(
|
||||||
final int selectionStartIndex = value.selection.start;
|
final int selectionStartIndex = value.selection.start;
|
||||||
final int selectionEndIndex = value.selection.end;
|
final int selectionEndIndex = value.selection.end;
|
||||||
String manipulatedText;
|
String manipulatedText;
|
||||||
TextSelection manipulatedSelection;
|
TextSelection? manipulatedSelection;
|
||||||
if (selectionStartIndex < 0 || selectionEndIndex < 0) {
|
if (selectionStartIndex < 0 || selectionEndIndex < 0) {
|
||||||
manipulatedText = substringManipulation(value.text);
|
manipulatedText = substringManipulation(value.text);
|
||||||
} else {
|
} else {
|
||||||
final String beforeSelection = substringManipulation(
|
final String beforeSelection = substringManipulation(value.text.substring(0, selectionStartIndex));
|
||||||
value.text.substring(0, selectionStartIndex)
|
final String inSelection = substringManipulation(value.text.substring(selectionStartIndex, selectionEndIndex));
|
||||||
);
|
final String afterSelection = substringManipulation(value.text.substring(selectionEndIndex));
|
||||||
final String inSelection = substringManipulation(
|
|
||||||
value.text.substring(selectionStartIndex, selectionEndIndex)
|
|
||||||
);
|
|
||||||
final String afterSelection = substringManipulation(
|
|
||||||
value.text.substring(selectionEndIndex)
|
|
||||||
);
|
|
||||||
manipulatedText = beforeSelection + inSelection + afterSelection;
|
manipulatedText = beforeSelection + inSelection + afterSelection;
|
||||||
if (value.selection.baseOffset > value.selection.extentOffset) {
|
if (value.selection.baseOffset > value.selection.extentOffset) {
|
||||||
manipulatedSelection = value.selection.copyWith(
|
manipulatedSelection = value.selection.copyWith(
|
||||||
|
@ -111,8 +102,6 @@ TextEditingValue _selectionAwareTextManipulation(
|
||||||
return TextEditingValue(
|
return TextEditingValue(
|
||||||
text: manipulatedText,
|
text: manipulatedText,
|
||||||
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
|
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
|
||||||
composing: manipulatedText == value.text
|
composing: manipulatedText == value.text ? value.composing : TextRange.empty,
|
||||||
? value.composing
|
|
||||||
: TextRange.empty,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
||||||
import 'package:mobile_nebula/validators/ipValidator.dart';
|
import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
|
|
||||||
|
@ -9,16 +7,17 @@ import 'IPField.dart';
|
||||||
//TODO: reset doesn't update the ui but clears the field
|
//TODO: reset doesn't update the ui but clears the field
|
||||||
|
|
||||||
class IPFormField extends FormField<String> {
|
class IPFormField extends FormField<String> {
|
||||||
//TODO: validator, autovalidate, enabled?
|
//TODO: validator, auto-validate, enabled?
|
||||||
IPFormField({
|
IPFormField({
|
||||||
Key key,
|
Key? key,
|
||||||
ipOnly = false,
|
ipOnly = false,
|
||||||
|
enableIPV6 = false,
|
||||||
help = "ip address",
|
help = "ip address",
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
focusNode,
|
focusNode,
|
||||||
nextFocusNode,
|
nextFocusNode,
|
||||||
ValueChanged<String> onChanged,
|
ValueChanged<String>? onChanged,
|
||||||
FormFieldSetter<String> onSaved,
|
FormFieldSetter<String>? onSaved,
|
||||||
textPadding = const EdgeInsets.all(6.0),
|
textPadding = const EdgeInsets.all(6.0),
|
||||||
textInputAction,
|
textInputAction,
|
||||||
initialValue,
|
initialValue,
|
||||||
|
@ -34,7 +33,7 @@ class IPFormField extends FormField<String> {
|
||||||
return "Please fill out this field";
|
return "Please fill out this field";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!ipValidator(ip) || (!ipOnly && !dnsValidator(ip))) {
|
if (!ipValidator(ip, enableIPV6) || (!ipOnly && !dnsValidator(ip))) {
|
||||||
print(ip);
|
print(ip);
|
||||||
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
|
return ipOnly ? 'Please enter a valid ip address' : 'Please enter a valid ip address or dns name';
|
||||||
}
|
}
|
||||||
|
@ -42,7 +41,7 @@ class IPFormField extends FormField<String> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<String> field) {
|
builder: (FormFieldState<String> field) {
|
||||||
final _IPFormField state = field;
|
final _IPFormField state = field as _IPFormField;
|
||||||
|
|
||||||
void onChangedHandler(String value) {
|
void onChangedHandler(String value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -65,7 +64,7 @@ class IPFormField extends FormField<String> {
|
||||||
textAlign: textAlign),
|
textAlign: textAlign),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(
|
? Text(
|
||||||
field.errorText,
|
field.errorText!,
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
)
|
)
|
||||||
|
@ -73,19 +72,19 @@ class IPFormField extends FormField<String> {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_IPFormField createState() => _IPFormField();
|
_IPFormField createState() => _IPFormField();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IPFormField extends FormFieldState<String> {
|
class _IPFormField extends FormFieldState<String> {
|
||||||
TextEditingController _controller;
|
TextEditingController? _controller;
|
||||||
|
|
||||||
TextEditingController get _effectiveController => widget.controller ?? _controller;
|
TextEditingController get _effectiveController => widget.controller ?? _controller!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IPFormField get widget => super.widget;
|
IPFormField get widget => super.widget as IPFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -93,7 +92,7 @@ class _IPFormField extends FormFieldState<String> {
|
||||||
if (widget.controller == null) {
|
if (widget.controller == null) {
|
||||||
_controller = TextEditingController(text: widget.initialValue);
|
_controller = TextEditingController(text: widget.initialValue);
|
||||||
} else {
|
} else {
|
||||||
widget.controller.addListener(_handleControllerChanged);
|
widget.controller!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -105,9 +104,9 @@ class _IPFormField extends FormFieldState<String> {
|
||||||
widget.controller?.addListener(_handleControllerChanged);
|
widget.controller?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.controller != null && widget.controller == null)
|
if (oldWidget.controller != null && widget.controller == null)
|
||||||
_controller = TextEditingController.fromValue(oldWidget.controller.value);
|
_controller = TextEditingController.fromValue(oldWidget.controller!.value);
|
||||||
if (widget.controller != null) {
|
if (widget.controller != null) {
|
||||||
setValue(widget.controller.text);
|
setValue(widget.controller!.text);
|
||||||
if (oldWidget.controller == null) _controller = null;
|
if (oldWidget.controller == null) _controller = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -123,7 +122,7 @@ class _IPFormField extends FormFieldState<String> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveController.text = widget.initialValue;
|
_effectiveController.text = widget.initialValue ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,34 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
//TODO: reset doesn't update the ui but clears the field
|
//TODO: reset doesn't update the ui but clears the field
|
||||||
|
|
||||||
class PlatformTextFormField extends FormField<String> {
|
class PlatformTextFormField extends FormField<String> {
|
||||||
//TODO: autovalidate, enabled?
|
//TODO: auto-validate, enabled?
|
||||||
PlatformTextFormField(
|
PlatformTextFormField(
|
||||||
{Key key,
|
{Key? key,
|
||||||
widgetKey,
|
widgetKey,
|
||||||
this.controller,
|
this.controller,
|
||||||
focusNode,
|
focusNode,
|
||||||
nextFocusNode,
|
nextFocusNode,
|
||||||
TextInputType keyboardType,
|
TextInputType? keyboardType,
|
||||||
textInputAction,
|
textInputAction,
|
||||||
List<TextInputFormatter> inputFormatters,
|
List<TextInputFormatter>? inputFormatters,
|
||||||
textAlign,
|
textAlign,
|
||||||
autofocus,
|
autofocus,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
maxLength,
|
maxLength,
|
||||||
maxLengthEnforced,
|
maxLengthEnforcement,
|
||||||
onChanged,
|
onChanged,
|
||||||
keyboardAppearance,
|
keyboardAppearance,
|
||||||
minLines,
|
minLines,
|
||||||
expands,
|
expands,
|
||||||
suffix,
|
suffix,
|
||||||
textAlignVertical,
|
textAlignVertical,
|
||||||
String initialValue,
|
String? initialValue,
|
||||||
String placeholder,
|
String? placeholder,
|
||||||
FormFieldValidator<String> validator,
|
FormFieldValidator<String>? validator,
|
||||||
ValueChanged<String> onSaved})
|
ValueChanged<String?>? onSaved})
|
||||||
: super(
|
: super(
|
||||||
key: key,
|
key: key,
|
||||||
initialValue: controller != null ? controller.text : (initialValue ?? ''),
|
initialValue: controller != null ? controller.text : (initialValue ?? ''),
|
||||||
|
@ -43,7 +41,7 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<String> field) {
|
builder: (FormFieldState<String> field) {
|
||||||
final _PlatformTextFormFieldState state = field;
|
final _PlatformTextFormFieldState state = field as _PlatformTextFormFieldState;
|
||||||
|
|
||||||
void onChangedHandler(String value) {
|
void onChangedHandler(String value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -64,7 +62,7 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
maxLength: maxLength,
|
maxLength: maxLength,
|
||||||
maxLengthEnforced: maxLengthEnforced,
|
maxLengthEnforcement: maxLengthEnforcement,
|
||||||
onChanged: onChangedHandler,
|
onChanged: onChangedHandler,
|
||||||
keyboardAppearance: keyboardAppearance,
|
keyboardAppearance: keyboardAppearance,
|
||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
|
@ -75,7 +73,7 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
suffix: suffix),
|
suffix: suffix),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(
|
? Text(
|
||||||
field.errorText,
|
field.errorText!,
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
)
|
)
|
||||||
|
@ -83,19 +81,19 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PlatformTextFormFieldState createState() => _PlatformTextFormFieldState();
|
_PlatformTextFormFieldState createState() => _PlatformTextFormFieldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlatformTextFormFieldState extends FormFieldState<String> {
|
class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
TextEditingController _controller;
|
TextEditingController? _controller;
|
||||||
|
|
||||||
TextEditingController get _effectiveController => widget.controller ?? _controller;
|
TextEditingController get _effectiveController => widget.controller ?? _controller!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PlatformTextFormField get widget => super.widget;
|
PlatformTextFormField get widget => super.widget as PlatformTextFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -103,7 +101,7 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
if (widget.controller == null) {
|
if (widget.controller == null) {
|
||||||
_controller = TextEditingController(text: widget.initialValue);
|
_controller = TextEditingController(text: widget.initialValue);
|
||||||
} else {
|
} else {
|
||||||
widget.controller.addListener(_handleControllerChanged);
|
widget.controller!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,9 +113,9 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
widget.controller?.addListener(_handleControllerChanged);
|
widget.controller?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.controller != null && widget.controller == null)
|
if (oldWidget.controller != null && widget.controller == null)
|
||||||
_controller = TextEditingController.fromValue(oldWidget.controller.value);
|
_controller = TextEditingController.fromValue(oldWidget.controller!.value);
|
||||||
if (widget.controller != null) {
|
if (widget.controller != null) {
|
||||||
setValue(widget.controller.text);
|
setValue(widget.controller!.text);
|
||||||
if (oldWidget.controller == null) _controller = null;
|
if (oldWidget.controller == null) _controller = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -133,7 +131,7 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveController.text = widget.initialValue;
|
_effectiveController.text = widget.initialValue ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,9 @@ enum SimpleScrollable {
|
||||||
|
|
||||||
class SimplePage extends StatelessWidget {
|
class SimplePage extends StatelessWidget {
|
||||||
const SimplePage(
|
const SimplePage(
|
||||||
{Key key,
|
{Key? key,
|
||||||
this.title,
|
required this.title,
|
||||||
@required this.child,
|
required this.child,
|
||||||
this.leadingAction,
|
this.leadingAction,
|
||||||
this.trailingActions = const [],
|
this.trailingActions = const [],
|
||||||
this.scrollable = SimpleScrollable.vertical,
|
this.scrollable = SimpleScrollable.vertical,
|
||||||
|
@ -24,27 +24,28 @@ class SimplePage extends StatelessWidget {
|
||||||
this.bottomBar,
|
this.bottomBar,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
this.onLoading,
|
this.onLoading,
|
||||||
|
this.alignment,
|
||||||
this.refreshController})
|
this.refreshController})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String title;
|
final Widget title;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final SimpleScrollable scrollable;
|
final SimpleScrollable scrollable;
|
||||||
final ScrollController scrollController;
|
final ScrollController? scrollController;
|
||||||
|
final AlignmentGeometry? alignment;
|
||||||
|
|
||||||
/// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorderable listviews
|
/// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorder-able listviews
|
||||||
/// This is set to true if you have any scrollable other than none
|
/// This is set to true if you have any scrollable other than none
|
||||||
final bool scrollbar;
|
final bool scrollbar;
|
||||||
final Widget bottomBar;
|
final Widget? bottomBar;
|
||||||
|
|
||||||
/// If no leading action is provided then a default "Back" widget than pops the page will be provided
|
/// If no leading action is provided then a default "Back" widget than pops the page will be provided
|
||||||
final Widget leadingAction;
|
final Widget? leadingAction;
|
||||||
final List<Widget> trailingActions;
|
final List<Widget> trailingActions;
|
||||||
|
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback? onRefresh;
|
||||||
final VoidCallback onLoading;
|
final VoidCallback? onLoading;
|
||||||
final RefreshController refreshController;
|
final RefreshController? refreshController;
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -52,7 +53,10 @@ class SimplePage extends StatelessWidget {
|
||||||
var addScrollbar = this.scrollbar;
|
var addScrollbar = this.scrollbar;
|
||||||
|
|
||||||
if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) {
|
if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) {
|
||||||
realChild = SingleChildScrollView(scrollDirection: Axis.vertical, child: realChild, controller: refreshController == null ? scrollController : null);
|
realChild = SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
child: realChild,
|
||||||
|
controller: refreshController == null ? scrollController : null);
|
||||||
addScrollbar = true;
|
addScrollbar = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -70,7 +74,7 @@ class SimplePage extends StatelessWidget {
|
||||||
scrollController: scrollController,
|
scrollController: scrollController,
|
||||||
onRefresh: onRefresh,
|
onRefresh: onRefresh,
|
||||||
onLoading: onLoading,
|
onLoading: onLoading,
|
||||||
controller: refreshController,
|
controller: refreshController!,
|
||||||
child: realChild,
|
child: realChild,
|
||||||
enablePullUp: onLoading != null,
|
enablePullUp: onLoading != null,
|
||||||
enablePullDown: onRefresh != null,
|
enablePullDown: onRefresh != null,
|
||||||
|
@ -83,20 +87,24 @@ class SimplePage extends StatelessWidget {
|
||||||
realChild = Scrollbar(child: realChild);
|
realChild = Scrollbar(child: realChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alignment != null) {
|
||||||
|
realChild = Align(alignment: this.alignment!, child: realChild);
|
||||||
|
}
|
||||||
|
|
||||||
if (bottomBar != null) {
|
if (bottomBar != null) {
|
||||||
realChild = Column(children: [
|
realChild = Column(children: [
|
||||||
Expanded(child: realChild),
|
Expanded(child: realChild),
|
||||||
bottomBar,
|
bottomBar!,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PlatformScaffold(
|
return PlatformScaffold(
|
||||||
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context),
|
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context),
|
||||||
appBar: PlatformAppBar(
|
appBar: PlatformAppBar(
|
||||||
title: Text(title),
|
title: title,
|
||||||
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
|
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
|
||||||
trailingActions: trailingActions,
|
trailingActions: trailingActions,
|
||||||
ios: (_) => CupertinoNavigationBarData(
|
cupertino: (_, __) => CupertinoNavigationBarData(
|
||||||
transitionBetweenRoutes: false,
|
transitionBetweenRoutes: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialButton.dart';
|
import 'package:mobile_nebula/components/SpecialButton.dart';
|
||||||
import 'package:mobile_nebula/models/Site.dart';
|
import 'package:mobile_nebula/models/Site.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class SiteItem extends StatelessWidget {
|
class SiteItem extends StatelessWidget {
|
||||||
const SiteItem({Key key, this.site, this.onPressed}) : super(key: key);
|
const SiteItem({Key? key, required this.site, this.onPressed}) : super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
final onPressed;
|
final onPressed;
|
||||||
|
@ -27,10 +28,7 @@ class SiteItem extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context) {
|
Widget _buildContent(BuildContext context) {
|
||||||
final border = BorderSide(color: Utils.configSectionBorder(context));
|
final border = BorderSide(color: Utils.configSectionBorder(context));
|
||||||
var ip = "Error";
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
if (site.cert != null && site.cert.cert.details.ips.length > 0) {
|
|
||||||
ip = site.cert.cert.details.ips[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return SpecialButton(
|
return SpecialButton(
|
||||||
decoration:
|
decoration:
|
||||||
|
@ -41,8 +39,10 @@ class SiteItem extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)),
|
site.managed ?
|
||||||
Expanded(child: Text(ip, textAlign: TextAlign.end)),
|
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) :
|
||||||
|
Container(),
|
||||||
|
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
Padding(padding: EdgeInsets.only(right: 10)),
|
Padding(padding: EdgeInsets.only(right: 10)),
|
||||||
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
||||||
],
|
],
|
||||||
|
|
|
@ -5,14 +5,15 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to
|
// This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to
|
||||||
class SpecialButton extends StatefulWidget {
|
class SpecialButton extends StatefulWidget {
|
||||||
const SpecialButton({Key key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration}) : super(key: key);
|
const SpecialButton({Key? key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final Widget child;
|
final Widget? child;
|
||||||
final Color color;
|
final Color? color;
|
||||||
final bool useButtonTheme;
|
final bool useButtonTheme;
|
||||||
final BoxDecoration decoration;
|
final BoxDecoration? decoration;
|
||||||
|
|
||||||
final Function onPressed;
|
final GestureTapCallback? onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SpecialButtonState createState() => _SpecialButtonState();
|
_SpecialButtonState createState() => _SpecialButtonState();
|
||||||
|
@ -58,12 +59,11 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
child: Semantics(
|
child: Semantics(
|
||||||
button: true,
|
button: true,
|
||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: _opacityAnimation,
|
opacity: _opacityAnimation!,
|
||||||
child: DefaultTextStyle(style: textStyle, child: Container(child: widget.child, color: widget.color)),
|
child: DefaultTextStyle(style: textStyle, child: Container(child: widget.child, color: widget.color)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eyeballed values. Feel free to tweak.
|
// Eyeballed values. Feel free to tweak.
|
||||||
|
@ -71,8 +71,8 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
static const Duration kFadeInDuration = Duration(milliseconds: 100);
|
static const Duration kFadeInDuration = Duration(milliseconds: 100);
|
||||||
final Tween<double> _opacityTween = Tween<double>(begin: 1.0);
|
final Tween<double> _opacityTween = Tween<double>(begin: 1.0);
|
||||||
|
|
||||||
AnimationController _animationController;
|
AnimationController? _animationController;
|
||||||
Animation<double> _opacityAnimation;
|
Animation<double>? _opacityAnimation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -82,7 +82,7 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
value: 0.0,
|
value: 0.0,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_opacityAnimation = _animationController.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween);
|
_opacityAnimation = _animationController!.drive(CurveTween(curve: Curves.decelerate)).drive(_opacityTween);
|
||||||
_setTween();
|
_setTween();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -98,8 +98,7 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController?.dispose();
|
||||||
_animationController = null;
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,14 +126,14 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
void _animate() {
|
void _animate() {
|
||||||
if (_animationController.isAnimating) {
|
if (_animationController == null || _animationController!.isAnimating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final bool wasHeldDown = _buttonHeldDown;
|
final bool wasHeldDown = _buttonHeldDown;
|
||||||
final TickerFuture ticker = _buttonHeldDown
|
final TickerFuture ticker = _buttonHeldDown
|
||||||
? _animationController.animateTo(1.0, duration: kFadeOutDuration)
|
? _animationController!.animateTo(1.0, duration: kFadeOutDuration)
|
||||||
: _animationController.animateTo(0.0, duration: kFadeInDuration);
|
: _animationController!.animateTo(0.0, duration: kFadeInDuration);
|
||||||
|
|
||||||
ticker.then<void>((void value) {
|
ticker.then<void>((void value) {
|
||||||
if (mounted && wasHeldDown != _buttonHeldDown) {
|
if (mounted && wasHeldDown != _buttonHeldDown) {
|
||||||
|
|
|
@ -1,683 +0,0 @@
|
||||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style license that can be
|
|
||||||
// found in the LICENSE file.
|
|
||||||
|
|
||||||
// @dart = 2.8
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
|
|
||||||
|
|
||||||
/// An eyeballed value that moves the cursor slightly left of where it is
|
|
||||||
/// rendered for text on Android so its positioning more accurately matches the
|
|
||||||
/// native iOS text cursor positioning.
|
|
||||||
///
|
|
||||||
/// This value is in device pixels, not logical pixels as is typically used
|
|
||||||
/// throughout the codebase.
|
|
||||||
const int iOSHorizontalOffset = -2;
|
|
||||||
|
|
||||||
class _TextSpanEditingController extends TextEditingController {
|
|
||||||
_TextSpanEditingController({@required TextSpan textSpan}):
|
|
||||||
assert(textSpan != null),
|
|
||||||
_textSpan = textSpan,
|
|
||||||
super(text: textSpan.toPlainText());
|
|
||||||
|
|
||||||
final TextSpan _textSpan;
|
|
||||||
|
|
||||||
@override
|
|
||||||
TextSpan buildTextSpan({TextStyle style ,bool withComposing}) {
|
|
||||||
// This does not care about composing.
|
|
||||||
return TextSpan(
|
|
||||||
style: style,
|
|
||||||
children: <TextSpan>[_textSpan],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
set text(String newText) {
|
|
||||||
// This should never be reached.
|
|
||||||
throw UnimplementedError();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpecialSelectableTextSelectionGestureDetectorBuilder extends TextSelectionGestureDetectorBuilder {
|
|
||||||
_SpecialSelectableTextSelectionGestureDetectorBuilder({
|
|
||||||
@required _SpecialSelectableTextState state,
|
|
||||||
}) : _state = state,
|
|
||||||
super(delegate: state);
|
|
||||||
|
|
||||||
final _SpecialSelectableTextState _state;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onForcePressStart(ForcePressDetails details) {
|
|
||||||
super.onForcePressStart(details);
|
|
||||||
if (delegate.selectionEnabled && shouldShowSelectionToolbar) {
|
|
||||||
editableText.showToolbar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onForcePressEnd(ForcePressDetails details) {
|
|
||||||
// Not required.
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onSingleLongTapMoveUpdate(LongPressMoveUpdateDetails details) {
|
|
||||||
if (delegate.selectionEnabled) {
|
|
||||||
switch (Theme.of(_state.context).platform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
renderEditable.selectPositionAt(
|
|
||||||
from: details.globalPosition,
|
|
||||||
cause: SelectionChangedCause.longPress,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
renderEditable.selectWordsInRange(
|
|
||||||
from: details.globalPosition - details.offsetFromOrigin,
|
|
||||||
to: details.globalPosition,
|
|
||||||
cause: SelectionChangedCause.longPress,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onSingleTapUp(TapUpDetails details) {
|
|
||||||
editableText.hideToolbar();
|
|
||||||
if (delegate.selectionEnabled) {
|
|
||||||
switch (Theme.of(_state.context).platform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
renderEditable.selectWordEdge(cause: SelectionChangedCause.tap);
|
|
||||||
break;
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
renderEditable.selectPosition(cause: SelectionChangedCause.tap);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (_state.widget.onTap != null)
|
|
||||||
_state.widget.onTap();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void onSingleLongTapStart(LongPressStartDetails details) {
|
|
||||||
if (delegate.selectionEnabled) {
|
|
||||||
switch (Theme.of(_state.context).platform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
renderEditable.selectPositionAt(
|
|
||||||
from: details.globalPosition,
|
|
||||||
cause: SelectionChangedCause.longPress,
|
|
||||||
);
|
|
||||||
break;
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
renderEditable.selectWord(cause: SelectionChangedCause.longPress);
|
|
||||||
Feedback.forLongPress(_state.context);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A run of selectable text with a single style.
|
|
||||||
///
|
|
||||||
/// The [SpecialSelectableText] widget displays a string of text with a single style.
|
|
||||||
/// The string might break across multiple lines or might all be displayed on
|
|
||||||
/// the same line depending on the layout constraints.
|
|
||||||
///
|
|
||||||
/// {@youtube 560 315 https://www.youtube.com/watch?v=ZSU3ZXOs6hc}
|
|
||||||
///
|
|
||||||
/// The [style] argument is optional. When omitted, the text will use the style
|
|
||||||
/// from the closest enclosing [DefaultTextStyle]. If the given style's
|
|
||||||
/// [TextStyle.inherit] property is true (the default), the given style will
|
|
||||||
/// be merged with the closest enclosing [DefaultTextStyle]. This merging
|
|
||||||
/// behavior is useful, for example, to make the text bold while using the
|
|
||||||
/// default font family and size.
|
|
||||||
///
|
|
||||||
/// {@tool snippet}
|
|
||||||
///
|
|
||||||
/// ```dart
|
|
||||||
/// SpecialSelectableText(
|
|
||||||
/// 'Hello! How are you?',
|
|
||||||
/// textAlign: TextAlign.center,
|
|
||||||
/// style: TextStyle(fontWeight: FontWeight.bold),
|
|
||||||
/// )
|
|
||||||
/// ```
|
|
||||||
/// {@end-tool}
|
|
||||||
///
|
|
||||||
/// Using the [SpecialSelectableText.rich] constructor, the [SpecialSelectableText] widget can
|
|
||||||
/// display a paragraph with differently styled [TextSpan]s. The sample
|
|
||||||
/// that follows displays "Hello beautiful world" with different styles
|
|
||||||
/// for each word.
|
|
||||||
///
|
|
||||||
/// {@tool snippet}
|
|
||||||
///
|
|
||||||
/// ```dart
|
|
||||||
/// const SpecialSelectableText.rich(
|
|
||||||
/// TextSpan(
|
|
||||||
/// text: 'Hello', // default text style
|
|
||||||
/// children: <TextSpan>[
|
|
||||||
/// TextSpan(text: ' beautiful ', style: TextStyle(fontStyle: FontStyle.italic)),
|
|
||||||
/// TextSpan(text: 'world', style: TextStyle(fontWeight: FontWeight.bold)),
|
|
||||||
/// ],
|
|
||||||
/// ),
|
|
||||||
/// )
|
|
||||||
/// ```
|
|
||||||
/// {@end-tool}
|
|
||||||
///
|
|
||||||
/// ## Interactivity
|
|
||||||
///
|
|
||||||
/// To make [SpecialSelectableText] react to touch events, use callback [onTap] to achieve
|
|
||||||
/// the desired behavior.
|
|
||||||
///
|
|
||||||
/// See also:
|
|
||||||
///
|
|
||||||
/// * [Text], which is the non selectable version of this widget.
|
|
||||||
/// * [TextField], which is the editable version of this widget.
|
|
||||||
class SpecialSelectableText extends StatefulWidget {
|
|
||||||
/// Creates a selectable text widget.
|
|
||||||
///
|
|
||||||
/// If the [style] argument is null, the text will use the style from the
|
|
||||||
/// closest enclosing [DefaultTextStyle].
|
|
||||||
///
|
|
||||||
|
|
||||||
/// The [showCursor], [autofocus], [dragStartBehavior], and [data] parameters
|
|
||||||
/// must not be null. If specified, the [maxLines] argument must be greater
|
|
||||||
/// than zero.
|
|
||||||
const SpecialSelectableText(
|
|
||||||
this.data, {
|
|
||||||
Key key,
|
|
||||||
this.focusNode,
|
|
||||||
this.style,
|
|
||||||
this.strutStyle,
|
|
||||||
this.textAlign,
|
|
||||||
this.textDirection,
|
|
||||||
this.textScaleFactor,
|
|
||||||
this.showCursor = false,
|
|
||||||
this.autofocus = false,
|
|
||||||
ToolbarOptions toolbarOptions,
|
|
||||||
this.minLines,
|
|
||||||
this.maxLines,
|
|
||||||
this.cursorWidth = 2.0,
|
|
||||||
this.cursorRadius,
|
|
||||||
this.cursorColor,
|
|
||||||
this.dragStartBehavior = DragStartBehavior.start,
|
|
||||||
this.enableInteractiveSelection = true,
|
|
||||||
this.onTap,
|
|
||||||
this.scrollPhysics,
|
|
||||||
this.textHeightBehavior,
|
|
||||||
this.textWidthBasis,
|
|
||||||
}) : assert(showCursor != null),
|
|
||||||
assert(autofocus != null),
|
|
||||||
assert(dragStartBehavior != null),
|
|
||||||
assert(maxLines == null || maxLines > 0),
|
|
||||||
assert(minLines == null || minLines > 0),
|
|
||||||
assert(
|
|
||||||
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
|
|
||||||
'minLines can\'t be greater than maxLines',
|
|
||||||
),
|
|
||||||
assert(
|
|
||||||
data != null,
|
|
||||||
'A non-null String must be provided to a SpecialSelectableText widget.',
|
|
||||||
),
|
|
||||||
textSpan = null,
|
|
||||||
toolbarOptions = toolbarOptions ??
|
|
||||||
const ToolbarOptions(
|
|
||||||
selectAll: true,
|
|
||||||
copy: true,
|
|
||||||
),
|
|
||||||
super(key: key);
|
|
||||||
|
|
||||||
/// Creates a selectable text widget with a [TextSpan].
|
|
||||||
///
|
|
||||||
/// The [textSpan] parameter must not be null and only contain [TextSpan] in
|
|
||||||
/// [textSpan.children]. Other type of [InlineSpan] is not allowed.
|
|
||||||
///
|
|
||||||
/// The [autofocus] and [dragStartBehavior] arguments must not be null.
|
|
||||||
const SpecialSelectableText.rich(
|
|
||||||
this.textSpan, {
|
|
||||||
Key key,
|
|
||||||
this.focusNode,
|
|
||||||
this.style,
|
|
||||||
this.strutStyle,
|
|
||||||
this.textAlign,
|
|
||||||
this.textDirection,
|
|
||||||
this.textScaleFactor,
|
|
||||||
this.showCursor = false,
|
|
||||||
this.autofocus = false,
|
|
||||||
ToolbarOptions toolbarOptions,
|
|
||||||
this.minLines,
|
|
||||||
this.maxLines,
|
|
||||||
this.cursorWidth = 2.0,
|
|
||||||
this.cursorRadius,
|
|
||||||
this.cursorColor,
|
|
||||||
this.dragStartBehavior = DragStartBehavior.start,
|
|
||||||
this.enableInteractiveSelection = true,
|
|
||||||
this.onTap,
|
|
||||||
this.scrollPhysics,
|
|
||||||
this.textHeightBehavior,
|
|
||||||
this.textWidthBasis,
|
|
||||||
}) : assert(showCursor != null),
|
|
||||||
assert(autofocus != null),
|
|
||||||
assert(dragStartBehavior != null),
|
|
||||||
assert(maxLines == null || maxLines > 0),
|
|
||||||
assert(minLines == null || minLines > 0),
|
|
||||||
assert(
|
|
||||||
(maxLines == null) || (minLines == null) || (maxLines >= minLines),
|
|
||||||
'minLines can\'t be greater than maxLines',
|
|
||||||
),
|
|
||||||
assert(
|
|
||||||
textSpan != null,
|
|
||||||
'A non-null TextSpan must be provided to a SpecialSelectableText.rich widget.',
|
|
||||||
),
|
|
||||||
data = null,
|
|
||||||
toolbarOptions = toolbarOptions ??
|
|
||||||
const ToolbarOptions(
|
|
||||||
selectAll: true,
|
|
||||||
copy: true,
|
|
||||||
),
|
|
||||||
super(key: key);
|
|
||||||
|
|
||||||
/// The text to display.
|
|
||||||
///
|
|
||||||
/// This will be null if a [textSpan] is provided instead.
|
|
||||||
final String data;
|
|
||||||
|
|
||||||
/// The text to display as a [TextSpan].
|
|
||||||
///
|
|
||||||
/// This will be null if [data] is provided instead.
|
|
||||||
final TextSpan textSpan;
|
|
||||||
|
|
||||||
/// Defines the focus for this widget.
|
|
||||||
///
|
|
||||||
/// Text is only selectable when widget is focused.
|
|
||||||
///
|
|
||||||
/// The [focusNode] is a long-lived object that's typically managed by a
|
|
||||||
/// [StatefulWidget] parent. See [FocusNode] for more information.
|
|
||||||
///
|
|
||||||
/// To give the focus to this widget, provide a [focusNode] and then
|
|
||||||
/// use the current [FocusScope] to request the focus:
|
|
||||||
///
|
|
||||||
/// ```dart
|
|
||||||
/// FocusScope.of(context).requestFocus(myFocusNode);
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// This happens automatically when the widget is tapped.
|
|
||||||
///
|
|
||||||
/// To be notified when the widget gains or loses the focus, add a listener
|
|
||||||
/// to the [focusNode]:
|
|
||||||
///
|
|
||||||
/// ```dart
|
|
||||||
/// focusNode.addListener(() { print(myFocusNode.hasFocus); });
|
|
||||||
/// ```
|
|
||||||
///
|
|
||||||
/// If null, this widget will create its own [FocusNode].
|
|
||||||
final FocusNode focusNode;
|
|
||||||
|
|
||||||
/// The style to use for the text.
|
|
||||||
///
|
|
||||||
/// If null, defaults [DefaultTextStyle] of context.
|
|
||||||
final TextStyle style;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.strutStyle}
|
|
||||||
final StrutStyle strutStyle;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.textAlign}
|
|
||||||
final TextAlign textAlign;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.textDirection}
|
|
||||||
final TextDirection textDirection;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.textScaleFactor}
|
|
||||||
final double textScaleFactor;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.autofocus}
|
|
||||||
final bool autofocus;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.minLines}
|
|
||||||
final int minLines;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.maxLines}
|
|
||||||
final int maxLines;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.showCursor}
|
|
||||||
final bool showCursor;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.cursorWidth}
|
|
||||||
final double cursorWidth;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.cursorRadius}
|
|
||||||
final Radius cursorRadius;
|
|
||||||
|
|
||||||
/// The color to use when painting the cursor.
|
|
||||||
///
|
|
||||||
/// Defaults to the theme's `cursorColor` when null.
|
|
||||||
final Color cursorColor;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.enableInteractiveSelection}
|
|
||||||
final bool enableInteractiveSelection;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.scrollable.dragStartBehavior}
|
|
||||||
final DragStartBehavior dragStartBehavior;
|
|
||||||
|
|
||||||
/// Configuration of toolbar options.
|
|
||||||
///
|
|
||||||
/// Paste and cut will be disabled regardless.
|
|
||||||
///
|
|
||||||
/// If not set, select all and copy will be enabled by default.
|
|
||||||
final ToolbarOptions toolbarOptions;
|
|
||||||
|
|
||||||
/// {@macro flutter.rendering.editable.selectionEnabled}
|
|
||||||
bool get selectionEnabled {
|
|
||||||
return enableInteractiveSelection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Called when the user taps on this selectable text.
|
|
||||||
///
|
|
||||||
/// The selectable text builds a [GestureDetector] to handle input events like tap,
|
|
||||||
/// to trigger focus requests, to move the caret, adjust the selection, etc.
|
|
||||||
/// Handling some of those events by wrapping the selectable text with a competing
|
|
||||||
/// GestureDetector is problematic.
|
|
||||||
///
|
|
||||||
/// To unconditionally handle taps, without interfering with the selectable text's
|
|
||||||
/// internal gesture detector, provide this callback.
|
|
||||||
///
|
|
||||||
/// To be notified when the text field gains or loses the focus, provide a
|
|
||||||
/// [focusNode] and add a listener to that.
|
|
||||||
///
|
|
||||||
/// To listen to arbitrary pointer events without competing with the
|
|
||||||
/// selectable text's internal gesture detector, use a [Listener].
|
|
||||||
final GestureTapCallback onTap;
|
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.scrollPhysics}
|
|
||||||
final ScrollPhysics scrollPhysics;
|
|
||||||
|
|
||||||
/// {@macro flutter.dart:ui.textHeightBehavior}
|
|
||||||
final TextHeightBehavior textHeightBehavior;
|
|
||||||
|
|
||||||
/// {@macro flutter.painting.textPainter.textWidthBasis}
|
|
||||||
final TextWidthBasis textWidthBasis;
|
|
||||||
|
|
||||||
@override
|
|
||||||
_SpecialSelectableTextState createState() => _SpecialSelectableTextState();
|
|
||||||
|
|
||||||
@override
|
|
||||||
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
|
|
||||||
super.debugFillProperties(properties);
|
|
||||||
properties.add(DiagnosticsProperty<String>('data', data, defaultValue: null));
|
|
||||||
properties.add(DiagnosticsProperty<FocusNode>('focusNode', focusNode, defaultValue: null));
|
|
||||||
properties.add(DiagnosticsProperty<TextStyle>('style', style, defaultValue: null));
|
|
||||||
properties.add(DiagnosticsProperty<bool>('autofocus', autofocus, defaultValue: false));
|
|
||||||
properties.add(DiagnosticsProperty<bool>('showCursor', showCursor, defaultValue: false));
|
|
||||||
properties.add(IntProperty('minLines', minLines, defaultValue: null));
|
|
||||||
properties.add(IntProperty('maxLines', maxLines, defaultValue: null));
|
|
||||||
properties.add(EnumProperty<TextAlign>('textAlign', textAlign, defaultValue: null));
|
|
||||||
properties.add(EnumProperty<TextDirection>('textDirection', textDirection, defaultValue: null));
|
|
||||||
properties.add(DoubleProperty('textScaleFactor', textScaleFactor, defaultValue: null));
|
|
||||||
properties.add(DoubleProperty('cursorWidth', cursorWidth, defaultValue: 2.0));
|
|
||||||
properties.add(DiagnosticsProperty<Radius>('cursorRadius', cursorRadius, defaultValue: null));
|
|
||||||
properties.add(DiagnosticsProperty<Color>('cursorColor', cursorColor, defaultValue: null));
|
|
||||||
properties.add(FlagProperty('selectionEnabled', value: selectionEnabled, defaultValue: true, ifFalse: 'selection disabled'));
|
|
||||||
properties.add(DiagnosticsProperty<ScrollPhysics>('scrollPhysics', scrollPhysics, defaultValue: null));
|
|
||||||
properties.add(DiagnosticsProperty<TextHeightBehavior>('textHeightBehavior', textHeightBehavior, defaultValue: null));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpecialSelectableTextState extends State<SpecialSelectableText> with AutomaticKeepAliveClientMixin implements TextSelectionGestureDetectorBuilderDelegate {
|
|
||||||
EditableTextState get _editableText => editableTextKey.currentState;
|
|
||||||
|
|
||||||
_TextSpanEditingController _controller;
|
|
||||||
|
|
||||||
FocusNode _keyFocusNode = FocusNode();
|
|
||||||
FocusNode _focusNode;
|
|
||||||
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_focusNode ??= FocusNode());
|
|
||||||
|
|
||||||
bool _showSelectionHandles = false;
|
|
||||||
|
|
||||||
_SpecialSelectableTextSelectionGestureDetectorBuilder _selectionGestureDetectorBuilder;
|
|
||||||
|
|
||||||
// API for TextSelectionGestureDetectorBuilderDelegate.
|
|
||||||
@override
|
|
||||||
bool forcePressEnabled;
|
|
||||||
|
|
||||||
@override
|
|
||||||
final GlobalKey<EditableTextState> editableTextKey = GlobalKey<EditableTextState>();
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get selectionEnabled => widget.selectionEnabled;
|
|
||||||
// End of API for TextSelectionGestureDetectorBuilderDelegate.
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_selectionGestureDetectorBuilder = _SpecialSelectableTextSelectionGestureDetectorBuilder(state: this);
|
|
||||||
_controller = _TextSpanEditingController(
|
|
||||||
textSpan: widget.textSpan ?? TextSpan(text: widget.data)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(SpecialSelectableText oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (widget.data != oldWidget.data || widget.textSpan != oldWidget.textSpan) {
|
|
||||||
_controller = _TextSpanEditingController(
|
|
||||||
textSpan: widget.textSpan ?? TextSpan(text: widget.data)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_effectiveFocusNode.hasFocus && _controller.selection.isCollapsed) {
|
|
||||||
_showSelectionHandles = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focusNode?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSelectionChanged(TextSelection selection, SelectionChangedCause cause) {
|
|
||||||
final bool willShowSelectionHandles = _shouldShowSelectionHandles(cause);
|
|
||||||
if (willShowSelectionHandles != _showSelectionHandles) {
|
|
||||||
setState(() {
|
|
||||||
_showSelectionHandles = willShowSelectionHandles;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (Theme.of(context).platform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
if (cause == SelectionChangedCause.longPress) {
|
|
||||||
_editableText?.bringIntoView(selection.base);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
// Do nothing.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Toggle the toolbar when a selection handle is tapped.
|
|
||||||
void _handleSelectionHandleTapped() {
|
|
||||||
if (_controller.selection.isCollapsed) {
|
|
||||||
_editableText.toggleToolbar();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _shouldShowSelectionHandles(SelectionChangedCause cause) {
|
|
||||||
// When the text field is activated by something that doesn't trigger the
|
|
||||||
// selection overlay, we shouldn't show the handles either.
|
|
||||||
if (!_selectionGestureDetectorBuilder.shouldShowSelectionToolbar)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (_controller.selection.isCollapsed)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (cause == SelectionChangedCause.keyboard)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if (cause == SelectionChangedCause.longPress)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (_controller.text.isNotEmpty)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool get wantKeepAlive => true;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
super.build(context); // See AutomaticKeepAliveClientMixin.
|
|
||||||
assert(() {
|
|
||||||
return _controller._textSpan.visitChildren((InlineSpan span) => span.runtimeType == TextSpan);
|
|
||||||
}(), 'SpecialSelectableText only supports TextSpan; Other type of InlineSpan is not allowed');
|
|
||||||
assert(debugCheckHasMediaQuery(context));
|
|
||||||
assert(debugCheckHasDirectionality(context));
|
|
||||||
assert(
|
|
||||||
!(widget.style != null && widget.style.inherit == false &&
|
|
||||||
(widget.style.fontSize == null || widget.style.textBaseline == null)),
|
|
||||||
'inherit false style must supply fontSize and textBaseline',
|
|
||||||
);
|
|
||||||
|
|
||||||
final ThemeData themeData = Theme.of(context);
|
|
||||||
final FocusNode focusNode = _effectiveFocusNode;
|
|
||||||
|
|
||||||
TextSelectionControls textSelectionControls;
|
|
||||||
bool paintCursorAboveText;
|
|
||||||
bool cursorOpacityAnimates;
|
|
||||||
Offset cursorOffset;
|
|
||||||
Color cursorColor = widget.cursorColor;
|
|
||||||
Radius cursorRadius = widget.cursorRadius;
|
|
||||||
|
|
||||||
switch (themeData.platform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
forcePressEnabled = true;
|
|
||||||
textSelectionControls = cupertinoTextSelectionControls;
|
|
||||||
paintCursorAboveText = true;
|
|
||||||
cursorOpacityAnimates = true;
|
|
||||||
cursorColor ??= CupertinoTheme.of(context).primaryColor;
|
|
||||||
cursorRadius ??= const Radius.circular(2.0);
|
|
||||||
cursorOffset = Offset(iOSHorizontalOffset / MediaQuery.of(context).devicePixelRatio, 0);
|
|
||||||
break;
|
|
||||||
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
forcePressEnabled = false;
|
|
||||||
textSelectionControls = materialTextSelectionControls;
|
|
||||||
paintCursorAboveText = false;
|
|
||||||
cursorOpacityAnimates = false;
|
|
||||||
cursorColor ??= themeData.cursorColor;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
final DefaultTextStyle defaultTextStyle = DefaultTextStyle.of(context);
|
|
||||||
TextStyle effectiveTextStyle = widget.style;
|
|
||||||
if (widget.style == null || widget.style.inherit)
|
|
||||||
effectiveTextStyle = defaultTextStyle.style.merge(widget.style);
|
|
||||||
if (MediaQuery.boldTextOverride(context))
|
|
||||||
effectiveTextStyle = effectiveTextStyle.merge(const TextStyle(fontWeight: FontWeight.bold));
|
|
||||||
final Widget child = RepaintBoundary(
|
|
||||||
child: EditableText(
|
|
||||||
key: editableTextKey,
|
|
||||||
style: effectiveTextStyle,
|
|
||||||
readOnly: true,
|
|
||||||
textWidthBasis: widget.textWidthBasis ?? defaultTextStyle.textWidthBasis,
|
|
||||||
textHeightBehavior: widget.textHeightBehavior ?? defaultTextStyle.textHeightBehavior,
|
|
||||||
showSelectionHandles: _showSelectionHandles,
|
|
||||||
showCursor: widget.showCursor,
|
|
||||||
controller: _controller,
|
|
||||||
focusNode: focusNode,
|
|
||||||
strutStyle: widget.strutStyle ?? const StrutStyle(),
|
|
||||||
textAlign: widget.textAlign ?? defaultTextStyle.textAlign ?? TextAlign.start,
|
|
||||||
textDirection: widget.textDirection,
|
|
||||||
textScaleFactor: widget.textScaleFactor,
|
|
||||||
autofocus: widget.autofocus,
|
|
||||||
forceLine: false,
|
|
||||||
toolbarOptions: widget.toolbarOptions,
|
|
||||||
minLines: widget.minLines,
|
|
||||||
maxLines: widget.maxLines ?? defaultTextStyle.maxLines,
|
|
||||||
selectionColor: themeData.textSelectionColor,
|
|
||||||
selectionControls: widget.selectionEnabled ? textSelectionControls : null,
|
|
||||||
onSelectionChanged: _handleSelectionChanged,
|
|
||||||
onSelectionHandleTapped: _handleSelectionHandleTapped,
|
|
||||||
rendererIgnoresPointer: true,
|
|
||||||
cursorWidth: widget.cursorWidth,
|
|
||||||
cursorRadius: cursorRadius,
|
|
||||||
cursorColor: cursorColor,
|
|
||||||
cursorOpacityAnimates: cursorOpacityAnimates,
|
|
||||||
cursorOffset: cursorOffset,
|
|
||||||
paintCursorAboveText: paintCursorAboveText,
|
|
||||||
backgroundCursorColor: CupertinoColors.inactiveGray,
|
|
||||||
enableInteractiveSelection: widget.enableInteractiveSelection,
|
|
||||||
dragStartBehavior: widget.dragStartBehavior,
|
|
||||||
scrollPhysics: widget.scrollPhysics,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Semantics(
|
|
||||||
onTap: () {
|
|
||||||
if (!_controller.selection.isValid)
|
|
||||||
_controller.selection = TextSelection.collapsed(offset: _controller.text.length);
|
|
||||||
_effectiveFocusNode.requestFocus();
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
|
||||||
_effectiveFocusNode.requestFocus();
|
|
||||||
},
|
|
||||||
child: _selectionGestureDetectorBuilder.buildGestureDetector(
|
|
||||||
behavior: HitTestBehavior.translucent,
|
|
||||||
child: RawKeyboardListener(
|
|
||||||
focusNode: _keyFocusNode,
|
|
||||||
onKey: _onKey,
|
|
||||||
child: child,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onKey(RawKeyEvent event) {
|
|
||||||
// We don't care about key up events
|
|
||||||
if (event is RawKeyUpEvent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
//TODO: tab to next focus node
|
|
||||||
|
|
||||||
// Handle special keyboard events with control key
|
|
||||||
if (event.data.isControlPressed) {
|
|
||||||
// Handle select all
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.keyA) {
|
|
||||||
_controller.selection = TextSelection(baseOffset: 0, extentOffset: _controller.text.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle copy
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.keyC) {
|
|
||||||
Clipboard.setData(ClipboardData(text: _controller.selection.textInside(_controller.text)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,12 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
|
||||||
/// A normal TextField or CupertinoTextField that watches for copy, paste, cut, or select all keyboard actions
|
/// A normal TextField or CupertinoTextField that looks the same on all platforms
|
||||||
class SpecialTextField extends StatefulWidget {
|
class SpecialTextField extends StatefulWidget {
|
||||||
const SpecialTextField(
|
const SpecialTextField(
|
||||||
{Key key,
|
{Key? key,
|
||||||
this.placeholder,
|
this.placeholder,
|
||||||
this.suffix,
|
this.suffix,
|
||||||
this.controller,
|
this.controller,
|
||||||
|
@ -16,7 +15,7 @@ class SpecialTextField extends StatefulWidget {
|
||||||
this.minLines,
|
this.minLines,
|
||||||
this.maxLines,
|
this.maxLines,
|
||||||
this.maxLength,
|
this.maxLength,
|
||||||
this.maxLengthEnforced,
|
this.maxLengthEnforcement,
|
||||||
this.style,
|
this.style,
|
||||||
this.keyboardType,
|
this.keyboardType,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
|
@ -31,50 +30,44 @@ class SpecialTextField extends StatefulWidget {
|
||||||
this.inputFormatters})
|
this.inputFormatters})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String placeholder;
|
final String? placeholder;
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final bool autocorrect;
|
final bool? autocorrect;
|
||||||
final int minLines;
|
final int? minLines;
|
||||||
final int maxLines;
|
final int? maxLines;
|
||||||
final int maxLength;
|
final int? maxLength;
|
||||||
final bool maxLengthEnforced;
|
final MaxLengthEnforcement? maxLengthEnforcement;
|
||||||
final Widget suffix;
|
final Widget? suffix;
|
||||||
final TextStyle style;
|
final TextStyle? style;
|
||||||
final TextInputType keyboardType;
|
final TextInputType? keyboardType;
|
||||||
final Brightness keyboardAppearance;
|
final Brightness? keyboardAppearance;
|
||||||
|
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final TextCapitalization textCapitalization;
|
final TextCapitalization? textCapitalization;
|
||||||
final TextAlign textAlign;
|
final TextAlign? textAlign;
|
||||||
final TextAlignVertical textAlignVertical;
|
final TextAlignVertical? textAlignVertical;
|
||||||
|
|
||||||
final bool autofocus;
|
final bool? autofocus;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
final bool enabled;
|
final bool? enabled;
|
||||||
final List<TextInputFormatter> inputFormatters;
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
final bool expands;
|
final bool? expands;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SpecialTextFieldState createState() => _SpecialTextFieldState();
|
_SpecialTextFieldState createState() => _SpecialTextFieldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SpecialTextFieldState extends State<SpecialTextField> {
|
class _SpecialTextFieldState extends State<SpecialTextField> {
|
||||||
FocusNode _focusNode = FocusNode();
|
List<TextInputFormatter> formatters = [];
|
||||||
List<TextInputFormatter> formatters;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
formatters = widget.inputFormatters;
|
if (widget.inputFormatters == null || formatters.length == 0) {
|
||||||
if (formatters == null || formatters.length == 0) {
|
formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))];
|
||||||
formatters = [WhitelistingTextInputFormatter(RegExp(r'[^\t]'))];
|
} else {
|
||||||
|
formatters = widget.inputFormatters!;
|
||||||
}
|
}
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -82,15 +75,12 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RawKeyboardListener(
|
return PlatformTextField(
|
||||||
focusNode: _focusNode,
|
|
||||||
onKey: _onKey,
|
|
||||||
child: PlatformTextField(
|
|
||||||
autocorrect: widget.autocorrect,
|
autocorrect: widget.autocorrect,
|
||||||
minLines: widget.minLines,
|
minLines: widget.minLines,
|
||||||
maxLines: widget.maxLines,
|
maxLines: widget.maxLines,
|
||||||
maxLength: widget.maxLength,
|
maxLength: widget.maxLength,
|
||||||
maxLengthEnforced: widget.maxLengthEnforced,
|
maxLengthEnforcement: widget.maxLengthEnforcement,
|
||||||
keyboardType: widget.keyboardType,
|
keyboardType: widget.keyboardType,
|
||||||
keyboardAppearance: widget.keyboardAppearance,
|
keyboardAppearance: widget.keyboardAppearance,
|
||||||
textInputAction: widget.textInputAction,
|
textInputAction: widget.textInputAction,
|
||||||
|
@ -108,7 +98,7 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
|
||||||
},
|
},
|
||||||
expands: widget.expands,
|
expands: widget.expands,
|
||||||
inputFormatters: formatters,
|
inputFormatters: formatters,
|
||||||
android: (_) => MaterialTextFieldData(
|
material: (_, __) => MaterialTextFieldData(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
border: InputBorder.none,
|
border: InputBorder.none,
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
|
@ -116,87 +106,12 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
|
||||||
hintText: widget.placeholder,
|
hintText: widget.placeholder,
|
||||||
counterText: '',
|
counterText: '',
|
||||||
suffix: widget.suffix)),
|
suffix: widget.suffix)),
|
||||||
ios: (_) => CupertinoTextFieldData(
|
cupertino: (_, __) => CupertinoTextFieldData(
|
||||||
decoration: BoxDecoration(),
|
decoration: BoxDecoration(),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
placeholder: widget.placeholder,
|
placeholder: widget.placeholder,
|
||||||
suffix: widget.suffix),
|
suffix: widget.suffix),
|
||||||
style: widget.style,
|
style: widget.style,
|
||||||
controller: widget.controller));
|
controller: widget.controller);
|
||||||
}
|
|
||||||
|
|
||||||
_onKey(RawKeyEvent event) {
|
|
||||||
// We don't care about key up events
|
|
||||||
if (event is RawKeyUpEvent) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.tab) {
|
|
||||||
// Handle tab to the next node
|
|
||||||
if (widget.nextFocusNode != null) {
|
|
||||||
FocusScope.of(context).requestFocus(widget.nextFocusNode);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle special keyboard events with control key
|
|
||||||
if (event.data.isControlPressed) {
|
|
||||||
// Handle paste
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.keyV) {
|
|
||||||
Clipboard.getData("text/plain").then((data) {
|
|
||||||
// Adjust our clipboard entry to confirm with the leftover space if we have maxLength
|
|
||||||
var text = data.text;
|
|
||||||
if (widget.maxLength != null && widget.maxLength > 0) {
|
|
||||||
var leftover = widget.maxLength - widget.controller.text.length;
|
|
||||||
if (leftover < data.text.length) {
|
|
||||||
text = text.substring(0, leftover);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If maxLength took us to 0 then bail
|
|
||||||
if (text.length == 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var end = widget.controller.selection.end;
|
|
||||||
var start = widget.controller.selection.start;
|
|
||||||
|
|
||||||
// Insert our paste buffer into the selection, which can be 0 selected text (normal caret)
|
|
||||||
widget.controller.text = widget.controller.selection.textBefore(widget.controller.text) +
|
|
||||||
text +
|
|
||||||
widget.controller.selection.textAfter(widget.controller.text);
|
|
||||||
|
|
||||||
// Adjust our caret to be at the end of the pasted contents, need to take into account the size of the selection
|
|
||||||
// We may want runes instead of
|
|
||||||
end += text.length - (end - start);
|
|
||||||
widget.controller.selection = TextSelection(baseOffset: end, extentOffset: end);
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle select all
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.keyA) {
|
|
||||||
widget.controller.selection = TextSelection(baseOffset: 0, extentOffset: widget.controller.text.length);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle copy
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.keyC) {
|
|
||||||
Clipboard.setData(ClipboardData(text: widget.controller.selection.textInside(widget.controller.text)));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle cut
|
|
||||||
if (event.logicalKey == LogicalKeyboardKey.keyX) {
|
|
||||||
Clipboard.setData(ClipboardData(text: widget.controller.selection.textInside(widget.controller.text)));
|
|
||||||
|
|
||||||
var start = widget.controller.selection.start;
|
|
||||||
widget.controller.text = widget.controller.selection.textBefore(widget.controller.text) +
|
|
||||||
widget.controller.selection.textAfter(widget.controller.text);
|
|
||||||
widget.controller.selection = TextSelection(baseOffset: start, extentOffset: start);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,14 +1,13 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialButton.dart';
|
import 'package:mobile_nebula/components/SpecialButton.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
// A config item that detects tapping and calls back on a tap
|
// A config item that detects tapping and calls back on a tap
|
||||||
class ConfigButtonItem extends StatelessWidget {
|
class ConfigButtonItem extends StatelessWidget {
|
||||||
const ConfigButtonItem({Key key, this.content, this.onPressed}) : super(key: key);
|
const ConfigButtonItem({Key? key, this.content, this.onPressed}) : super(key: key);
|
||||||
|
|
||||||
final Widget content;
|
final Widget? content;
|
||||||
final onPressed;
|
final onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -21,21 +20,5 @@ class ConfigButtonItem extends StatelessWidget {
|
||||||
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
|
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
|
||||||
child: Center(child: content),
|
child: Center(child: content),
|
||||||
));
|
));
|
||||||
|
|
||||||
return Container(
|
|
||||||
color: Utils.configItemBackground(context),
|
|
||||||
constraints: BoxConstraints(minHeight: Utils.minInteractiveSize, minWidth: double.infinity),
|
|
||||||
child: PlatformButton(
|
|
||||||
androidFlat: (_) => MaterialFlatButtonData(
|
|
||||||
textTheme: ButtonTextTheme.normal, padding: EdgeInsets.zero, shape: RoundedRectangleBorder()),
|
|
||||||
ios: (_) => CupertinoButtonData(padding: EdgeInsets.zero, borderRadius: BorderRadius.zero),
|
|
||||||
padding: EdgeInsets.symmetric(vertical: 7),
|
|
||||||
child: content,
|
|
||||||
onPressed: () {
|
|
||||||
if (onPressed != null) {
|
|
||||||
onPressed();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialButton.dart';
|
import 'package:mobile_nebula/components/SpecialButton.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class ConfigCheckboxItem extends StatelessWidget {
|
class ConfigCheckboxItem extends StatelessWidget {
|
||||||
const ConfigCheckboxItem({Key key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked})
|
const ConfigCheckboxItem(
|
||||||
|
{Key? key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked = false})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final Widget label;
|
final Widget? label;
|
||||||
final Widget content;
|
final Widget? content;
|
||||||
final double labelWidth;
|
final double labelWidth;
|
||||||
final bool checked;
|
final bool checked;
|
||||||
final Function onChanged;
|
final Function? onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -35,7 +35,7 @@ class ConfigCheckboxItem extends StatelessWidget {
|
||||||
child: item,
|
child: item,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
onChanged();
|
onChanged!();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,15 +4,15 @@ import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
TextStyle basicTextStyle(BuildContext context) =>
|
TextStyle basicTextStyle(BuildContext context) =>
|
||||||
Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.subhead;
|
Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.subtitle1!;
|
||||||
|
|
||||||
const double _headerFontSize = 13.0;
|
const double _headerFontSize = 13.0;
|
||||||
|
|
||||||
class ConfigHeader extends StatelessWidget {
|
class ConfigHeader extends StatelessWidget {
|
||||||
const ConfigHeader({Key key, this.label, this.color}) : super(key: key);
|
const ConfigHeader({Key? key, required this.label, this.color}) : super(key: key);
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final Color color;
|
final Color? color;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -4,10 +4,14 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class ConfigItem extends StatelessWidget {
|
class ConfigItem extends StatelessWidget {
|
||||||
const ConfigItem(
|
const ConfigItem(
|
||||||
{Key key, this.label, this.content, this.labelWidth = 100, this.crossAxisAlignment = CrossAxisAlignment.center})
|
{Key? key,
|
||||||
|
this.label,
|
||||||
|
required this.content,
|
||||||
|
this.labelWidth = 100,
|
||||||
|
this.crossAxisAlignment = CrossAxisAlignment.center})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final Widget label;
|
final Widget? label;
|
||||||
final Widget content;
|
final Widget content;
|
||||||
final double labelWidth;
|
final double labelWidth;
|
||||||
final CrossAxisAlignment crossAxisAlignment;
|
final CrossAxisAlignment crossAxisAlignment;
|
||||||
|
|
|
@ -7,19 +7,21 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class ConfigPageItem extends StatelessWidget {
|
class ConfigPageItem extends StatelessWidget {
|
||||||
const ConfigPageItem(
|
const ConfigPageItem(
|
||||||
{Key key,
|
{Key? key,
|
||||||
this.label,
|
this.label,
|
||||||
this.content,
|
this.content,
|
||||||
this.labelWidth = 100,
|
this.labelWidth = 100,
|
||||||
this.onPressed,
|
this.onPressed,
|
||||||
|
this.disabled = false,
|
||||||
this.crossAxisAlignment = CrossAxisAlignment.center})
|
this.crossAxisAlignment = CrossAxisAlignment.center})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final Widget label;
|
final Widget? label;
|
||||||
final Widget content;
|
final Widget? content;
|
||||||
final double labelWidth;
|
final double labelWidth;
|
||||||
final CrossAxisAlignment crossAxisAlignment;
|
final CrossAxisAlignment crossAxisAlignment;
|
||||||
final onPressed;
|
final onPressed;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -28,8 +30,8 @@ class ConfigPageItem extends StatelessWidget {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final origTheme = Theme.of(context);
|
final origTheme = Theme.of(context);
|
||||||
theme = origTheme.copyWith(
|
theme = origTheme.copyWith(
|
||||||
textTheme:
|
textTheme: origTheme.textTheme
|
||||||
origTheme.textTheme.copyWith(button: origTheme.textTheme.button.copyWith(fontWeight: FontWeight.normal)));
|
.copyWith(button: origTheme.textTheme.button!.copyWith(fontWeight: FontWeight.normal)));
|
||||||
return Theme(data: theme, child: _buildContent(context));
|
return Theme(data: theme, child: _buildContent(context));
|
||||||
} else {
|
} else {
|
||||||
final origTheme = CupertinoTheme.of(context);
|
final origTheme = CupertinoTheme.of(context);
|
||||||
|
@ -40,7 +42,7 @@ class ConfigPageItem extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context) {
|
Widget _buildContent(BuildContext context) {
|
||||||
return SpecialButton(
|
return SpecialButton(
|
||||||
onPressed: onPressed,
|
onPressed: this.disabled ? null : onPressed,
|
||||||
color: Utils.configItemBackground(context),
|
color: Utils.configItemBackground(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.only(left: 15),
|
padding: EdgeInsets.only(left: 15),
|
||||||
|
@ -50,7 +52,7 @@ class ConfigPageItem extends StatelessWidget {
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
label != null ? Container(width: labelWidth, child: label) : Container(),
|
label != null ? Container(width: labelWidth, child: label) : Container(),
|
||||||
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))),
|
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))),
|
||||||
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
this.disabled ? Container() : Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
import 'ConfigHeader.dart';
|
import 'ConfigHeader.dart';
|
||||||
|
|
||||||
class ConfigSection extends StatelessWidget {
|
class ConfigSection extends StatelessWidget {
|
||||||
const ConfigSection({Key key, this.label, this.children, this.borderColor, this.labelColor}) : super(key: key);
|
const ConfigSection({Key? key, this.label, required this.children, this.borderColor, this.labelColor})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
final String label;
|
final String? label;
|
||||||
final Color borderColor;
|
final Color? borderColor;
|
||||||
final Color labelColor;
|
final Color? labelColor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -33,7 +33,7 @@ class ConfigSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
label != null ? ConfigHeader(label: label, color: labelColor) : Container(height: 20),
|
label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20),
|
||||||
Container(
|
Container(
|
||||||
decoration:
|
decoration:
|
||||||
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)),
|
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)),
|
||||||
|
|
|
@ -5,10 +5,13 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
|
|
||||||
class ConfigTextItem extends StatelessWidget {
|
class ConfigTextItem extends StatelessWidget {
|
||||||
const ConfigTextItem({Key key, this.placeholder, this.controller}) : super(key: key);
|
const ConfigTextItem(
|
||||||
|
{Key? key, this.placeholder, this.controller, this.style = const TextStyle(fontFamily: 'RobotoMono')})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final String placeholder;
|
final String? placeholder;
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
final TextStyle style;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -19,7 +22,7 @@ class ConfigTextItem extends StatelessWidget {
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
maxLines: 10,
|
maxLines: 10,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
style: TextStyle(fontFamily: 'RobotoMono'),
|
style: style,
|
||||||
controller: controller));
|
controller: controller));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
|
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
|
||||||
import 'package:flutter/material.dart'
|
import 'package:flutter/material.dart'
|
||||||
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode;
|
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode;
|
||||||
|
@ -6,11 +8,16 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/screens/MainScreen.dart';
|
import 'package:mobile_nebula/screens/MainScreen.dart';
|
||||||
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
||||||
import 'package:mobile_nebula/services/settings.dart';
|
import 'package:mobile_nebula/services/settings.dart';
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
//TODO: EventChannel might be better than the stream controller we are using now
|
//TODO: EventChannel might be better than the stream controller we are using now
|
||||||
|
|
||||||
void main() => runApp(Main());
|
void main() {
|
||||||
|
usePathUrlStrategy();
|
||||||
|
runApp(Main());
|
||||||
|
}
|
||||||
|
|
||||||
class Main extends StatelessWidget {
|
class Main extends StatelessWidget {
|
||||||
// This widget is the root of your application.
|
// This widget is the root of your application.
|
||||||
|
@ -26,6 +33,7 @@ class App extends StatefulWidget {
|
||||||
class _AppState extends State<App> {
|
class _AppState extends State<App> {
|
||||||
final settings = Settings();
|
final settings = Settings();
|
||||||
Brightness brightness = SchedulerBinding.instance.window.platformBrightness;
|
Brightness brightness = SchedulerBinding.instance.window.platformBrightness;
|
||||||
|
StreamController dnEnrolled = StreamController.broadcast();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -41,6 +49,12 @@ class _AppState extends State<App> {
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
dnEnrolled.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ThemeData lightTheme = ThemeData(
|
final ThemeData lightTheme = ThemeData(
|
||||||
|
@ -85,15 +99,33 @@ class _AppState extends State<App> {
|
||||||
DefaultCupertinoLocalizations.delegate,
|
DefaultCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
title: 'Nebula',
|
title: 'Nebula',
|
||||||
android: (_) {
|
material: (_, __) {
|
||||||
return new MaterialAppData(
|
return new MaterialAppData(
|
||||||
themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark,
|
themeMode: brightness == Brightness.light ? ThemeMode.light : ThemeMode.dark,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
ios: (_) => CupertinoAppData(
|
cupertino: (_, __) => CupertinoAppData(
|
||||||
theme: CupertinoThemeData(brightness: brightness),
|
theme: CupertinoThemeData(brightness: brightness),
|
||||||
),
|
),
|
||||||
home: MainScreen(),
|
onGenerateRoute: (settings) {
|
||||||
|
if (settings.name == '/') {
|
||||||
|
return platformPageRoute(context: context, builder: (context) => MainScreen(this.dnEnrolled));
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse(settings.name!);
|
||||||
|
if (uri.path == EnrollmentScreen.routeName) {
|
||||||
|
// TODO: maybe implement this as a dialog instead of a page, you can stack multiple enrollment screens which is annoying in dev
|
||||||
|
return platformPageRoute(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => EnrollmentScreen(
|
||||||
|
code: EnrollmentScreen.parseCode(settings.name!),
|
||||||
|
stream: this.dnEnrolled
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class CIDR {
|
class CIDR {
|
||||||
CIDR({this.ip, this.bits});
|
CIDR({this.ip = '', this.bits = 0});
|
||||||
|
|
||||||
String ip;
|
String ip;
|
||||||
int bits;
|
int bits;
|
||||||
|
@ -13,13 +13,15 @@ class CIDR {
|
||||||
return toString();
|
return toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
CIDR.fromString(String val) {
|
factory CIDR.fromString(String val) {
|
||||||
final parts = val.split('/');
|
final parts = val.split('/');
|
||||||
if (parts.length != 2) {
|
if (parts.length != 2) {
|
||||||
throw 'Invalid CIDR string';
|
throw 'Invalid CIDR string';
|
||||||
}
|
}
|
||||||
|
|
||||||
ip = parts[0];
|
return CIDR(
|
||||||
bits = int.parse(parts[1]);
|
ip: parts[0],
|
||||||
|
bits: int.parse(parts[1]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
class CertificateInfo {
|
class CertificateInfo {
|
||||||
Certificate cert;
|
Certificate cert;
|
||||||
String rawCert;
|
String? rawCert;
|
||||||
CertificateValidity validity;
|
CertificateValidity? validity;
|
||||||
|
|
||||||
CertificateInfo.debug({this.rawCert = ""})
|
CertificateInfo.debug({this.rawCert = ""})
|
||||||
: this.cert = Certificate.debug(),
|
: this.cert = Certificate.debug(),
|
||||||
|
@ -12,10 +12,10 @@ class CertificateInfo {
|
||||||
rawCert = json['RawCert'],
|
rawCert = json['RawCert'],
|
||||||
validity = CertificateValidity.fromJson(json['Validity']);
|
validity = CertificateValidity.fromJson(json['Validity']);
|
||||||
|
|
||||||
CertificateInfo({this.cert, this.rawCert, this.validity});
|
CertificateInfo({required this.cert, this.rawCert, this.validity});
|
||||||
|
|
||||||
static List<CertificateInfo> fromJsonList(List<dynamic> list) {
|
static List<CertificateInfo> fromJsonList(List<dynamic> list) {
|
||||||
return list.map((v) => CertificateInfo.fromJson(v));
|
return list.map((v) => CertificateInfo.fromJson(v)).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,8 +59,8 @@ class CertificateDetails {
|
||||||
|
|
||||||
CertificateDetails.fromJson(Map<String, dynamic> json)
|
CertificateDetails.fromJson(Map<String, dynamic> json)
|
||||||
: name = json['name'],
|
: name = json['name'],
|
||||||
notBefore = DateTime.tryParse(json['notBefore']),
|
notBefore = DateTime.parse(json['notBefore']),
|
||||||
notAfter = DateTime.tryParse(json['notAfter']),
|
notAfter = DateTime.parse(json['notAfter']),
|
||||||
publicKey = json['publicKey'],
|
publicKey = json['publicKey'],
|
||||||
groups = List<String>.from(json['groups']),
|
groups = List<String>.from(json['groups']),
|
||||||
ips = List<String>.from(json['ips']),
|
ips = List<String>.from(json['ips']),
|
||||||
|
|
|
@ -6,31 +6,48 @@ class HostInfo {
|
||||||
int remoteIndex;
|
int remoteIndex;
|
||||||
List<UDPAddress> remoteAddresses;
|
List<UDPAddress> remoteAddresses;
|
||||||
int cachedPackets;
|
int cachedPackets;
|
||||||
Certificate cert;
|
Certificate? cert;
|
||||||
UDPAddress currentRemote;
|
UDPAddress? currentRemote;
|
||||||
int messageCounter;
|
int messageCounter;
|
||||||
|
|
||||||
HostInfo.fromJson(Map<String, dynamic> json) {
|
HostInfo({
|
||||||
vpnIp = json['vpnIp'];
|
required this.vpnIp,
|
||||||
localIndex = json['localIndex'];
|
required this.localIndex,
|
||||||
remoteIndex = json['remoteIndex'];
|
required this.remoteIndex,
|
||||||
cachedPackets = json['cachedPackets'];
|
required this.remoteAddresses,
|
||||||
|
required this.cachedPackets,
|
||||||
|
required this.messageCounter,
|
||||||
|
this.cert,
|
||||||
|
this.currentRemote,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory HostInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
UDPAddress? currentRemote;
|
||||||
if (json['currentRemote'] != null) {
|
if (json['currentRemote'] != null) {
|
||||||
currentRemote = UDPAddress.fromJson(json['currentRemote']);
|
currentRemote = UDPAddress.fromJson(json['currentRemote']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Certificate? cert;
|
||||||
if (json['cert'] != null) {
|
if (json['cert'] != null) {
|
||||||
cert = Certificate.fromJson(json['cert']);
|
cert = Certificate.fromJson(json['cert']);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<dynamic> addrs = json['remoteAddrs'];
|
List<dynamic>? addrs = json['remoteAddrs'];
|
||||||
remoteAddresses = [];
|
List<UDPAddress> remoteAddresses = [];
|
||||||
addrs?.forEach((val) {
|
addrs?.forEach((val) {
|
||||||
remoteAddresses.add(UDPAddress.fromJson(val));
|
remoteAddresses.add(UDPAddress.fromJson(val));
|
||||||
});
|
});
|
||||||
|
|
||||||
messageCounter = json['messageCounter'];
|
return HostInfo(
|
||||||
|
vpnIp: json['vpnIp'],
|
||||||
|
localIndex: json['localIndex'],
|
||||||
|
remoteIndex: json['remoteIndex'],
|
||||||
|
remoteAddresses: remoteAddresses,
|
||||||
|
cachedPackets: json['cachedPackets'],
|
||||||
|
messageCounter: json['messageCounter'],
|
||||||
|
cert: cert,
|
||||||
|
currentRemote: currentRemote,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,10 +57,15 @@ class UDPAddress {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
// Simple check on if nebula told us about a v4 or v6 ip address
|
||||||
|
if (ip.contains(':')) {
|
||||||
|
return '[$ip]:$port';
|
||||||
|
}
|
||||||
|
|
||||||
return '$ip:$port';
|
return '$ip:$port';
|
||||||
}
|
}
|
||||||
|
|
||||||
UDPAddress.fromJson(Map<String, dynamic> json)
|
UDPAddress.fromJson(Map<String, dynamic> json)
|
||||||
: ip = json['IP'],
|
: ip = json['ip'],
|
||||||
port = json['Port'];
|
port = json['port'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,5 +5,5 @@ class Hostmap {
|
||||||
List<IPAndPort> destinations;
|
List<IPAndPort> destinations;
|
||||||
bool lighthouse;
|
bool lighthouse;
|
||||||
|
|
||||||
Hostmap({this.nebulaIp, this.destinations, this.lighthouse});
|
Hostmap({required this.nebulaIp, required this.destinations, required this.lighthouse});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,15 @@
|
||||||
class IPAndPort {
|
class IPAndPort {
|
||||||
IPAndPort({this.ip, this.port});
|
String? ip;
|
||||||
|
int? port;
|
||||||
|
|
||||||
String ip;
|
IPAndPort({this.ip, this.port});
|
||||||
int port;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String toString() {
|
String toString() {
|
||||||
|
if (ip != null && ip!.contains(':')) {
|
||||||
|
return '[$ip]:$port';
|
||||||
|
}
|
||||||
|
|
||||||
return '$ip:$port';
|
return '$ip:$port';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -13,13 +17,13 @@ class IPAndPort {
|
||||||
return toString();
|
return toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
IPAndPort.fromString(String val) {
|
factory IPAndPort.fromString(String val) {
|
||||||
final parts = val.split(':');
|
//TODO: Uri.parse is as close as I could get to parsing both ipv4 and v6 addresses with a port without bringing a whole mess of code into here
|
||||||
if (parts.length != 2) {
|
final uri = Uri.parse("ugh://$val");
|
||||||
throw 'Invalid IPAndPort string';
|
|
||||||
}
|
|
||||||
|
|
||||||
ip = parts[0];
|
return IPAndPort(
|
||||||
port = int.parse(parts[1]);
|
ip: uri.host,
|
||||||
|
port: uri.port,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:mobile_nebula/models/HostInfo.dart';
|
import 'package:mobile_nebula/models/HostInfo.dart';
|
||||||
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
||||||
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'Certificate.dart';
|
import 'Certificate.dart';
|
||||||
import 'StaticHosts.dart';
|
import 'StaticHosts.dart';
|
||||||
|
@ -12,129 +13,217 @@ var uuid = Uuid();
|
||||||
|
|
||||||
class Site {
|
class Site {
|
||||||
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
EventChannel _updates;
|
late EventChannel _updates;
|
||||||
|
|
||||||
/// Signals that something about this site has changed. onError is called with an error string if there was an error
|
/// Signals that something about this site has changed. onError is called with an error string if there was an error
|
||||||
StreamController _change = StreamController.broadcast();
|
StreamController _change = StreamController.broadcast();
|
||||||
|
|
||||||
// Identifiers
|
// Identifiers
|
||||||
String name;
|
late String name;
|
||||||
String id;
|
late String id;
|
||||||
|
|
||||||
// static_host_map
|
// static_host_map
|
||||||
Map<String, StaticHost> staticHostmap;
|
late Map<String, StaticHost> staticHostmap;
|
||||||
List<UnsafeRoute> unsafeRoutes;
|
late List<UnsafeRoute> unsafeRoutes;
|
||||||
|
late List<String> dnsResolvers;
|
||||||
|
|
||||||
// pki fields
|
// pki fields
|
||||||
List<CertificateInfo> ca;
|
late List<CertificateInfo> ca;
|
||||||
CertificateInfo cert;
|
String? key;
|
||||||
String key;
|
late CertificateInfo? certInfo;
|
||||||
|
|
||||||
// lighthouse options
|
// lighthouse options
|
||||||
int lhDuration; // in seconds
|
late int lhDuration; // in seconds
|
||||||
|
|
||||||
// listen settings
|
// listen settings
|
||||||
int port;
|
late int port;
|
||||||
int mtu;
|
late int mtu;
|
||||||
|
|
||||||
String cipher;
|
late String cipher;
|
||||||
int sortKey;
|
late int sortKey;
|
||||||
bool connected;
|
late bool connected;
|
||||||
String status;
|
late String status;
|
||||||
String logFile;
|
late String logFile;
|
||||||
String logVerbosity;
|
late String logVerbosity;
|
||||||
bool logLocalTZ;
|
|
||||||
|
late bool managed;
|
||||||
|
// The following fields are present when managed = true
|
||||||
|
late String? rawConfig;
|
||||||
|
late DateTime? lastManagedUpdate;
|
||||||
|
|
||||||
// A list of errors encountered while loading the site
|
// A list of errors encountered while loading the site
|
||||||
List<String> errors;
|
late List<String> errors;
|
||||||
|
|
||||||
Site(
|
Site({
|
||||||
{this.name,
|
String name = '',
|
||||||
id,
|
String? id,
|
||||||
staticHostmap,
|
Map<String, StaticHost>? staticHostmap,
|
||||||
ca,
|
List<CertificateInfo>? ca,
|
||||||
this.cert,
|
CertificateInfo? certInfo,
|
||||||
this.lhDuration = 0,
|
int lhDuration = 0,
|
||||||
this.port = 0,
|
int port = 0,
|
||||||
this.cipher = "aes",
|
String cipher = "aes",
|
||||||
this.sortKey,
|
int sortKey = 0,
|
||||||
this.mtu = 1300,
|
int mtu = 1300,
|
||||||
this.connected,
|
bool connected = false,
|
||||||
this.status,
|
String status = '',
|
||||||
this.logFile,
|
String logFile = '',
|
||||||
this.logVerbosity = 'info',
|
String logVerbosity = 'info',
|
||||||
this.logLocalTZ,
|
List<String>? errors,
|
||||||
errors,
|
List<UnsafeRoute>? unsafeRoutes,
|
||||||
unsafeRoutes})
|
List<String>? dnsResolvers,
|
||||||
: staticHostmap = staticHostmap ?? {},
|
bool managed = false,
|
||||||
unsafeRoutes = unsafeRoutes ?? [],
|
String? rawConfig,
|
||||||
errors = errors ?? [],
|
DateTime? lastManagedUpdate,
|
||||||
ca = ca ?? [],
|
}) {
|
||||||
id = id ?? uuid.v4();
|
this.name = name;
|
||||||
|
this.id = id ?? uuid.v4();
|
||||||
Site.fromJson(Map<String, dynamic> json) {
|
this.staticHostmap = staticHostmap ?? {};
|
||||||
name = json['name'];
|
this.ca = ca ?? [];
|
||||||
id = json['id'];
|
this.certInfo = certInfo;
|
||||||
|
this.lhDuration = lhDuration;
|
||||||
Map<String, dynamic> rawHostmap = json['staticHostmap'];
|
this.port = port;
|
||||||
staticHostmap = {};
|
this.cipher = cipher;
|
||||||
rawHostmap.forEach((key, val) {
|
this.sortKey = sortKey;
|
||||||
staticHostmap[key] = StaticHost.fromJson(val);
|
this.mtu = mtu;
|
||||||
});
|
this.connected = connected;
|
||||||
|
this.status = status;
|
||||||
List<dynamic> rawUnsafeRoutes = json['unsafeRoutes'];
|
this.logFile = logFile;
|
||||||
unsafeRoutes = [];
|
this.logVerbosity = logVerbosity;
|
||||||
if (rawUnsafeRoutes != null) {
|
this.errors = errors ?? [];
|
||||||
rawUnsafeRoutes.forEach((val) {
|
this.unsafeRoutes = unsafeRoutes ?? [];
|
||||||
unsafeRoutes.add(UnsafeRoute.fromJson(val));
|
this.dnsResolvers = dnsResolvers ?? [];
|
||||||
});
|
this.managed = managed;
|
||||||
}
|
this.rawConfig = rawConfig;
|
||||||
|
this.lastManagedUpdate = lastManagedUpdate;
|
||||||
List<dynamic> rawCA = json['ca'];
|
|
||||||
ca = [];
|
|
||||||
rawCA.forEach((val) {
|
|
||||||
ca.add(CertificateInfo.fromJson(val));
|
|
||||||
});
|
|
||||||
|
|
||||||
if (json['cert'] != null) {
|
|
||||||
cert = CertificateInfo.fromJson(json['cert']);
|
|
||||||
}
|
|
||||||
|
|
||||||
lhDuration = json['lhDuration'];
|
|
||||||
port = json['port'];
|
|
||||||
mtu = json['mtu'];
|
|
||||||
cipher = json['cipher'];
|
|
||||||
sortKey = json['sortKey'];
|
|
||||||
logFile = json['logFile'];
|
|
||||||
logVerbosity = json['logVerbosity'];
|
|
||||||
logLocalTZ = json['logLocalTZ'];
|
|
||||||
connected = json['connected'] ?? false;
|
|
||||||
status = json['status'] ?? "";
|
|
||||||
|
|
||||||
errors = [];
|
|
||||||
List<dynamic> rawErrors = json["errors"];
|
|
||||||
rawErrors.forEach((error) {
|
|
||||||
errors.add(error);
|
|
||||||
});
|
|
||||||
|
|
||||||
_updates = EventChannel('net.defined.nebula/$id');
|
_updates = EventChannel('net.defined.nebula/$id');
|
||||||
_updates.receiveBroadcastStream().listen((d) {
|
_updates.receiveBroadcastStream().listen((d) {
|
||||||
try {
|
try {
|
||||||
this.status = d['status'];
|
_updateFromJson(d);
|
||||||
this.connected = d['connected'];
|
|
||||||
_change.add(null);
|
_change.add(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//TODO: handle the error
|
//TODO: handle the error
|
||||||
print(err);
|
print(err);
|
||||||
}
|
}
|
||||||
}, onError: (err) {
|
}, onError: (err) {
|
||||||
|
_updateFromJson(err.details);
|
||||||
var error = err as PlatformException;
|
var error = err as PlatformException;
|
||||||
this.status = error.details['status'];
|
_change.addError(error.message ?? 'An unexpected error occurred');
|
||||||
this.connected = error.details['connected'];
|
|
||||||
_change.addError(error.message);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
factory Site.fromJson(Map<String, dynamic> json) {
|
||||||
|
var decoded = Site._fromJson(json);
|
||||||
|
return Site(
|
||||||
|
name: decoded["name"],
|
||||||
|
id: decoded['id'],
|
||||||
|
staticHostmap: decoded['staticHostmap'],
|
||||||
|
ca: decoded['ca'],
|
||||||
|
certInfo: decoded['certInfo'],
|
||||||
|
lhDuration: decoded['lhDuration'],
|
||||||
|
port: decoded['port'],
|
||||||
|
cipher: decoded['cipher'],
|
||||||
|
sortKey: decoded['sortKey'],
|
||||||
|
mtu: decoded['mtu'],
|
||||||
|
connected: decoded['connected'],
|
||||||
|
status: decoded['status'],
|
||||||
|
logFile: decoded['logFile'],
|
||||||
|
logVerbosity: decoded['logVerbosity'],
|
||||||
|
errors: decoded['errors'],
|
||||||
|
unsafeRoutes: decoded['unsafeRoutes'],
|
||||||
|
dnsResolvers: decoded['dnsResolvers'],
|
||||||
|
managed: decoded['managed'],
|
||||||
|
rawConfig: decoded['rawConfig'],
|
||||||
|
lastManagedUpdate: decoded['lastManagedUpdate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFromJson(String json) {
|
||||||
|
var decoded = Site._fromJson(jsonDecode(json));
|
||||||
|
this.name = decoded["name"];
|
||||||
|
this.id = decoded['id']; // TODO update EventChannel
|
||||||
|
this.staticHostmap = decoded['staticHostmap'];
|
||||||
|
this.ca = decoded['ca'];
|
||||||
|
this.certInfo = decoded['certInfo'];
|
||||||
|
this.lhDuration = decoded['lhDuration'];
|
||||||
|
this.port = decoded['port'];
|
||||||
|
this.cipher = decoded['cipher'];
|
||||||
|
this.sortKey = decoded['sortKey'];
|
||||||
|
this.mtu = decoded['mtu'];
|
||||||
|
this.connected = decoded['connected'];
|
||||||
|
this.status = decoded['status'];
|
||||||
|
this.logFile = decoded['logFile'];
|
||||||
|
this.logVerbosity = decoded['logVerbosity'];
|
||||||
|
this.errors = decoded['errors'];
|
||||||
|
this.unsafeRoutes = decoded['unsafeRoutes'];
|
||||||
|
this.dnsResolvers = decoded['dnsResolvers'];
|
||||||
|
this.managed = decoded['managed'];
|
||||||
|
this.rawConfig = decoded['rawConfig'];
|
||||||
|
this.lastManagedUpdate = decoded['lastManagedUpdate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _fromJson(Map<String, dynamic> json) {
|
||||||
|
Map<String, dynamic> rawHostmap = json['staticHostmap'];
|
||||||
|
Map<String, StaticHost> staticHostmap = {};
|
||||||
|
rawHostmap.forEach((key, val) {
|
||||||
|
staticHostmap[key] = StaticHost.fromJson(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<dynamic> rawUnsafeRoutes = json['unsafeRoutes'];
|
||||||
|
List<UnsafeRoute> unsafeRoutes = [];
|
||||||
|
rawUnsafeRoutes.forEach((val) {
|
||||||
|
unsafeRoutes.add(UnsafeRoute.fromJson(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
List<dynamic> rawDNSResolvers = json['dnsResolvers'];
|
||||||
|
List<String> dnsResolvers = [];
|
||||||
|
rawDNSResolvers.forEach((val) {
|
||||||
|
dnsResolvers.add(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<dynamic> rawCA = json['ca'];
|
||||||
|
List<CertificateInfo> ca = [];
|
||||||
|
rawCA.forEach((val) {
|
||||||
|
ca.add(CertificateInfo.fromJson(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
CertificateInfo? certInfo;
|
||||||
|
if (json['cert'] != null) {
|
||||||
|
certInfo = CertificateInfo.fromJson(json['cert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> rawErrors = json["errors"];
|
||||||
|
List<String> errors = [];
|
||||||
|
rawErrors.forEach((error) {
|
||||||
|
errors.add(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": json["name"],
|
||||||
|
"id": json['id'],
|
||||||
|
"staticHostmap": staticHostmap,
|
||||||
|
"ca": ca,
|
||||||
|
"certInfo": certInfo,
|
||||||
|
"lhDuration": json['lhDuration'],
|
||||||
|
"port": json['port'],
|
||||||
|
"cipher": json['cipher'],
|
||||||
|
"sortKey": json['sortKey'],
|
||||||
|
"mtu": json['mtu'],
|
||||||
|
"connected": json['connected'] ?? false,
|
||||||
|
"status": json['status'] ?? "",
|
||||||
|
"logFile": json['logFile'],
|
||||||
|
"logVerbosity": json['logVerbosity'],
|
||||||
|
"errors": errors,
|
||||||
|
"unsafeRoutes": unsafeRoutes,
|
||||||
|
"dnsResolvers": dnsResolvers,
|
||||||
|
"managed": json['managed'] ?? false,
|
||||||
|
"rawConfig": json['rawConfig'],
|
||||||
|
"lastManagedUpdate": json["lastManagedUpdate"] == null ?
|
||||||
|
null : DateTime.parse(json["lastManagedUpdate"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Stream onChange() {
|
Stream onChange() {
|
||||||
return _change.stream;
|
return _change.stream;
|
||||||
}
|
}
|
||||||
|
@ -145,11 +234,11 @@ class Site {
|
||||||
'id': id,
|
'id': id,
|
||||||
'staticHostmap': staticHostmap,
|
'staticHostmap': staticHostmap,
|
||||||
'unsafeRoutes': unsafeRoutes,
|
'unsafeRoutes': unsafeRoutes,
|
||||||
'ca': ca?.map((cert) {
|
'dnsResolvers': dnsResolvers,
|
||||||
|
'ca': ca.map((cert) {
|
||||||
return cert.rawCert;
|
return cert.rawCert;
|
||||||
})?.join('\n') ??
|
}).join('\n'),
|
||||||
"",
|
'cert': certInfo?.rawCert,
|
||||||
'cert': cert?.rawCert,
|
|
||||||
'key': key,
|
'key': key,
|
||||||
'lhDuration': lhDuration,
|
'lhDuration': lhDuration,
|
||||||
'port': port,
|
'port': port,
|
||||||
|
@ -157,7 +246,8 @@ class Site {
|
||||||
'cipher': cipher,
|
'cipher': cipher,
|
||||||
'sortKey': sortKey,
|
'sortKey': sortKey,
|
||||||
'logVerbosity': logVerbosity,
|
'logVerbosity': logVerbosity,
|
||||||
'logLocalTZ': logLocalTZ,
|
'managed': managed,
|
||||||
|
'rawConfig': rawConfig,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -189,8 +279,7 @@ class Site {
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod("startSite", <String, String>{"id": id});
|
await platform.invokeMethod("startSite", <String, String>{"id": id});
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
//TODO: fix this message
|
throw err.message ?? err.toString();
|
||||||
throw err.details ?? err.message ?? err.toString();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
throw err.toString();
|
throw err.toString();
|
||||||
}
|
}
|
||||||
|
@ -210,7 +299,7 @@ class Site {
|
||||||
Future<List<HostInfo>> listHostmap() async {
|
Future<List<HostInfo>> listHostmap() async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.listHostmap", <String, String>{"id": id});
|
var ret = await platform.invokeMethod("active.listHostmap", <String, String>{"id": id});
|
||||||
if (ret == null) {
|
if (ret == null || ret == "null") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -221,7 +310,6 @@ class Site {
|
||||||
});
|
});
|
||||||
|
|
||||||
return hosts;
|
return hosts;
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
//TODO: fix this message
|
//TODO: fix this message
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
|
@ -233,7 +321,7 @@ class Site {
|
||||||
Future<List<HostInfo>> listPendingHostmap() async {
|
Future<List<HostInfo>> listPendingHostmap() async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.listPendingHostmap", <String, String>{"id": id});
|
var ret = await platform.invokeMethod("active.listPendingHostmap", <String, String>{"id": id});
|
||||||
if (ret == null) {
|
if (ret == null || ret == "null") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -244,7 +332,6 @@ class Site {
|
||||||
});
|
});
|
||||||
|
|
||||||
return hosts;
|
return hosts;
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -256,7 +343,6 @@ class Site {
|
||||||
try {
|
try {
|
||||||
var res = await Future.wait([this.listHostmap(), this.listPendingHostmap()]);
|
var res = await Future.wait([this.listHostmap(), this.listPendingHostmap()]);
|
||||||
return {"active": res[0], "pending": res[1]};
|
return {"active": res[0], "pending": res[1]};
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -268,16 +354,16 @@ class Site {
|
||||||
_change.close();
|
_change.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<HostInfo> getHostInfo(String vpnIp, bool pending) async {
|
Future<HostInfo?> getHostInfo(String vpnIp, bool pending) async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.getHostInfo", <String, dynamic>{"id": id, "vpnIp": vpnIp, "pending": pending});
|
var ret = await platform
|
||||||
|
.invokeMethod("active.getHostInfo", <String, dynamic>{"id": id, "vpnIp": vpnIp, "pending": pending});
|
||||||
final h = jsonDecode(ret);
|
final h = jsonDecode(ret);
|
||||||
if (h == null) {
|
if (h == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return HostInfo.fromJson(h);
|
return HostInfo.fromJson(h);
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -285,16 +371,16 @@ class Site {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<HostInfo> setRemoteForTunnel(String vpnIp, String addr) async {
|
Future<HostInfo?> setRemoteForTunnel(String vpnIp, String addr) async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.setRemoteForTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp, "addr": addr});
|
var ret = await platform
|
||||||
|
.invokeMethod("active.setRemoteForTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp, "addr": addr});
|
||||||
final h = jsonDecode(ret);
|
final h = jsonDecode(ret);
|
||||||
if (h == null) {
|
if (h == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return HostInfo.fromJson(h);
|
return HostInfo.fromJson(h);
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -305,7 +391,6 @@ class Site {
|
||||||
Future<bool> closeTunnel(String vpnIp) async {
|
Future<bool> closeTunnel(String vpnIp) async {
|
||||||
try {
|
try {
|
||||||
return await platform.invokeMethod("active.closeTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp});
|
return await platform.invokeMethod("active.closeTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp});
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -4,19 +4,20 @@ class StaticHost {
|
||||||
bool lighthouse;
|
bool lighthouse;
|
||||||
List<IPAndPort> destinations;
|
List<IPAndPort> destinations;
|
||||||
|
|
||||||
StaticHost({this.lighthouse, this.destinations});
|
StaticHost({required this.lighthouse, required this.destinations});
|
||||||
|
|
||||||
StaticHost.fromJson(Map<String, dynamic> json) {
|
|
||||||
lighthouse = json['lighthouse'];
|
|
||||||
|
|
||||||
|
factory StaticHost.fromJson(Map<String, dynamic> json) {
|
||||||
var list = json['destinations'] as List<dynamic>;
|
var list = json['destinations'] as List<dynamic>;
|
||||||
var result = List<IPAndPort>();
|
var result = <IPAndPort>[];
|
||||||
|
|
||||||
list.forEach((item) {
|
list.forEach((item) {
|
||||||
result.add(IPAndPort.fromString(item));
|
result.add(IPAndPort.fromString(item));
|
||||||
});
|
});
|
||||||
|
|
||||||
destinations = result;
|
return StaticHost(
|
||||||
|
lighthouse: json['lighthouse'],
|
||||||
|
destinations: result,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
class UnsafeRoute {
|
class UnsafeRoute {
|
||||||
String route;
|
String? route;
|
||||||
String via;
|
String? via;
|
||||||
|
|
||||||
UnsafeRoute({this.route, this.via});
|
UnsafeRoute({this.route, this.via});
|
||||||
|
|
||||||
UnsafeRoute.fromJson(Map<String, dynamic> json) {
|
factory UnsafeRoute.fromJson(Map<String, dynamic> json) {
|
||||||
route = json['route'];
|
return UnsafeRoute(
|
||||||
via = json['via'];
|
route: json['route'],
|
||||||
|
via: json['via'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
|
@ -1,11 +1,6 @@
|
||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialSelectableText.dart';
|
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
@ -14,7 +9,7 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:package_info/package_info.dart';
|
import 'package:package_info/package_info.dart';
|
||||||
|
|
||||||
class AboutScreen extends StatefulWidget {
|
class AboutScreen extends StatefulWidget {
|
||||||
const AboutScreen({Key key}) : super(key: key);
|
const AboutScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_AboutScreenState createState() => _AboutScreenState();
|
_AboutScreenState createState() => _AboutScreenState();
|
||||||
|
@ -22,7 +17,7 @@ class AboutScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _AboutScreenState extends State<AboutScreen> {
|
class _AboutScreenState extends State<AboutScreen> {
|
||||||
bool ready = false;
|
bool ready = false;
|
||||||
PackageInfo packageInfo;
|
PackageInfo? packageInfo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -38,35 +33,54 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// packageInfo is null until ready is true
|
||||||
if (!ready) {
|
if (!ready) {
|
||||||
return Center(
|
return Center(
|
||||||
child: PlatformCircularProgressIndicator(ios: (_) {
|
child: PlatformCircularProgressIndicator(cupertino: (_, __) {
|
||||||
return CupertinoProgressIndicatorData(radius: 50);
|
return CupertinoProgressIndicatorData(radius: 50);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: 'About',
|
title: Text('About'),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
ConfigSection(children: <Widget>[
|
ConfigSection(children: <Widget>[
|
||||||
ConfigItem(label: Text('App version'), labelWidth: 150, content: _buildText('${packageInfo.version}-${packageInfo.buildNumber} (sha: $gitSha)')),
|
ConfigItem(
|
||||||
ConfigItem(label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')),
|
label: Text('App version'),
|
||||||
ConfigItem(label: Text('Flutter version'), labelWidth: 150, content: _buildText(flutterVersion['frameworkVersion'])),
|
labelWidth: 150,
|
||||||
ConfigItem(label: Text('Dart version'), labelWidth: 150, content: _buildText(flutterVersion['dartSdkVersion'])),
|
content: _buildText('${packageInfo!.version}-${packageInfo!.buildNumber} (sha: $gitSha)')),
|
||||||
|
ConfigItem(
|
||||||
|
label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')),
|
||||||
|
ConfigItem(
|
||||||
|
label: Text('Flutter version'),
|
||||||
|
labelWidth: 150,
|
||||||
|
content: _buildText(flutterVersion['frameworkVersion'] ?? 'Unknown')),
|
||||||
|
ConfigItem(
|
||||||
|
label: Text('Dart version'),
|
||||||
|
labelWidth: 150,
|
||||||
|
content: _buildText(flutterVersion['dartSdkVersion'] ?? 'Unknown')),
|
||||||
]),
|
]),
|
||||||
ConfigSection(children: <Widget>[
|
ConfigSection(children: <Widget>[
|
||||||
//TODO: wire up these other pages
|
//TODO: wire up these other pages
|
||||||
// ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)),
|
// ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)),
|
||||||
ConfigPageItem(label: Text('Privacy policy'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/privacy-policy', context)),
|
ConfigPageItem(
|
||||||
|
label: Text('Privacy policy'),
|
||||||
|
labelWidth: 300,
|
||||||
|
onPressed: () => Utils.launchUrl('https://www.defined.net/privacy/', context)),
|
||||||
// ConfigPageItem(label: Text('Licenses'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/license', context)),
|
// ConfigPageItem(label: Text('Licenses'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/license', context)),
|
||||||
]),
|
]),
|
||||||
Padding(padding: EdgeInsets.only(top: 20), child: Text('Copyright © 2020 Defined Networking, Inc', textAlign: TextAlign.center,)),
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 20),
|
||||||
|
child: Text(
|
||||||
|
'Copyright © 2022 Defined Networking, Inc',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildText(String str) {
|
_buildText(String str) {
|
||||||
return Align(alignment: AlignmentDirectional.centerEnd, child: SpecialSelectableText(str));
|
return Align(alignment: AlignmentDirectional.centerEnd, child: SelectableText(str));
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,186 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
|
|
||||||
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class EnrollmentScreen extends StatefulWidget {
|
||||||
|
final String? code;
|
||||||
|
final StreamController? stream;
|
||||||
|
final bool allowCodeEntry;
|
||||||
|
|
||||||
|
static const routeName = '/v1/mobile-enrollment';
|
||||||
|
|
||||||
|
// Attempts to find an enrollment code in the provided url. If one is not found then assume the input was
|
||||||
|
// an enrollment code. Primarily to support manual dn enrollment where the user can input a code or a url.
|
||||||
|
static String parseCode(String url) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (uri.path != EnrollmentScreen.routeName) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.hasFragment) {
|
||||||
|
final qp = Uri.splitQueryString(uri.fragment);
|
||||||
|
return qp["code"] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrollmentScreen({super.key, this.code, this.stream, this.allowCodeEntry = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_EnrollmentScreenState createState() => _EnrollmentScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||||
|
String? error;
|
||||||
|
var enrolled = false;
|
||||||
|
var enrollInput = TextEditingController();
|
||||||
|
String? code;
|
||||||
|
|
||||||
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
|
|
||||||
|
void initState() {
|
||||||
|
code = widget.code;
|
||||||
|
super.initState();
|
||||||
|
_enroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
enrollInput.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_enroll() async {
|
||||||
|
try {
|
||||||
|
await platform.invokeMethod("dn.enroll", code);
|
||||||
|
setState(() {
|
||||||
|
enrolled = true;
|
||||||
|
if (widget.stream != null) {
|
||||||
|
// Signal a new site has been added
|
||||||
|
widget.stream!.add(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} on PlatformException catch (err) {
|
||||||
|
setState(() {
|
||||||
|
error = err.details ?? err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final bodyTextStyle = textTheme.bodyLarge!.apply(color: colorScheme.onPrimary);
|
||||||
|
final contactUri = Uri.parse('mailto:support@defined.net');
|
||||||
|
|
||||||
|
Widget child;
|
||||||
|
AlignmentGeometry? alignment;
|
||||||
|
|
||||||
|
if (code == null) {
|
||||||
|
if (widget.allowCodeEntry) {
|
||||||
|
child = _codeEntry();
|
||||||
|
} else {
|
||||||
|
// No code, show the error
|
||||||
|
child = Padding(
|
||||||
|
child: Center(child: Text(
|
||||||
|
'No valid enrollment code was found.\n\nContact your administrator to obtain a new enrollment code.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
|
||||||
|
)),
|
||||||
|
padding: EdgeInsets.only(top: 20)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (this.error != null) {
|
||||||
|
// Error while enrolling, display it
|
||||||
|
child = Center(child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
child: SelectableText('There was an issue while attempting to enroll this device. Contact your administrator to obtain a new enrollment code.'),
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 20)
|
||||||
|
),
|
||||||
|
Padding(child: SelectableText.rich(TextSpan(children: [
|
||||||
|
TextSpan(text: 'If the problem persists, please let us know at '),
|
||||||
|
TextSpan(
|
||||||
|
text: 'support@defined.net',
|
||||||
|
style: bodyTextStyle.apply(color: colorScheme.primary),
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () async {
|
||||||
|
if (await canLaunchUrl(contactUri)) {
|
||||||
|
print(await launchUrl(contactUri));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextSpan(text: ' and provide the following error:'),
|
||||||
|
])), padding: EdgeInsets.only(bottom: 10)),
|
||||||
|
Container(
|
||||||
|
child: Padding(child: SelectableText(this.error!), padding: EdgeInsets.all(10)),
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
));
|
||||||
|
|
||||||
|
} else if (this.enrolled) {
|
||||||
|
// Enrollment complete!
|
||||||
|
child = Padding(
|
||||||
|
child: Center(child: Text(
|
||||||
|
'Enrollment complete! 🎉',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)),
|
||||||
|
padding: EdgeInsets.only(top: 20)
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Have a code and actively enrolling
|
||||||
|
alignment = Alignment.center;
|
||||||
|
child = Center(child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(child: Text('Contacting DN for enrollment'), padding: EdgeInsets.only(bottom: 25)),
|
||||||
|
PlatformCircularProgressIndicator(cupertino: (_, __) {
|
||||||
|
return CupertinoProgressIndicatorData(radius: 50);
|
||||||
|
})
|
||||||
|
]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
return SimplePage(
|
||||||
|
title: Text('Enroll with Managed Nebula', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
child: Padding(child: child, padding: EdgeInsets.symmetric(horizontal: 10)),
|
||||||
|
alignment: alignment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _codeEntry() {
|
||||||
|
return Column(children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 20),
|
||||||
|
child: PlatformTextField(
|
||||||
|
hintText: 'defined.net enrollment code or link',
|
||||||
|
controller: enrollInput,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PlatformTextButton(
|
||||||
|
child: Text('Submit'),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
code = EnrollmentScreen.parseCode(enrollInput.text);
|
||||||
|
error = null;
|
||||||
|
_enroll();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialSelectableText.dart';
|
|
||||||
import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
|
@ -16,15 +14,24 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
class HostInfoScreen extends StatefulWidget {
|
class HostInfoScreen extends StatefulWidget {
|
||||||
const HostInfoScreen({Key key, this.hostInfo, this.isLighthouse, this.pending, this.onChanged, this.site})
|
const HostInfoScreen({
|
||||||
: super(key: key);
|
Key? key,
|
||||||
|
required this.hostInfo,
|
||||||
|
required this.isLighthouse,
|
||||||
|
required this.pending,
|
||||||
|
this.onChanged,
|
||||||
|
required this.site,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
final bool isLighthouse;
|
final bool isLighthouse;
|
||||||
final bool pending;
|
final bool pending;
|
||||||
final HostInfo hostInfo;
|
final HostInfo hostInfo;
|
||||||
final Function onChanged;
|
final Function? onChanged;
|
||||||
final Site site;
|
final Site site;
|
||||||
|
|
||||||
|
final bool supportsQRScanning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_HostInfoScreenState createState() => _HostInfoScreenState();
|
_HostInfoScreenState createState() => _HostInfoScreenState();
|
||||||
}
|
}
|
||||||
|
@ -32,7 +39,7 @@ class HostInfoScreen extends StatefulWidget {
|
||||||
//TODO: have a config option to refresh hostmaps on a cadence (applies to 3 screens so far)
|
//TODO: have a config option to refresh hostmaps on a cadence (applies to 3 screens so far)
|
||||||
|
|
||||||
class _HostInfoScreenState extends State<HostInfoScreen> {
|
class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
HostInfo hostInfo;
|
late HostInfo hostInfo;
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -46,7 +53,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
final title = widget.pending ? 'Pending' : 'Active';
|
final title = widget.pending ? 'Pending' : 'Active';
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: '$title Host Info',
|
title: Text('$title Host Info'),
|
||||||
refreshController: refreshController,
|
refreshController: refreshController,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await _getHostInfo();
|
await _getHostInfo();
|
||||||
|
@ -61,14 +68,17 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
|
|
||||||
Widget _buildMain() {
|
Widget _buildMain() {
|
||||||
return ConfigSection(children: [
|
return ConfigSection(children: [
|
||||||
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SpecialSelectableText(hostInfo.vpnIp)),
|
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)),
|
||||||
hostInfo.cert != null
|
hostInfo.cert != null
|
||||||
? ConfigPageItem(
|
? ConfigPageItem(
|
||||||
label: Text('Certificate'),
|
label: Text('Certificate'),
|
||||||
labelWidth: 150,
|
labelWidth: 150,
|
||||||
content: Text(hostInfo.cert.details.name),
|
content: Text(hostInfo.cert!.details.name),
|
||||||
onPressed: () => Utils.openPage(
|
onPressed: () => Utils.openPage(
|
||||||
context, (context) => CertificateDetailsScreen(certificate: CertificateInfo(cert: hostInfo.cert))))
|
context, (context) => CertificateDetailsScreen(
|
||||||
|
certInfo: CertificateInfo(cert: hostInfo.cert!),
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
)))
|
||||||
: Container(),
|
: Container(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -76,18 +86,19 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
Widget _buildDetails() {
|
Widget _buildDetails() {
|
||||||
return ConfigSection(children: <Widget>[
|
return ConfigSection(children: <Widget>[
|
||||||
ConfigItem(
|
ConfigItem(
|
||||||
label: Text('Lighthouse'), labelWidth: 150, content: SpecialSelectableText(widget.isLighthouse ? 'Yes' : 'No')),
|
label: Text('Lighthouse'), labelWidth: 150, content: SelectableText(widget.isLighthouse ? 'Yes' : 'No')),
|
||||||
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.localIndex}')),
|
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')),
|
||||||
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.remoteIndex}')),
|
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')),
|
||||||
ConfigItem(
|
ConfigItem(
|
||||||
label: Text('Message Counter'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.messageCounter}')),
|
label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')),
|
||||||
ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.cachedPackets}')),
|
ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SelectableText('${hostInfo.cachedPackets}')),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildRemotes() {
|
Widget _buildRemotes() {
|
||||||
if (hostInfo.remoteAddresses.length == 0) {
|
if (hostInfo.remoteAddresses.length == 0) {
|
||||||
return ConfigSection(label: 'REMOTES', children: [ConfigItem(content: Text('No remote addresses yet'), labelWidth: 0)]);
|
return ConfigSection(
|
||||||
|
label: 'REMOTES', children: [ConfigItem(content: Text('No remote addresses yet'), labelWidth: 0)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return widget.pending ? _buildStaticRemotes() : _buildEditRemotes();
|
return widget.pending ? _buildStaticRemotes() : _buildEditRemotes();
|
||||||
|
@ -103,7 +114,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
String remote = remoteObj.toString();
|
String remote = remoteObj.toString();
|
||||||
items.add(ConfigCheckboxItem(
|
items.add(ConfigCheckboxItem(
|
||||||
key: Key(remote),
|
key: Key(remote),
|
||||||
label: Text(remote),
|
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
|
||||||
labelWidth: ipWidth,
|
labelWidth: ipWidth,
|
||||||
checked: currentRemote == remote,
|
checked: currentRemote == remote,
|
||||||
onChanged: () async {
|
onChanged: () async {
|
||||||
|
@ -117,7 +128,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
_setHostInfo(h);
|
_setHostInfo(h);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while changing the remote', err);
|
Utils.popError(context, 'Error while changing the remote', err.toString());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
@ -136,7 +147,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
String remote = remoteObj.toString();
|
String remote = remoteObj.toString();
|
||||||
items.add(ConfigCheckboxItem(
|
items.add(ConfigCheckboxItem(
|
||||||
key: Key(remote),
|
key: Key(remote),
|
||||||
label: Text(remote),
|
label: Text(remote), //TODO: need to do something to adjust the font size in the event we have an ipv6 address
|
||||||
labelWidth: ipWidth,
|
labelWidth: ipWidth,
|
||||||
checked: currentRemote == remote,
|
checked: currentRemote == remote,
|
||||||
));
|
));
|
||||||
|
@ -150,18 +161,18 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
|
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: PlatformButton(
|
child: PlatformElevatedButton(
|
||||||
child: Text('Close Tunnel'),
|
child: Text('Close Tunnel'),
|
||||||
color: CupertinoColors.systemRed.resolveFrom(context),
|
color: CupertinoColors.systemRed.resolveFrom(context),
|
||||||
onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async {
|
onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async {
|
||||||
try {
|
try {
|
||||||
await widget.site.closeTunnel(hostInfo.vpnIp);
|
await widget.site.closeTunnel(hostInfo.vpnIp);
|
||||||
if (widget.onChanged != null) {
|
if (widget.onChanged != null) {
|
||||||
widget.onChanged();
|
widget.onChanged!();
|
||||||
}
|
}
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while trying to close the tunnel', err);
|
Utils.popError(context, 'Error while trying to close the tunnel', err.toString());
|
||||||
}
|
}
|
||||||
}, deleteLabel: 'Close'))));
|
}, deleteLabel: 'Close'))));
|
||||||
}
|
}
|
||||||
|
@ -175,13 +186,13 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
|
|
||||||
_setHostInfo(h);
|
_setHostInfo(h);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Failed to refresh host info', err);
|
Utils.popError(context, 'Failed to refresh host info', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_setHostInfo(HostInfo h) {
|
_setHostInfo(HostInfo h) {
|
||||||
h.remoteAddresses.sort((a, b) {
|
h.remoteAddresses.sort((a, b) {
|
||||||
final diff = Utils.ip2int(a.ip) - Utils.ip2int(b.ip);
|
final diff = a.ip.compareTo(b.ip);
|
||||||
return diff == 0 ? a.port - b.port : diff;
|
return diff == 0 ? a.port - b.port : diff;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SiteItem.dart';
|
import 'package:mobile_nebula/components/SiteItem.dart';
|
||||||
|
@ -15,71 +14,167 @@ import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import 'package:mobile_nebula/models/Site.dart';
|
import 'package:mobile_nebula/models/Site.dart';
|
||||||
import 'package:mobile_nebula/models/StaticHosts.dart';
|
import 'package:mobile_nebula/models/StaticHosts.dart';
|
||||||
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
||||||
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
||||||
import 'package:mobile_nebula/screens/SettingsScreen.dart';
|
import 'package:mobile_nebula/screens/SettingsScreen.dart';
|
||||||
import 'package:mobile_nebula/screens/SiteDetailScreen.dart';
|
import 'package:mobile_nebula/screens/SiteDetailScreen.dart';
|
||||||
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
|
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
//TODO: add refresh
|
/// Contains an expired CA and certificate
|
||||||
|
const badDebugSave = {
|
||||||
|
'name': 'Bad Site',
|
||||||
|
'cert': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa
|
||||||
|
A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp
|
||||||
|
VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU
|
||||||
|
Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ==
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
||||||
|
rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
|
-----END NEBULA X25519 PRIVATE KEY-----''',
|
||||||
|
'ca': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
|
||||||
|
4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv
|
||||||
|
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Contains an expired CA and certificate
|
||||||
|
const goodDebugSave = {
|
||||||
|
'name': 'Good Site',
|
||||||
|
'cert': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CmcKCmRlYnVnIGhvc3QSCYKAhFCA/v//DyiX0ZaaBjDjjPf5ETogyYzKdlRh7pW6
|
||||||
|
yOd8+aMQAFPha2wuYixuq53ru9+qXC9KIJd3ow6qIiaHInT1dgJvy+122WK7g86+
|
||||||
|
Z8qYtTZnox1cEkBYpC0SySrCp6jd/zeAFEJM6naPYgc6rmy/H/qveyQ6WAtbgLpK
|
||||||
|
tM3EXbbOE9+fV/Ma6Oilf1SixO3ZBo30nRYL
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
||||||
|
vu9t0mNy8cD5x3CMVpQ/cdKpjdz46NBlcRqvJAQpO44=
|
||||||
|
-----END NEBULA X25519 PRIVATE KEY-----''',
|
||||||
|
'ca': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CjcKBWRlYnVnKOTQlpoGMOSM9/kROiCWNJUs7c4ZRzUn2LbeAEQrz2PVswnu9dcL
|
||||||
|
Sn/2VNNu30ABEkCQtWxmCJqBr5Yd9vtDWCPo/T1JQmD3stBozcM6aUl1hP3zjURv
|
||||||
|
MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
};
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
const MainScreen({Key key}) : super(key: key);
|
const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
final StreamController dnEnrollStream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_MainScreenState createState() => _MainScreenState();
|
_MainScreenState createState() => _MainScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainScreenState extends State<MainScreen> {
|
class _MainScreenState extends State<MainScreen> {
|
||||||
bool ready = false;
|
List<Site>? sites;
|
||||||
List<Site> sites;
|
// A set of widgets to display in a column that represents an error blocking us from moving forward entirely
|
||||||
|
List<Widget>? error;
|
||||||
|
|
||||||
|
bool supportsQRScanning = false;
|
||||||
|
|
||||||
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
|
RefreshController refreshController = RefreshController();
|
||||||
|
ScrollController scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_loadSites();
|
_loadSites();
|
||||||
|
|
||||||
|
widget.dnEnrollStream.stream.listen((_) {
|
||||||
|
_loadSites();
|
||||||
|
});
|
||||||
|
|
||||||
|
platform.setMethodCallHandler(handleMethodCall);
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
scrollController.dispose();
|
||||||
|
refreshController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> handleMethodCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case "refreshSites":
|
||||||
|
_loadSites();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
print("ERR: Unexpected method call ${call.method}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
Widget? debugSite;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugSite = Row(
|
||||||
|
children: [
|
||||||
|
_debugSave(badDebugSave),
|
||||||
|
_debugSave(goodDebugSave),
|
||||||
|
_debugClearKeys(),
|
||||||
|
],
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whether the device supports QR scanning. For example, some
|
||||||
|
// Chromebooks do not have camera support.
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
platform.invokeMethod("android.deviceHasCamera").then(
|
||||||
|
(hasCamera) => setState(() => supportsQRScanning = hasCamera)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
supportsQRScanning = true;
|
||||||
|
}
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: 'Nebula',
|
title: Text('Nebula'),
|
||||||
scrollable: SimpleScrollable.none,
|
scrollable: SimpleScrollable.vertical,
|
||||||
|
scrollController: scrollController,
|
||||||
leadingAction: PlatformIconButton(
|
leadingAction: PlatformIconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon: Icon(Icons.add, size: 28.0),
|
icon: Icon(Icons.add, size: 28.0),
|
||||||
onPressed: () => Utils.openPage(context, (context) {
|
onPressed: () => Utils.openPage(context, (context) {
|
||||||
return SiteConfigScreen(onSave: (_) {
|
return SiteConfigScreen(onSave: (_) {
|
||||||
_loadSites();
|
_loadSites();
|
||||||
});
|
}, supportsQRScanning: supportsQRScanning);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
refreshController: refreshController,
|
||||||
|
onRefresh: () {
|
||||||
|
_loadSites();
|
||||||
|
refreshController.refreshCompleted();
|
||||||
|
},
|
||||||
trailingActions: <Widget>[
|
trailingActions: <Widget>[
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon: Icon(Icons.menu, size: 28.0),
|
icon: Icon(Icons.menu, size: 28.0),
|
||||||
onPressed: () => Utils.openPage(context, (_) => SettingsScreen()),
|
onPressed: () => Utils.openPage(context, (_) => SettingsScreen(widget.dnEnrollStream)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
bottomBar: kDebugMode ? _debugSave() : null,
|
bottomBar: debugSite,
|
||||||
child: _buildBody(),
|
child: _buildBody(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (!ready) {
|
if (error != null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: PlatformCircularProgressIndicator(ios: (_) {
|
child: Padding(
|
||||||
return CupertinoProgressIndicatorData(radius: 50);
|
child: Column(
|
||||||
}),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
);
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
}
|
children: error!,
|
||||||
|
),
|
||||||
if (sites == null || sites.length == 0) {
|
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10)));
|
||||||
return _buildNoSites();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildSites();
|
return _buildSites();
|
||||||
|
@ -88,7 +183,8 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
Widget _buildNoSites() {
|
Widget _buildNoSites() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Center(child: Column(
|
child: Center(
|
||||||
|
child: Column(
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0),
|
padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0),
|
||||||
|
@ -102,19 +198,29 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildSites() {
|
Widget _buildSites() {
|
||||||
|
if (sites == null || sites!.length == 0) {
|
||||||
|
return _buildNoSites();
|
||||||
|
}
|
||||||
|
|
||||||
List<Widget> items = [];
|
List<Widget> items = [];
|
||||||
sites.forEach((site) {
|
sites!.forEach((site) {
|
||||||
items.add(SiteItem(
|
items.add(SiteItem(
|
||||||
key: Key(site.id),
|
key: Key(site.id),
|
||||||
site: site,
|
site: site,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Utils.openPage(context, (context) {
|
Utils.openPage(context, (context) {
|
||||||
return SiteDetailScreen(site: site, onChanged: () => _loadSites());
|
return SiteDetailScreen(
|
||||||
|
site: site,
|
||||||
|
onChanged: () => _loadSites(),
|
||||||
|
supportsQRScanning: supportsQRScanning,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget child = ReorderableListView(
|
Widget child = ReorderableListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
scrollController: scrollController,
|
||||||
padding: EdgeInsets.symmetric(vertical: 5),
|
padding: EdgeInsets.symmetric(vertical: 5),
|
||||||
children: items,
|
children: items,
|
||||||
onReorder: (oldI, newI) async {
|
onReorder: (oldI, newI) async {
|
||||||
|
@ -124,17 +230,21 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final Site moved = sites.removeAt(oldI);
|
final Site moved = sites!.removeAt(oldI);
|
||||||
sites.insert(newI, moved);
|
sites!.insert(newI, moved);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (var i = min(oldI, newI); i <= max(oldI, newI); i++) {
|
for (var i = 0; i < sites!.length; i++) {
|
||||||
sites[i].sortKey = i;
|
if (sites![i].sortKey == i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sites![i].sortKey = i;
|
||||||
try {
|
try {
|
||||||
await sites[i].save();
|
await sites![i].save();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//TODO: display error at the end
|
//TODO: display error at the end
|
||||||
print('ERR ${sites[i].name} - $err');
|
print('ERR ${sites![i].name} - $err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,46 +256,28 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The theme here is to remove the hardcoded canvas border reordering forces on us
|
// The theme here is to remove the hardcoded canvas border reordering forces on us
|
||||||
return Theme(
|
return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child);
|
||||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
|
||||||
child: child
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _debugSave() {
|
Widget _debugSave(Map<String, String> siteConfig) {
|
||||||
return CupertinoButton(
|
return CupertinoButton(
|
||||||
key: Key('debug-save'),
|
child: Text(siteConfig['name']!),
|
||||||
child: Text("DEBUG SAVE"),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
var uuid = Uuid();
|
var uuid = Uuid();
|
||||||
|
|
||||||
var cert = '''-----BEGIN NEBULA CERTIFICATE-----
|
|
||||||
CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa
|
|
||||||
A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp
|
|
||||||
VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU
|
|
||||||
Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ==
|
|
||||||
-----END NEBULA CERTIFICATE-----''';
|
|
||||||
|
|
||||||
var ca = '''-----BEGIN NEBULA CERTIFICATE-----
|
|
||||||
CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
|
|
||||||
4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv
|
|
||||||
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
|
|
||||||
-----END NEBULA CERTIFICATE-----''';
|
|
||||||
|
|
||||||
var s = Site(
|
var s = Site(
|
||||||
name: "DEBUG TEST",
|
name: siteConfig['name']!,
|
||||||
id: uuid.v4(),
|
id: uuid.v4(),
|
||||||
staticHostmap: {
|
staticHostmap: {
|
||||||
"10.1.0.1": StaticHost(lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242)])
|
"10.1.0.1": StaticHost(
|
||||||
|
lighthouse: true,
|
||||||
|
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
|
||||||
},
|
},
|
||||||
ca: [CertificateInfo.debug(rawCert: ca)],
|
ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])],
|
||||||
cert: CertificateInfo.debug(rawCert: cert),
|
certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']),
|
||||||
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]
|
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]);
|
||||||
);
|
|
||||||
|
|
||||||
s.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
s.key = siteConfig['key'];
|
||||||
rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
|
||||||
-----END NEBULA X25519 PRIVATE KEY-----''';
|
|
||||||
|
|
||||||
var err = await s.save();
|
var err = await s.save();
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
|
@ -197,17 +289,23 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadSites() async {
|
Widget _debugClearKeys() {
|
||||||
if (Platform.isAndroid) {
|
return CupertinoButton(
|
||||||
await platform.invokeMethod("android.requestPermissions");
|
child: Text("Clear Keys"),
|
||||||
|
onPressed: () async {
|
||||||
|
await platform.invokeMethod("debug.clearKeys", null);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_loadSites() async {
|
||||||
//TODO: This can throw, we need to show an error dialog
|
//TODO: This can throw, we need to show an error dialog
|
||||||
Map<String, dynamic> rawSites = jsonDecode(await platform.invokeMethod('listSites'));
|
Map<String, dynamic> rawSites = jsonDecode(await platform.invokeMethod('listSites'));
|
||||||
bool hasErrors = false;
|
bool hasErrors = false;
|
||||||
|
|
||||||
sites = [];
|
sites = [];
|
||||||
rawSites.values.forEach((rawSite) {
|
rawSites.forEach((id, rawSite) {
|
||||||
try {
|
try {
|
||||||
var site = Site.fromJson(rawSite);
|
var site = Site.fromJson(rawSite);
|
||||||
if (site.errors.length > 0) {
|
if (site.errors.length > 0) {
|
||||||
|
@ -219,28 +317,34 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}, onError: (err) {
|
}, onError: (err) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
if (ModalRoute.of(context).isCurrent) {
|
if (ModalRoute.of(context)!.isCurrent) {
|
||||||
Utils.popError(context, "${site.name} Error", err);
|
Utils.popError(context, "${site.name} Error", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sites.add(site);
|
|
||||||
|
sites!.add(site);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//TODO: handle error
|
//TODO: handle error
|
||||||
print(err);
|
print("$err site config: $rawSite");
|
||||||
|
// Sometimes it is helpful to just nuke these is dev
|
||||||
|
// platform.invokeMethod('deleteSite', id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors) {
|
if (Platform.isAndroid) {
|
||||||
Utils.popError(context, "Site Error(s)", "1 or more sites have errors and need your attention, problem sites have a red border.");
|
// Android suffers from a race to discover the active site and attach site specific event listeners
|
||||||
|
platform.invokeMethod("android.registerActiveSite");
|
||||||
|
}
|
||||||
|
|
||||||
|
sites!.sort((a, b) {
|
||||||
|
if (a.sortKey == b.sortKey) {
|
||||||
|
return a.name.compareTo(b.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
sites.sort((a, b) {
|
|
||||||
return a.sortKey - b.sortKey;
|
return a.sortKey - b.sortKey;
|
||||||
});
|
});
|
||||||
|
|
||||||
setState(() {
|
setState(() {});
|
||||||
ready = true;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
||||||
import 'package:mobile_nebula/services/settings.dart';
|
import 'package:mobile_nebula/services/settings.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
import 'AboutScreen.dart';
|
import 'AboutScreen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
final StreamController stream;
|
||||||
|
|
||||||
|
const SettingsScreen(this.stream, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SettingsScreenState createState() {
|
_SettingsScreenState createState() => _SettingsScreenState();
|
||||||
return _SettingsScreenState();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
@ -66,10 +70,40 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
|
||||||
List<Widget> items = [];
|
List<Widget> items = [];
|
||||||
items.add(ConfigSection(children: colorSection));
|
items.add(ConfigSection(children: colorSection));
|
||||||
items.add(ConfigSection(children: [ConfigPageItem(label: Text('About'), onPressed: () => Utils.openPage(context, (context) => AboutScreen()),)]));
|
items.add(ConfigItem(
|
||||||
|
label: Text('Wrap log output'),
|
||||||
|
labelWidth: 200,
|
||||||
|
content: Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: Switch.adaptive(
|
||||||
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
|
value: settings.logWrap,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() {
|
||||||
|
settings.logWrap = value;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
items.add(ConfigSection(children: [
|
||||||
|
ConfigPageItem(
|
||||||
|
label: Text('Enroll with Managed Nebula'),
|
||||||
|
labelWidth: 200,
|
||||||
|
onPressed: () => Utils.openPage(context, (context) => EnrollmentScreen(stream: widget.stream, allowCodeEntry: true))
|
||||||
|
)
|
||||||
|
]));
|
||||||
|
|
||||||
|
items.add(ConfigSection(children: [
|
||||||
|
ConfigPageItem(
|
||||||
|
label: Text('About'),
|
||||||
|
onPressed: () => Utils.openPage(context, (context) => AboutScreen()),
|
||||||
|
)
|
||||||
|
]));
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: 'Settings',
|
title: Text('Settings'),
|
||||||
child: Column(children: items),
|
child: Column(children: items),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,9 @@ import 'dart:async';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialSelectableText.dart';
|
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
@ -22,46 +21,48 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
|
//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
|
||||||
|
|
||||||
class SiteDetailScreen extends StatefulWidget {
|
class SiteDetailScreen extends StatefulWidget {
|
||||||
const SiteDetailScreen({Key key, this.site, this.onChanged}) : super(key: key);
|
const SiteDetailScreen({
|
||||||
|
Key? key,
|
||||||
|
required this.site,
|
||||||
|
this.onChanged,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
final Function onChanged;
|
final Function? onChanged;
|
||||||
|
final bool supportsQRScanning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SiteDetailScreenState createState() => _SiteDetailScreenState();
|
_SiteDetailScreenState createState() => _SiteDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
Site site;
|
late Site site;
|
||||||
StreamSubscription onChange;
|
late StreamSubscription onChange;
|
||||||
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
List<HostInfo> activeHosts;
|
List<HostInfo>? activeHosts;
|
||||||
List<HostInfo> pendingHosts;
|
List<HostInfo>? pendingHosts;
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
bool lastState;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
site = widget.site;
|
site = widget.site;
|
||||||
lastState = site.connected;
|
|
||||||
if (site.connected) {
|
if (site.connected) {
|
||||||
_listHostmap();
|
_listHostmap();
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange = site.onChange().listen((_) {
|
onChange = site.onChange().listen((_) {
|
||||||
setState(() {});
|
// TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
|
||||||
if (lastState != site.connected) {
|
// If we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
|
||||||
//TODO: connected is set before the nebula object exists leading to a crash race, waiting for "Connected" status is a gross hack but keeps it alive
|
|
||||||
if (site.status == 'Connected') {
|
if (site.status == 'Connected') {
|
||||||
lastState = true;
|
|
||||||
_listHostmap();
|
_listHostmap();
|
||||||
} else {
|
} else {
|
||||||
lastState = false;
|
|
||||||
activeHosts = null;
|
activeHosts = null;
|
||||||
pendingHosts = null;
|
pendingHosts = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
setState(() {});
|
||||||
}, onError: (err) {
|
}, onError: (err) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
Utils.popError(context, "Error", err);
|
Utils.popError(context, "Error", err);
|
||||||
|
@ -78,17 +79,25 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
final title = Row(children: [
|
||||||
|
site.managed ?
|
||||||
|
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) :
|
||||||
|
Container(),
|
||||||
|
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)))
|
||||||
|
]);
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: site.name,
|
title: title,
|
||||||
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
||||||
if (changed && widget.onChanged != null) {
|
if (changed && widget.onChanged != null) {
|
||||||
widget.onChanged();
|
widget.onChanged!();
|
||||||
}
|
}
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}),
|
}),
|
||||||
refreshController: refreshController,
|
refreshController: refreshController,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
if (site.connected) {
|
if (site.connected && site.status == "Connected") {
|
||||||
await _listHostmap();
|
await _listHostmap();
|
||||||
}
|
}
|
||||||
refreshController.refreshCompleted();
|
refreshController.refreshCompleted();
|
||||||
|
@ -110,7 +119,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
List<Widget> items = [];
|
List<Widget> items = [];
|
||||||
site.errors.forEach((error) {
|
site.errors.forEach((error) {
|
||||||
items.add(ConfigItem(
|
items.add(ConfigItem(
|
||||||
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SpecialSelectableText(error))));
|
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error))));
|
||||||
});
|
});
|
||||||
|
|
||||||
return ConfigSection(
|
return ConfigSection(
|
||||||
|
@ -164,13 +173,13 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
if (activeHosts == null) {
|
if (activeHosts == null) {
|
||||||
active = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
active = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
||||||
} else {
|
} else {
|
||||||
active = Text(Utils.itemCountFormat(activeHosts.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
active = Text(Utils.itemCountFormat(activeHosts!.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingHosts == null) {
|
if (pendingHosts == null) {
|
||||||
pending = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
pending = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
||||||
} else {
|
} else {
|
||||||
pending = Text(Utils.itemCountFormat(pendingHosts.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
pending = Text(Utils.itemCountFormat(pendingHosts!.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ConfigSection(
|
return ConfigSection(
|
||||||
|
@ -178,33 +187,41 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ConfigPageItem(
|
ConfigPageItem(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (activeHosts == null) return;
|
||||||
|
|
||||||
Utils.openPage(
|
Utils.openPage(
|
||||||
context,
|
context,
|
||||||
(context) => SiteTunnelsScreen(
|
(context) => SiteTunnelsScreen(
|
||||||
pending: false,
|
pending: false,
|
||||||
tunnels: activeHosts,
|
tunnels: activeHosts!,
|
||||||
site: site,
|
site: site,
|
||||||
onChanged: (hosts) {
|
onChanged: (hosts) {
|
||||||
setState(() {
|
setState(() {
|
||||||
activeHosts = hosts;
|
activeHosts = hosts;
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
));
|
||||||
},
|
},
|
||||||
label: Text("Active"),
|
label: Text("Active"),
|
||||||
content: Container(alignment: Alignment.centerRight, child: active)),
|
content: Container(alignment: Alignment.centerRight, child: active)),
|
||||||
ConfigPageItem(
|
ConfigPageItem(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (pendingHosts == null) return;
|
||||||
|
|
||||||
Utils.openPage(
|
Utils.openPage(
|
||||||
context,
|
context,
|
||||||
(context) => SiteTunnelsScreen(
|
(context) => SiteTunnelsScreen(
|
||||||
pending: true,
|
pending: true,
|
||||||
tunnels: pendingHosts,
|
tunnels: pendingHosts!,
|
||||||
site: site,
|
site: site,
|
||||||
onChanged: (hosts) {
|
onChanged: (hosts) {
|
||||||
setState(() {
|
setState(() {
|
||||||
pendingHosts = hosts;
|
pendingHosts = hosts;
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
));
|
||||||
},
|
},
|
||||||
label: Text("Pending"),
|
label: Text("Pending"),
|
||||||
content: Container(alignment: Alignment.centerRight, child: pending))
|
content: Container(alignment: Alignment.centerRight, child: pending))
|
||||||
|
@ -223,7 +240,10 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
site: widget.site,
|
site: widget.site,
|
||||||
onSave: (site) async {
|
onSave: (site) async {
|
||||||
changed = true;
|
changed = true;
|
||||||
});
|
setState(() {});
|
||||||
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -235,7 +255,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
|
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: PlatformButton(
|
child: PlatformElevatedButton(
|
||||||
child: Text('Delete'),
|
child: Text('Delete'),
|
||||||
color: CupertinoColors.systemRed.resolveFrom(context),
|
color: CupertinoColors.systemRed.resolveFrom(context),
|
||||||
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async {
|
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async {
|
||||||
|
@ -252,7 +272,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
pendingHosts = maps["pending"];
|
pendingHosts = maps["pending"];
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while fetching hostmaps', err);
|
Utils.popError(context, 'Error while fetching hostmaps', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -269,7 +289,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.onChanged != null) {
|
if (widget.onChanged != null) {
|
||||||
widget.onChanged();
|
widget.onChanged!();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,17 +2,17 @@ import 'dart:io';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialSelectableText.dart';
|
|
||||||
import 'package:mobile_nebula/models/Site.dart';
|
import 'package:mobile_nebula/models/Site.dart';
|
||||||
|
import 'package:mobile_nebula/services/settings.dart';
|
||||||
import 'package:mobile_nebula/services/share.dart';
|
import 'package:mobile_nebula/services/share.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
class SiteLogsScreen extends StatefulWidget {
|
class SiteLogsScreen extends StatefulWidget {
|
||||||
const SiteLogsScreen({Key key, this.site}) : super(key: key);
|
const SiteLogsScreen({Key? key, required this.site}) : super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
ScrollController controller = ScrollController();
|
ScrollController controller = ScrollController();
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
|
|
||||||
|
var settings = Settings();
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
loadLogs();
|
loadLogs();
|
||||||
|
@ -39,8 +40,16 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
final title = Row(children: [
|
||||||
|
widget.site.managed ?
|
||||||
|
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) :
|
||||||
|
Container(),
|
||||||
|
Expanded(child: Text(widget.site.name, style: TextStyle(fontWeight: FontWeight.bold)))
|
||||||
|
]);
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: widget.site.name,
|
title: title,
|
||||||
scrollable: SimpleScrollable.both,
|
scrollable: SimpleScrollable.both,
|
||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
|
@ -54,8 +63,8 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
refreshController: refreshController,
|
refreshController: refreshController,
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.all(5),
|
padding: EdgeInsets.all(5),
|
||||||
constraints: BoxConstraints(minWidth: MediaQuery.of(context).size.width),
|
constraints: logBoxConstraints(context),
|
||||||
child: SpecialSelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
|
child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
|
||||||
bottomBar: _buildBottomBar(),
|
bottomBar: _buildBottomBar(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -75,20 +84,19 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
),
|
),
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PlatformIconButton(
|
child: Builder(
|
||||||
|
builder: (BuildContext context) {
|
||||||
|
return PlatformIconButton(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
icon: Icon(context.platformIcons.share, size: 30),
|
icon: Icon(context.platformIcons.share, size: 30),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Share.shareFile(title: '${widget.site.name} logs', filePath: widget.site.logFile, filename: '${widget.site.name}.log');
|
Share.shareFile(context,
|
||||||
},
|
title: '${widget.site.name} logs',
|
||||||
)),
|
filePath: widget.site.logFile,
|
||||||
Expanded(
|
filename: '${widget.site.name}.log');
|
||||||
child: PlatformIconButton(
|
|
||||||
padding: padding,
|
|
||||||
icon: Icon(context.platformIcons.delete, size: Platform.isIOS ? 38 : 30),
|
|
||||||
onPressed: () {
|
|
||||||
Utils.confirmDelete(context, 'Are you sure you want to clear all logs?', () => deleteLogs());
|
|
||||||
},
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
)),
|
)),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PlatformIconButton(
|
child: PlatformIconButton(
|
||||||
|
@ -105,32 +113,29 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
loadLogs() async {
|
loadLogs() async {
|
||||||
var file = File(widget.site.logFile);
|
var file = File(widget.site.logFile);
|
||||||
try {
|
try {
|
||||||
String v = await file.readAsString();
|
final v = await file.readAsString();
|
||||||
if(widget.site.logLocalTZ) {
|
|
||||||
v = convertToLocalTZ(v);
|
|
||||||
}
|
|
||||||
setState(() {
|
setState(() {
|
||||||
logs = v;
|
logs = v;
|
||||||
});
|
});
|
||||||
|
} on FileSystemException {
|
||||||
|
Utils.popError(context, 'Error while reading logs', 'No log file was present');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while reading logs', err.toString());
|
Utils.popError(context, 'Error while reading logs', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
convertToLocalTZ(String rawLog) {
|
|
||||||
// Regex to extract time value
|
|
||||||
rawLog = rawLog.replaceAllMapped(RegExp('time="(.*?)"'), (match){
|
|
||||||
// Convert to DateTime with the inner match from above
|
|
||||||
DateTime userDate = DateTime.parse(match.group(1));
|
|
||||||
// Return timestamp in format that matches default
|
|
||||||
return 'time="${userDate.toLocal().toIso8601String()}"';
|
|
||||||
});
|
|
||||||
return rawLog;
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteLogs() async {
|
deleteLogs() async {
|
||||||
var file = File(widget.site.logFile);
|
var file = File(widget.site.logFile);
|
||||||
await file.writeAsBytes([]);
|
await file.writeAsBytes([]);
|
||||||
await loadLogs();
|
await loadLogs();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logBoxConstraints(BuildContext context) {
|
||||||
|
if (settings.logWrap) {
|
||||||
|
return BoxConstraints(maxWidth: MediaQuery.of(context).size.width);
|
||||||
|
} else {
|
||||||
|
return BoxConstraints(minWidth: MediaQuery.of(context).size.width);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
@ -11,32 +10,43 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
class SiteTunnelsScreen extends StatefulWidget {
|
class SiteTunnelsScreen extends StatefulWidget {
|
||||||
const SiteTunnelsScreen({Key key, this.site, this.tunnels, this.pending, this.onChanged}) : super(key: key);
|
const SiteTunnelsScreen({
|
||||||
|
Key? key,
|
||||||
|
required this.site,
|
||||||
|
required this.tunnels,
|
||||||
|
required this.pending,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
final List<HostInfo> tunnels;
|
final List<HostInfo> tunnels;
|
||||||
final bool pending;
|
final bool pending;
|
||||||
final Function(List<HostInfo>) onChanged;
|
final Function(List<HostInfo>)? onChanged;
|
||||||
|
|
||||||
|
final bool supportsQRScanning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SiteTunnelsScreenState createState() => _SiteTunnelsScreenState();
|
_SiteTunnelsScreenState createState() => _SiteTunnelsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
Site site;
|
late Site site;
|
||||||
List<HostInfo> tunnels;
|
late List<HostInfo> tunnels;
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
site = widget.site;
|
site = widget.site;
|
||||||
tunnels = widget.tunnels ?? [];
|
tunnels = widget.tunnels;
|
||||||
_sortTunnels();
|
_sortTunnels();
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
refreshController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,10 +75,13 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
site: widget.site,
|
site: widget.site,
|
||||||
onChanged: () {
|
onChanged: () {
|
||||||
_listHostmap();
|
_listHostmap();
|
||||||
})),
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
),
|
||||||
|
),
|
||||||
label: Row(children: <Widget>[Padding(child: icon, padding: EdgeInsets.only(right: 10)), Text(hostInfo.vpnIp)]),
|
label: Row(children: <Widget>[Padding(child: icon, padding: EdgeInsets.only(right: 10)), Text(hostInfo.vpnIp)]),
|
||||||
labelWidth: ipWidth,
|
labelWidth: ipWidth,
|
||||||
content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details?.name ?? "")),
|
content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details.name ?? "")),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -82,7 +95,7 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
final title = widget.pending ? 'Pending' : 'Active';
|
final title = widget.pending ? 'Pending' : 'Active';
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: "$title Tunnels",
|
title: Text('$title Tunnels'),
|
||||||
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}),
|
}),
|
||||||
|
@ -122,11 +135,11 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
|
|
||||||
_sortTunnels();
|
_sortTunnels();
|
||||||
if (widget.onChanged != null) {
|
if (widget.onChanged != null) {
|
||||||
widget.onChanged(tunnels);
|
widget.onChanged!(tunnels);
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while fetching hostmap', err);
|
Utils.popError(context, 'Error while fetching hostmap', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue