3
0
Fork 0

Compare commits

..

1 Commits

Author SHA1 Message Date
Nate Brown 41a382d218 start 2021-04-29 21:28:21 -05:00
130 changed files with 3578 additions and 5741 deletions

View File

@ -1,2 +0,0 @@
# Big flutter format run
9934f226e3e79c3567ce07dbab9e9f6443e7afc5

View File

@ -1,10 +0,0 @@
#!/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

View File

@ -1,27 +0,0 @@
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

View File

@ -1,14 +0,0 @@
#!/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

View File

@ -1,34 +0,0 @@
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

View File

@ -1,154 +0,0 @@
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

3
.gitignore vendored
View File

@ -48,6 +48,3 @@ lib/generated_plugin_registrant.dart
/ios/Flutter/.last_build_id /ios/Flutter/.last_build_id
/local.properties /local.properties
/.gradle/ /.gradle/
*.keystore
/nebula/MobileNebula.xcframework/
/ios/MobileNebula.xcframework/

View File

@ -1,45 +0,0 @@
# 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

View File

@ -1,34 +1,16 @@
# Mobile Nebula # Dependencies
[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) - [`flutter`](https://flutter.dev/docs/get-started/install)
- [`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)
- [`flutter` 3.3.5](https://docs.flutter.dev/get-started/install) - [Enable NDK](https://developer.android.com/studio/projects/install-ndk) Check local.properties for current NDK version
- [`gomobile`](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile)
- [Flutter Android Studio Extension](https://docs.flutter.dev/get-started/editor?tab=androidstudio)
Ensure your path is set up correctly to execute flutter Currently using flutter 2.0.5
Run `flutter doctor` and fix everything it complains before proceeding Copy env.sh.example to env.sh and update your PATH variable to expose both flutter and go bin directories
*NOTE* on iOS, always open `Runner.xcworkspace` and NOT the `Runner.xccodeproj` ```export PATH="$PATH:/path/to/go/bin:/path/to/flutter/bin```
### 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
@ -39,13 +21,14 @@ Use:
flutter format lib/ test/ -l 120 flutter format lib/ test/ -l 120
``` ```
# Release # Release
Update `version` in `pubspec.yaml` to reflect this release, then Update `version` in `pubspec.yaml` to reflect this release, then
## Android ## Android
`flutter build appbundle` `flutter build appbundle --no-shrink`
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/`

1
android/.gitignore vendored
View File

@ -6,4 +6,3 @@ gradle-wrapper.jar
/local.properties /local.properties
GeneratedPluginRegistrant.java GeneratedPluginRegistrant.java
/build/build-attribution/ /build/build-attribution/
/mobileNebula/mobileNebula.aar

View File

@ -1,10 +0,0 @@
# 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)

View File

@ -1,220 +0,0 @@
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

View File

@ -25,52 +25,51 @@ 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()
def keystorePropertiesFile = rootProject.file('key.properties')
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
}
android { android {
namespace "net.defined.mobile_nebula" compileSdkVersion 29
compileSdkVersion 33
ndkVersion flutter.ndkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}
kotlinOptions {
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 26 //flutter.minSdkVersion minSdkVersion 25
targetSdkVersion 33 //flutter.targetSdkVersion targetSdkVersion 29
versionCode flutterVersionCode.toInteger() versionCode flutterVersionCode.toInteger()
versionName flutterVersionName versionName flutterVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
} }
signingConfigs { signingConfigs {
release { release {
keyAlias 'key' keyAlias keystoreProperties['keyAlias']
storeFile System.getenv('GOOGLE_PLAY_KEYSTORE_PATH') ? file(System.getenv('GOOGLE_PLAY_KEYSTORE_PATH')) : null keyPassword keystoreProperties['password']
keyPassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD') storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
storePassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD') storePassword keystoreProperties['password']
} }
} }
buildTypes { buildTypes {
release { release {
signingConfig signingConfigs.release signingConfig signingConfigs.release
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
resValue 'string', 'app_name', '"Nebula"'
}
debug { // We are disabling minification and proguard because it wrecks the crypto for storing keys
resValue 'string', 'app_name', '"Nebula-DEBUG"' // Ideally we would turn these on. We had issues with gson as well but resolved those with proguardFiles
applicationIdSuffix '.debug' shrinkResources false
minifyEnabled false
useProguard false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
} }
} }
} }
@ -79,13 +78,26 @@ flutter {
source '../..' source '../..'
} }
dependencies { repositories {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" flatDir {
implementation "androidx.security:security-crypto:1.0.0" dirs 'src/main/libs'
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')
} }
dependencies {
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
implementation "androidx.security:security-crypto:1.0.0-rc02"
implementation 'com.google.code.gson:gson:2.8.6'
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'
}
}
}

View File

@ -1,4 +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">
<!-- 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.
--> -->

View File

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#f2c10d</color>
</resources>

View File

@ -1,5 +1,5 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android" <manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"> package="net.defined.mobile_nebula">
<!-- 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,31 +7,20 @@
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="MyApplication" android:name="io.flutter.app.FlutterApplication"
android:label="@string/app_name" android:label="Nebula"
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"
@ -41,16 +30,8 @@
<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"
@ -60,18 +41,6 @@
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

View File

@ -1,45 +0,0 @@
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)
}
}

View File

@ -1,129 +0,0 @@
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
}
}

View File

@ -1,57 +1,22 @@
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 spec = MasterKeys.AES256_GCM_SPEC private val master: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
private var master: String = MasterKeys.getOrCreate(spec)
fun openRead(file: File): BufferedReader { fun openRead(file: File): BufferedReader {
// We may fail to decrypt the file, in which case we'll raise an exception. val eFile = EncryptedFile.Builder(file, context, master, scheme).build()
// Callers should handle this exception by deleting the invalid file. return eFile.openFileInput().bufferedReader()
return build(file).openFileInput().bufferedReader()
} }
fun openWrite(file: File): BufferedWriter { fun openWrite(file: File): BufferedWriter {
return try { val eFile = EncryptedFile.Builder(file, context, master, scheme).build()
build(file).openFileOutput().bufferedWriter() return eFile.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)
}
} }

View File

@ -1,76 +1,57 @@
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 android.util.Log import androidx.annotation.NonNull
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 ui: MethodChannel? = null private var sites: Sites? = 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(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(@NonNull 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
val intent = Intent(this, NebulaVpnService::class.java)
bindService(intent, connection, 0)
GeneratedPluginRegistrant.registerWith(flutterEngine);
GeneratedPluginRegistrant.registerWith(flutterEngine) MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
ui = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
ui!!.setMethodCallHandler { call, result ->
when(call.method) { when(call.method) {
"android.registerActiveSite" -> registerActiveSite(result) "android.requestPermissions" -> androidPermissions(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)
@ -84,52 +65,14 @@ class MainActivity: FlutterActivity() {
"active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result) "active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result)
"active.closeTunnel" -> activeCloseTunnel(call, result) "active.closeTunnel" -> activeCloseTunnel(call, result)
"debug.clearKeys" -> { "share" -> Share.share(call, result)
EncFile(context).resetMasterKey() "shareFile" -> Share.shareFile(call, result)
}
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 == "") {
@ -155,47 +98,6 @@ 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()
@ -215,66 +117,68 @@ 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)
siteDir = site.save(context) 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)
} }
if (!validateOrDeleteSite(siteDir)) { val siteDir = context.filesDir.resolve("sites").resolve(site.id)
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)
} }
startingSiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null) var siteContainer: SiteContainer = 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 {
onActivityResult(VPN_START_CODE, Activity.RESULT_OK, null) val intent = Intent(this, NebulaVpnService::class.java)
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).apply { val intent = Intent(this, NebulaVpnService::class.java)
action = NebulaVpnService.ACTION_STOP intent.putExtra("COMMAND", "STOP")
}
// We can't stopService because we have to close the fd first. The service will call stopSelf when ready. //This is odd but stopService goes nowhere in my tests and this is correct
// See the official example: https://android.googlesource.com/platform/development/+/master/samples/ToyVpn/src/com/example/android/toyvpn/ToyVpnClient.java#116 // according to 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) {
@ -287,9 +191,9 @@ class MainActivity: FlutterActivity() {
return result.success(null) return result.success(null)
} }
val msg = Message.obtain() var msg = Message.obtain()
msg.what = NebulaVpnService.MSG_LIST_HOSTMAP msg.what = NebulaVpnService.MSG_LIST_HOSTMAP
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) { msg.replyTo = Messenger(object: Handler() {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
result.success(msg.data.getString("data")) result.success(msg.data.getString("data"))
} }
@ -307,9 +211,9 @@ class MainActivity: FlutterActivity() {
return result.success(null) return result.success(null)
} }
val msg = Message.obtain() var msg = Message.obtain()
msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) { msg.replyTo = Messenger(object: Handler() {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
result.success(msg.data.getString("data")) result.success(msg.data.getString("data"))
} }
@ -329,16 +233,16 @@ class MainActivity: FlutterActivity() {
} }
val pending = call.argument<Boolean>("pending") ?: false val pending = call.argument<Boolean>("pending") ?: false
if (outMessenger == null || activeSiteId == null || activeSiteId != id) { if (outMessenger == null || activeSiteId == null || activeSiteId != id) {
return result.success(null) return result.success(null)
} }
val msg = Message.obtain() var 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(Looper.getMainLooper()) { msg.replyTo = Messenger(object: Handler() {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
result.success(msg.data.getString("data")) result.success(msg.data.getString("data"))
} }
@ -358,7 +262,7 @@ class MainActivity: FlutterActivity() {
} }
val addr = call.argument<String>("addr") val addr = call.argument<String>("addr")
if (addr == "") { if (vpnIp == "") {
return result.error("required_argument", "addr is a required argument", null) return result.error("required_argument", "addr is a required argument", null)
} }
@ -366,18 +270,18 @@ class MainActivity: FlutterActivity() {
return result.success(null) return result.success(null)
} }
val msg = Message.obtain() var 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(Looper.getMainLooper()) { msg.replyTo = Messenger(object: Handler() {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
result.success(msg.data.getString("data")) result.success(msg.data.getString("data"))
} }
}) })
outMessenger?.send(msg) outMessenger?.send(msg)
} }
private fun activeCloseTunnel(call: MethodCall, result: MethodChannel.Result) { private fun activeCloseTunnel(call: MethodCall, result: MethodChannel.Result) {
val id = call.argument<String>("id") val id = call.argument<String>("id")
if (id == "") { if (id == "") {
@ -393,10 +297,10 @@ class MainActivity: FlutterActivity() {
return result.success(null) return result.success(null)
} }
val msg = Message.obtain() var 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(Looper.getMainLooper()) { msg.replyTo = Messenger(object: Handler() {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
result.success(msg.data.getBoolean("data")) result.success(msg.data.getBoolean("data"))
} }
@ -404,48 +308,52 @@ 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_START_CODE) { if (requestCode == VPN_PERMISSIONS_CODE && permResult != null) {
// If we are processing a result for VPN permissions and don't get them, let the UI know // We are processing a response for vpn permissions and the UI is waiting for feedback
val result = startResult!! //TODO: unlikely we ever register multiple attempts but this could be a trouble spot if we did
val siteContainer = startingSiteContainer!! val result = permResult!!
startResult = null permResult = null
startingSiteContainer = null if (resultCode == Activity.RESULT_OK) {
if (resultCode != Activity.RESULT_OK) { return result.success(null)
// 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)
} }
// Start the VPN service return result.error("denied", "User did not grant permission", null)
val intent = Intent(this, NebulaVpnService::class.java).apply {
putExtra("path", siteContainer.site.path) } else if (requestCode == VPN_START_CODE) {
putExtra("id", siteContainer.site.id) // We are processing a response for permissions while starting the VPN (or reusing code in the event we already have perms)
} startService(data)
startService(intent)
if (outMessenger == null) { if (outMessenger == null) {
bindService(intent, connection, 0) bindService(data, 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() */
private val connection = object : ServiceConnection { 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
@ -456,7 +364,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) {
@ -469,7 +377,7 @@ 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(Looper.getMainLooper()) { inner class IncomingHandler: Handler() {
override fun handleMessage(msg: Message) { override fun handleMessage(msg: Message) {
val id = msg.data.getString("id") val id = msg.data.getString("id")
@ -486,7 +394,7 @@ class MainActivity: FlutterActivity() {
private fun isRunning(site: SiteContainer, msg: Message) { private fun isRunning(site: SiteContainer, msg: Message) {
var status = "Disconnected" var status = "Disconnected"
var connected = false var connected = false
if (msg.arg1 == 1) { if (msg.arg1 == 1) {
status = "Connected" status = "Connected"
connected = true connected = true
@ -499,32 +407,6 @@ 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)
}
}
} }

View File

@ -1,19 +0,0 @@
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)
}
}

View File

@ -5,12 +5,10 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageManager
import android.net.* 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 androidx.annotation.RequiresApi
import mobileNebula.CIDR import mobileNebula.CIDR
import java.io.File import java.io.File
@ -18,11 +16,7 @@ import java.io.File
class NebulaVpnService : VpnService() { class NebulaVpnService : VpnService() {
companion object { companion object {
const val TAG = "NebulaVpnService" private 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
@ -40,66 +34,47 @@ 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 didSleep = false
private var networkCallback: NetworkCallback = NetworkCallback()
override fun onCreate() {
workManager = WorkManager.getInstance(this)
super.onCreate()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) { if (intent?.getStringExtra("COMMAND") == "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. announceExit(id, "Trying to run nebula but it is already running")
// 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")
}
//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(this, File(path!!)) site = Site(File(path))
if (site!!.cert == null) { if (site!!.certInfos == null || site!!.certInfos.isEmpty()) {
announceExit(id, "Site is missing a certificate") announceExit(id, "Site is missing a certificate")
//TODO: can we signal failure? //TODO: can we signal failure?
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() {
val ipNet: CIDR var ipNet: CIDR
try { try {
ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0]) ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.primaryCertInfo!!.cert.details.ips[0])
} catch (err: Exception) { } catch (err: Exception) {
return announceExit(site!!.id, err.message ?: "$err") return announceExit(site!!.id, err.message ?: "$err")
} }
@ -109,36 +84,21 @@ 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 unsafeIPNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route) val ipNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route)
builder.addRoute(unsafeIPNet.network, unsafeIPNet.maskSize.toInt()) builder.addRoute(ipNet.network, ipNet.maskSize.toInt())
} }
// Add our DNS resolvers val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
site!!.dnsResolvers.forEach { dnsResolver -> cm.allNetworks.forEach { network ->
builder.addDnsServer(dnsResolver) cm.getLinkProperties(network).dnsServers.forEach { builder.addDnsServer(it) }
} }
try { try {
vpnInterface = builder.establish() vpnInterface = builder.establish()
nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.detachFd().toLong()) nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.fd.toLong())
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Got an error $e") Log.e(TAG, "Got an error $e")
@ -148,24 +108,14 @@ class NebulaVpnService : VpnService() {
} }
registerNetworkCallback() registerNetworkCallback()
registerReloadReceiver()
//TODO: There is an open discussion around sleep killing tunnels or just changing mobile to tear down stale tunnels //TODO: There is an open discussion around sleep killing tunnels or just changing mobile to tear down stale tunnels
//registerSleep() //registerSleep()
nebula!!.start() nebula!!.start()
running = true running = true
sendSimple(MSG_IS_RUNNING, 1) sendSimple(MSG_IS_RUNNING, if (running) 1 else 0)
} }
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 // Used to detect network changes (wifi -> cell or vice versa) and rebinds the udp socket/updates LH
private fun registerNetworkCallback() { private fun registerNetworkCallback() {
val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager val connectivityManager = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
@ -173,27 +123,20 @@ class NebulaVpnService : VpnService() {
val builder = NetworkRequest.Builder() val builder = NetworkRequest.Builder()
builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) builder.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
connectivityManager.registerNetworkCallback(builder.build(), networkCallback) connectivityManager.registerNetworkCallback(builder.build(),
object : 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 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() { private fun registerSleep() {
val receiver: BroadcastReceiver = object : BroadcastReceiver() { val receiver: BroadcastReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) { override fun onReceive(context: Context, intent: Intent?) {
@ -214,40 +157,14 @@ class NebulaVpnService : VpnService() {
registerReceiver(receiver, IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED)) 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()
nebula = null vpnInterface?.close()
running = false running = false
announceExit(site?.id, null) announceExit(site?.id, null)
stopSelf()
} }
override fun onRevoke() { override fun onDestroy() {
stopVpn()
//TODO: wait for the thread to exit
super.onRevoke()
}
override fun onDestroy() {
stopVpn() stopVpn()
//TODO: wait for the thread to exit //TODO: wait for the thread to exit
super.onDestroy() super.onDestroy()
@ -262,22 +179,10 @@ 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 : Handler(Looper.getMainLooper()) { inner class IncomingHandler(context: Context, private val applicationContext: Context = context.applicationContext) : Handler() {
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
@ -318,16 +223,16 @@ 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)
val m = Message.obtain(null, msg.what) var m = Message.obtain(null, msg.what)
m.data.putString("data", res) m.data.putString("data", res)
msg.replyTo.send(m) msg.replyTo.send(m)
} }
private fun getHostInfo(msg: Message) { private fun getHostInfo(msg: Message) {
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"))
val m = Message.obtain(null, msg.what) var m = Message.obtain(null, msg.what)
m.data.putString("data", res) m.data.putString("data", res)
msg.replyTo.send(m) msg.replyTo.send(m)
} }
@ -336,16 +241,16 @@ 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"))
val m = Message.obtain(null, msg.what) var m = Message.obtain(null, msg.what)
m.data.putString("data", res) m.data.putString("data", res)
msg.replyTo.send(m) msg.replyTo.send(m)
} }
private fun closeTunnel(msg: Message) { private fun closeTunnel(msg: Message) {
if (protect(msg)) { return } if (protect(msg)) { return }
val res = nebula!!.closeTunnel(msg.data.getString("vpnIp")) val res = nebula!!.closeTunnel(msg.data.getString("vpnIp"))
val m = Message.obtain(null, msg.what) var m = Message.obtain(null, msg.what)
m.data.putBoolean("data", res) m.data.putBoolean("data", res)
msg.replyTo.send(m) msg.replyTo.send(m)
} }
@ -379,7 +284,7 @@ class NebulaVpnService : VpnService() {
return super.onBind(intent) return super.onBind(intent)
} }
messenger = Messenger(IncomingHandler()) messenger = Messenger(IncomingHandler(this))
return messenger.binder return messenger.binder
} }
} }

View File

@ -1,37 +0,0 @@
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
}
}

View File

@ -0,0 +1,134 @@
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.setPrimaryClip(ClipData.newPlainText("", file.readText()))
}
}
}
}

View File

@ -3,6 +3,7 @@ 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
@ -15,7 +16,7 @@ data class SiteContainer(
) )
class Sites(private var engine: FlutterEngine) { class Sites(private var engine: FlutterEngine) {
private var containers: HashMap<String, SiteContainer> = HashMap() private var sites: HashMap<String, SiteContainer> = HashMap()
init { init {
refreshSites() refreshSites()
@ -23,115 +24,64 @@ 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")
val sites = SiteList(context) if (!sitesDir.isDirectory) {
val containers: HashMap<String, SiteContainer> = HashMap() sitesDir.delete()
sites.getSites().values.forEach { site -> sitesDir.mkdir()
// Don't create a new SiteUpdater or we will lose subscribers }
var updater = this.containers[site.id]?.updater
if (updater != null) { sites = HashMap()
updater.setSite(site) sitesDir.listFiles().forEach { siteDir ->
} else { try {
updater = SiteUpdater(site, engine) val site = Site(siteDir)
}
// Make sure we can load the private key
if (site.id == activeSite) { site.getKey(context)
updater.setState(true, "Connected")
} val updater = SiteUpdater(site, engine)
if (site.id == activeSite) {
containers[site.id] = SiteContainer(site, updater) updater.setState(true, "Connected")
}
this.sites[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 containers.mapValues { it.value.site } return sites.mapValues { it.value.site }
} }
fun deleteSite(id: String) { fun deleteSite(id: String) {
val context = MainActivity.getContext()!! sites.remove(id)
val site = containers[id]!!.site val siteDir = MainActivity.getContext()!!.filesDir.resolve("sites").resolve(id)
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 containers[id] return sites[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, gson.toJson(site)) eventSink?.error("", err, d)
} else { } else {
eventSink?.success(gson.toJson(site)) eventSink?.success(d)
} }
} }
@ -151,9 +101,10 @@ class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.S
} }
data class CertificateInfo( data class CertificateInfo(
@SerializedName("Cert") val cert: Certificate, @SerializedName("Cert") val cert: Certificate,
@SerializedName("RawCert") val rawCert: String, @SerializedName("RawCert") val rawCert: String,
@SerializedName("Validity") val validity: CertificateValidity @SerializedName("Validity") val validity: CertificateValidity,
var primary: Boolean
) )
data class Certificate( data class Certificate(
@ -179,55 +130,36 @@ data class CertificateValidity(
@SerializedName("Reason") val reason: String @SerializedName("Reason") val reason: String
) )
data class DNCredentials( class Site {
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> val certInfos: ArrayList<CertificateInfo> = ArrayList()
var cert: CertificateInfo? = null lateinit var caInfos: Array<CertificateInfo>
var ca: Array<CertificateInfo>
val lhDuration: Int val lhDuration: Int
val port: Int val port: Int
val mtu: Int val mtu: Int
val cipher: String val cipher: String
val sortKey: Int val sortKey: Int
val logVerbosity: String var logVerbosity: String
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
@Transient @Expose(serialize = false)
val path: String val path: String
// Strong representation of the site config // Strong representation of the site config
@Transient @Expose(serialize = false)
val config: String val config: String
init { @Expose(serialize = false)
lateinit var primaryCertInfo: CertificateInfo
constructor(siteDir: File) {
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)
@ -237,7 +169,6 @@ class Site(context: Context, siteDir: File) {
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
@ -245,51 +176,41 @@ class Site(context: Context, siteDir: File) {
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"
rawConfig = incomingSite.rawConfig
managed = incomingSite.managed ?: false
lastManagedUpdate = incomingSite.lastManagedUpdate
connected = false connected = false
status = "Disconnected" status = "Disconnected"
try { incomingSite.certs?.forEach { certContainer ->
val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert) val certInfo = getCertDetails(certContainer.cert, gson) ?: return
val certs = gson.fromJson(rawDetails, Array<CertificateInfo>::class.java) certInfo.primary = certContainer.primary
if (certs.isEmpty()) { if (certInfo.primary) {
throw IllegalArgumentException("No certificate found") this.primaryCertInfo = certInfo
} }
cert = certs[0] this.certInfos.add(certInfo)
if (!cert!!.validity.valid) {
errors.add("Certificate is invalid: ${cert!!.validity.reason}")
}
} catch (err: Exception) {
errors.add("Error while loading certificate: ${err.message}")
} }
// Upgrade the old cert property if present
upgradeCert(incomingSite, gson)
try { try {
val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca) val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca)
ca = gson.fromJson(rawCa, Array<CertificateInfo>::class.java) caInfos = gson.fromJson(rawCa, Array<CertificateInfo>::class.java)
var hasErrors = false var hasErrors = false
ca.forEach { caInfos.forEach {
if (!it.validity.valid) { if (!it.validity.valid) {
hasErrors = true hasErrors = true
} }
} }
if (hasErrors && !managed) { if (hasErrors) {
errors.add("There are issues with 1 or more ca certificates") errors.add("There are issues with 1 or more ca certificates")
} }
} catch (err: Exception) { } catch (err: Exception) {
ca = arrayOf() caInfos = arrayOf()
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()!!))
@ -299,31 +220,69 @@ class Site(context: Context, siteDir: File) {
} }
} }
fun getKey(context: Context): String { // Upgrades cert -> certs in the stored site config if needed
val f = EncFile(context).openRead(File(path).resolve("key")) private fun upgradeCert(site: IncomingSite, gson: Gson) {
if (site.cert == null) {
// Nothing to do
return
}
val context = MainActivity.getContext()!!
// Try to get the details
val certInfo = getCertDetails(site.cert!!, gson) ?: return
// Push this cert in as the primary certificate
certInfo.primary
certInfos.add(certInfo)
// Upgrade the persisted object
site.cert = null
site.certs = arrayListOf(IncomingCert(certInfo.cert.fingerprint, certInfo.rawCert,true, null))
// Get the old key contents and delete the key
val oldKeyPath = File(path).resolve("key")
val oldKeyFile = EncFile(context).openRead(oldKeyPath)
val key = oldKeyFile.readText()
oldKeyFile.close()
oldKeyPath.delete()
// /data/data/net.defined.mobile_nebula/files/sites/8554c7fc-c2a5-4cba-9bc4-fe6e0eb3129a
// Write to the new path
val newKeyFile = EncFile(context).openWrite(File(path).resolve("key.${certInfo.cert.fingerprint}"))
newKeyFile.use { it.write(key) }
newKeyFile.close()
site.save(context)
}
private fun getCertDetails(rawCert: String, gson: Gson): CertificateInfo? {
var cert: CertificateInfo? = null
try {
val rawDetails = mobileNebula.MobileNebula.parseCerts(rawCert)
val certs = gson.fromJson(rawDetails, Array<CertificateInfo>::class.java)
if (certs.isEmpty()) {
throw IllegalArgumentException("No certificate found")
}
cert = certs[0]
if (!cert.validity.valid) {
errors.add("Certificate is invalid: ${cert.validity.reason}")
}
} catch (err: Exception) {
errors.add("Error while loading certificate: ${err.message}")
}
return cert
}
fun getKey(context: Context): String? {
val f = EncFile(context).openRead(File(path).resolve("key.${primaryCertInfo.cert.fingerprint}"))
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(
@ -337,50 +296,51 @@ data class UnsafeRoute(
val mtu: Int? val mtu: Int?
) )
data class IncomingCert(
// fingerprint is the cert fingerprint, only used as part of the key file name
val fingerprint: String,
val cert: String,
val primary: Boolean,
@Expose(serialize = false)
var key: String?
)
class IncomingSite( class IncomingSite(
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 certs: List<IncomingCert>?,
val cert: String,
val ca: String, val ca: String,
val lhDuration: Int, val lhDuration: Int,
val port: Int, val port: Int,
val mtu: Int?, val mtu: Int?,
val cipher: String, val cipher: String,
val sortKey: Int?, val sortKey: Int?,
val logVerbosity: String?, var logVerbosity: String?,
var key: String?,
val managed: Boolean?, @Deprecated("certs is the new property")
// The following fields are present when managed = true var cert: String?
val lastManagedUpdate: String?,
val rawConfig: String?,
var dnCredentials: DNCredentials?,
) { ) {
fun save(context: Context): File {
// Don't allow backups of DN-managed sites fun save(context: Context) {
val baseDir = if(managed == true) context.noBackupFilesDir else context.filesDir val siteDir = context.filesDir.resolve("sites").resolve(id)
val siteDir = baseDir.resolve("sites").resolve(id)
if (!siteDir.exists()) { if (!siteDir.exists()) {
siteDir.mkdir() siteDir.mkdir()
} }
if (key != null) { certs?.forEach { cert ->
val keyFile = siteDir.resolve("key") if (cert.key != null) {
keyFile.delete() val f = EncFile(context).openWrite(siteDir.resolve("key.${cert.fingerprint}"))
val encFile = EncFile(context).openWrite(keyFile) f.use { it.write(cert.key) }
encFile.use { it.write(key) } f.close()
encFile.close() }
cert.key = null
} }
key = null
dnCredentials?.save(context, siteDir)
dnCredentials = null
val gson = Gson()
val confFile = siteDir.resolve("config.json") val confFile = siteDir.resolve("config.json")
confFile.writeText(Gson().toJson(this)) confFile.writeText(gson.toJson(this))
return siteDir
} }
} }

View File

@ -1,4 +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">
<!-- 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.
--> -->

View File

@ -1,24 +1,20 @@
buildscript { buildscript {
ext { ext.kotlin_version = '1.3.61'
workVersion = "2.7.1"
kotlinVersion = '1.7.20'
}
repositories { repositories {
google() google()
mavenCentral() jcenter()
} }
dependencies { dependencies {
classpath 'com.android.tools.build:gradle:7.3.1' classpath 'com.android.tools.build:gradle:4.0.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
} }
} }
allprojects { allprojects {
repositories { repositories {
google() google()
mavenCentral() jcenter()
} }
} }
@ -30,6 +26,6 @@ subprojects {
project.evaluationDependsOn(':app') project.evaluationDependsOn(':app')
} }
tasks.register("clean", Delete) { task clean(type: Delete) {
delete rootProject.buildDir delete rootProject.buildDir
} }

View File

@ -1,2 +0,0 @@
package_name("net.defined.mobile_nebula")
json_key_file(ENV['GOOGLE_PLAY_API_JWT_PATH'])

View File

@ -1,50 +0,0 @@
# 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

View File

@ -1,40 +0,0 @@
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).

View File

@ -1,3 +1,4 @@
org.gradle.jvmargs=-Xmx1536M org.gradle.jvmargs=-Xmx1536M
android.enableR8=true
android.useAndroidX=true android.useAndroidX=true
android.enableJetifier=true android.enableJetifier=true

View File

@ -1,5 +1,6 @@
#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-7.4-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip

View File

@ -1,6 +0,0 @@
configurations.maybeCreate("default")
exec {
workingDir '../../'
commandLine './gen-artifacts.sh', 'android'
}
artifacts.add("default", file('mobileNebula.aar'))

View File

@ -1,11 +1,15 @@
include ':app', ':mobileNebula' include ':app'
def localPropertiesFile = new File(rootProject.projectDir, "local.properties") def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
def properties = new Properties()
assert localPropertiesFile.exists() def plugins = new Properties()
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
if (pluginsFile.exists()) {
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
}
def flutterSdkPath = properties.getProperty("flutter.sdk") plugins.each { name, path ->
assert flutterSdkPath != null, "flutter.sdk not set in local.properties" def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" include ":$name"
project(":$name").projectDir = pluginDirectory
}

View File

@ -0,0 +1 @@
include ':app'

View File

@ -9,16 +9,16 @@ cd nebula
if [ "$1" = "ios" ]; then if [ "$1" = "ios" ]; then
# Build for nebula for iOS # Build for nebula for iOS
make MobileNebula.xcframework make MobileNebula.framework
rm -rf ../ios/MobileNebula.xcframework rm -rf ../ios/NebulaNetworkExtension/MobileNebula.framework
cp -r MobileNebula.xcframework ../ios/ cp -r MobileNebula.framework ../ios/NebulaNetworkExtension/
elif [ "$1" = "android" ]; then elif [ "$1" = "android" ]; then
# Build nebula for android # Build nebula for android
make mobileNebula.aar make mobileNebula.aar
mkdir -p ../android/mobileNebula mkdir -p ../android/app/src/main/libs
rm -rf ../android/mobileNebula/mobileNebula.aar rm -rf ../android/app/src/main/libs/mobileNebula.aar
cp mobileNebula.aar ../android/mobileNebula/mobileNebula.aar cp mobileNebula.aar ../android/app/src/main/libs/mobileNebula.aar
else else
echo "Error: unsupported target os $1" echo "Error: unsupported target os $1"

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 421 B

View File

@ -1,4 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 423 B

View File

@ -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>11.0</string> <string>8.0</string>
</dict> </dict>
</plist> </plist>

View File

@ -1,3 +0,0 @@
source "https://rubygems.org"
gem "fastlane"

View File

@ -1,218 +0,0 @@
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

View File

@ -1,16 +0,0 @@
#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];
};

View File

@ -3,21 +3,17 @@ 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, managed: Bool) -> Bool { class func save(key: String, data: Data) -> Bool {
var query: [String: Any] = [ let 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,
] ]
if (managed) {
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
}
// Attempt to delete an existing key to allow for an overwrite SecItemDelete(query as CFDictionary)
_ = self.delete(key: key) let val = SecItemAdd(query as CFDictionary, nil)
return SecItemAdd(query as CFDictionary, nil) == 0 return val == 0
} }
class func load(key: String) -> Data? { class func load(key: String) -> Data? {
@ -42,8 +38,10 @@ 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 as String, kSecClass as String : kSecClassGenericPassword,
kSecAttrAccount as String : key, kSecAttrAccount as String : key,
kSecReturnData as String : kCFBooleanTrue!,
kSecMatchLimit as String : kSecMatchLimitOne,
kSecAttrAccessGroup as String: groupName, kSecAttrAccessGroup as String: groupName,
] ]

View File

@ -5,20 +5,27 @@ 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 = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider") private var _log = OSLog(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
private var nebula: MobileNebulaNebula? private var nebula: MobileNebulaNebula?
private var dnUpdater = DNUpdater()
private var didSleep = false private var didSleep = false
private var cachedRouteDescription: String? private var cachedRouteDescription: String?
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) { // This is the system completionHandler, only set when we expect the UI to ask us to actually start so that errors can flow back to the UI
private var startCompleter: ((Error?) -> Void)?
private func log(_ message: StaticString, _ args: Any...) {
os_log(message, log: _log, args)
}
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
// There is currently no way to get initialization errors back to the UI via completionHandler here // 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 // `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
// In the end we need to call this completionHandler to inform the system of our state
if options?["expectStart"] != nil { if options?["expectStart"] != nil {
// The system completion handler must be called before IPC will work startCompleter = completionHandler
completionHandler(nil)
return return
} }
@ -33,27 +40,35 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
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.error("Failed to render config from vpn object") log("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 {
return completionHandler(error) return completionHandler(error)
} }
let fileDescriptor = tunnelFileDescriptor let fileDescriptor = (self.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32) ?? -1
if fileDescriptor == nil { if fileDescriptor < 0 {
return completionHandler("Unable to locate the tun file descriptor") return completionHandler("Starting tunnel failed: Could not determine file descriptor")
} }
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")
@ -78,11 +93,6 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes tunnelNetworkSettings.ipv4Settings!.includedRoutes = routes
tunnelNetworkSettings.mtu = _site.mtu as NSNumber tunnelNetworkSettings.mtu = _site.mtu as NSNumber
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) {
@ -90,32 +100,19 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
} }
var err: NSError? var err: NSError?
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &err) self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, Int(fileDescriptor), &err)
self.startNetworkMonitor() self.startNetworkMonitor()
if err != nil { if err != nil {
self.log.error("We had an error starting up: \(err, privacy: .public)")
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) { //TODO: Sleep/wake get called aggresively and do nothing to help us here, we should locate why that is and make these work appropriately
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) { // override func sleep(completionHandler: @escaping () -> Void) {
// nebula!.sleep() // nebula!.sleep()
// completionHandler() // completionHandler()
@ -167,7 +164,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) { override func handleAppMessage(_ data: Data, completionHandler: ((Data?) -> Void)? = nil) {
guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else { guard let call = try? JSONDecoder().decode(IPCRequest.self, from: data) else {
log.error("Failed to decode IPCRequest from network extension") log("Failed to decode IPCRequest from network extension")
return return
} }
@ -177,6 +174,19 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
// start command has special treatment due to needing to call two completers // start command has special treatment due to needing to call two completers
if call.command == "start" { if call.command == "start" {
self.start() { error in self.start() { error in
// Notify the system of our start result
if self.startCompleter != nil {
if error == nil {
// Clean boot, no errors
self.startCompleter!(nil)
} else {
// We encountered an error, we can just pass NSError() here since ios throws it away
// But we will provide it in the event we can intercept the error without doing this workaround sometime in the future
self.startCompleter!(error!.localizedDescription)
}
}
// Notify the UI if we have a completionHandler // Notify the UI if we have a completionHandler
if completionHandler != nil { if completionHandler != nil {
if error == nil { if error == nil {
@ -184,9 +194,8 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))) completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))
} else { } else {
// We failed, notify and shutdown // Error response has
completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error!.localizedDescription)))) completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .error, message: JSON(error!.localizedDescription))))
self.cancelTunnelWithError(error)
} }
} }
} }
@ -195,7 +204,7 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
if nebula == nil { if nebula == nil {
// Respond with an empty success message in the event a command comes in before we've truly started // 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") log("Received command but do not have a nebula instance")
return completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil))) return completionHandler!(try? JSONEncoder().encode(IPCResponse.init(type: .success, message: nil)))
} }
@ -240,37 +249,5 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
let res = nebula!.closeTunnel(args["vpnIp"].string) let res = nebula!.closeTunnel(args["vpnIp"].string)
return (JSON(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
}
} }

View File

@ -13,7 +13,7 @@ class IPCResponse: Codable {
var type: IPCResponseType var type: IPCResponseType
//TODO: change message to data? //TODO: change message to data?
var message: JSON? var message: JSON?
init(type: IPCResponseType, message: JSON?) { init(type: IPCResponseType, message: JSON?) {
self.type = type self.type = type
self.message = message self.message = message
@ -23,12 +23,12 @@ class IPCResponse: Codable {
class IPCRequest: Codable { class IPCRequest: Codable {
var command: String var command: String
var arguments: JSON? var arguments: JSON?
init(command: String, arguments: JSON?) { init(command: String, arguments: JSON?) {
self.command = command self.command = command
self.arguments = arguments self.arguments = arguments
} }
init(command: String) { init(command: String) {
self.command = command self.command = command
} }
@ -38,7 +38,7 @@ struct CertificateInfo: Codable {
var cert: Certificate var cert: Certificate
var rawCert: String var rawCert: String
var validity: CertificateValidity var validity: CertificateValidity
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case cert = "Cert" case cert = "Cert"
case rawCert = "RawCert" case rawCert = "RawCert"
@ -50,8 +50,8 @@ struct Certificate: Codable {
var fingerprint: String var fingerprint: String
var signature: String var signature: String
var details: CertificateDetails var details: CertificateDetails
/// An empty initializer to make error reporting easier /// An empty initilizer to make error reporting easier
init() { init() {
fingerprint = "" fingerprint = ""
signature = "" signature = ""
@ -69,8 +69,8 @@ struct CertificateDetails: Codable {
var subnets: [String] var subnets: [String]
var isCa: Bool var isCa: Bool
var issuer: String var issuer: String
/// An empty initializer to make error reporting easier /// An empty initilizer to make error reporting easier
init() { init() {
name = "" name = ""
notBefore = "" notBefore = ""
@ -87,7 +87,7 @@ struct CertificateDetails: Codable {
struct CertificateValidity: Codable { struct CertificateValidity: Codable {
var valid: Bool var valid: Bool
var reason: String var reason: String
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case valid = "Valid" case valid = "Valid"
case reason = "Reason" case reason = "Reason"
@ -97,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: false, NEVPNStatus.connecting: true,
NEVPNStatus.connected: true, NEVPNStatus.connected: true,
NEVPNStatus.reasserting: true, NEVPNStatus.reasserting: true,
NEVPNStatus.disconnecting: true, NEVPNStatus.disconnecting: true,
@ -117,11 +117,10 @@ class Site: Codable {
// Stored in manager // Stored in manager
var name: String var name: String
var id: String var id: String
// 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
@ -133,22 +132,13 @@ class Site: Codable {
var connected: Bool? //TODO: active is a better name 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]
var manager: NETunnelProviderManager? var manager: NETunnelProviderManager?
var incomingSite: IncomingSite? // Creates a new site from a vpn manager instance
/// 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 { 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
@ -157,64 +147,33 @@ class Site: Codable {
self.connected = statusMap[manager.connection.status] self.connected = statusMap[manager.connection.status]
self.status = statusString[manager.connection.status] self.status = statusString[manager.connection.status]
} }
convenience init(proto: NETunnelProviderProtocol) throws { convenience init(proto: NETunnelProviderProtocol) throws {
let dict = proto.providerConfiguration let dict = proto.providerConfiguration
let config = dict?["config"] as? Data ?? Data()
if dict?["config"] != nil {
let config = dict?["config"] as? Data ?? Data()
let decoder = JSONDecoder()
let incoming = try decoder.decode(IncomingSite.self, from: config)
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 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)
} }
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
let rawDetails = MobileNebulaParseCerts(rawCert, &err) let rawDetails = MobileNebulaParseCerts(rawCert, &err)
if (err != nil) { if (err != nil) {
throw err! throw err!
} }
var certs: [CertificateInfo] var certs: [CertificateInfo]
certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!) certs = try JSONDecoder().decode([CertificateInfo].self, from: rawDetails.data(using: .utf8)!)
if (certs.count == 0) { if (certs.count == 0) {
throw "No certificate found" throw "No certificate found"
@ -223,11 +182,11 @@ class Site: Codable {
if (!cert!.validity.valid) { if (!cert!.validity.valid) {
errors.append("Certificate is invalid: \(cert!.validity.reason)") errors.append("Certificate is invalid: \(cert!.validity.reason)")
} }
} catch { } catch {
errors.append("Error while loading certificate: \(error.localizedDescription)") errors.append("Error while loading certificate: \(error.localizedDescription)")
} }
do { do {
let rawCa = incoming.ca let rawCa = incoming.ca
let rawCaDetails = MobileNebulaParseCerts(rawCa, &err) let rawCaDetails = MobileNebulaParseCerts(rawCa, &err)
@ -235,34 +194,31 @@ class Site: Codable {
throw err! throw err!
} }
ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!) ca = try JSONDecoder().decode([CertificateInfo].self, from: rawCaDetails.data(using: .utf8)!)
var hasErrors = false var hasErrors = false
ca.forEach { cert in ca.forEach { cert in
if (!cert.validity.valid) { if (!cert.validity.valid) {
hasErrors = true hasErrors = true
} }
} }
if (hasErrors && !managed) { if (hasErrors) {
errors.append("There are issues with 1 or more ca certificates") errors.append("There are issues with 1 or more ca certificates")
} }
} catch { } catch {
ca = [] ca = []
errors.append("Error while loading certificate authorities: \(error.localizedDescription)") errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
} }
do { lhDuration = incoming.lhDuration
logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path port = incoming.port
} catch { cipher = incoming.cipher
logFile = nil sortKey = incoming.sortKey ?? 0
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 {
let encoder = JSONEncoder() let encoder = JSONEncoder()
@ -270,7 +226,6 @@ class 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!
@ -280,53 +235,17 @@ class 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 from keychain" throw "failed to get key material 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
@ -342,13 +261,9 @@ class Site: Codable {
case status case status
case logFile case logFile
case unsafeRoutes case unsafeRoutes
case dnsResolvers
case logVerbosity case logVerbosity
case errors case errors
case mtu case mtu
case managed
case lastManagedUpdate
case rawConfig
} }
} }
@ -363,41 +278,12 @@ 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
@ -407,98 +293,76 @@ struct IncomingSite: Codable {
var sortKey: Int? var sortKey: Int?
var logVerbosity: String? var logVerbosity: String?
var key: String? var key: String?
var managed: Bool?
// The following fields are present if managed = true func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
var dnCredentials: DNCredentials? #if targetEnvironment(simulator)
var lastManagedUpdate: String? let fileManager = FileManager.default
var rawConfig: String? let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id)
func getConfig() throws -> Data {
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 {
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true) var config = self
config.key = nil
} catch { let rawConfig = try encoder.encode(config)
callback(error) try rawConfig.write(to: sitePath)
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 {
return callback(error)
}
try self.getConfig().write(to: configPath)
} catch { } catch {
return callback(error) 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
if (error != nil) { if (error != nil) {
return callback(error) return callback(error)
} }
return self.finishSaveToManager(manager: manager!, callback: callback) return self.finish(manager: manager!, callback: callback)
} }
return return
} }
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback) return finish(manager: NETunnelProviderManager(), callback: callback)
#endif
} }
private func finish(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
private func finishSaveToManager(manager: NETunnelProviderManager, callback: @escaping (Error?) -> ()) {
// 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
proto.providerConfiguration = ["id": self.id] // We tried using NSSecureCoder but that was obnoxious and didn't work so back to JSON
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
manager.protocolConfiguration = proto manager.protocolConfiguration = proto
//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 = self.name manager.localizedDescription = config.name
manager.isEnabled = true manager.isEnabled = true
manager.saveToPreferences{ error in manager.saveToPreferences{ error in

View File

@ -1,140 +0,0 @@
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
}
}

View File

@ -30,7 +30,7 @@ flutter_ios_podfile_setup
target 'Runner' do target 'Runner' do
use_frameworks! use_frameworks!
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 'SwiftyJSON', '~> 5.0' pod 'SwiftyJSON', '~> 5.0'
end end
@ -41,16 +41,6 @@ target 'NebulaNetworkExtension' do
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

View File

@ -1,103 +1,110 @@
PODS: PODS:
- DKImagePickerController/Core (4.3.4): - barcode_scan (0.0.1):
- Flutter
- MTBBarcodeScanner
- SwiftProtobuf
- DKImagePickerController/Core (4.3.0):
- DKImagePickerController/ImageDataManager - DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource - DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.4) - DKImagePickerController/ImageDataManager (4.3.0)
- DKImagePickerController/PhotoGallery (4.3.4): - DKImagePickerController/PhotoGallery (4.3.0):
- DKImagePickerController/Core - DKImagePickerController/Core
- DKPhotoGallery - DKPhotoGallery
- DKImagePickerController/Resource (4.3.4) - DKImagePickerController/Resource (4.3.0)
- DKPhotoGallery (0.0.17): - DKPhotoGallery (0.0.15):
- DKPhotoGallery/Core (= 0.0.17) - DKPhotoGallery/Core (= 0.0.15)
- DKPhotoGallery/Model (= 0.0.17) - DKPhotoGallery/Model (= 0.0.15)
- DKPhotoGallery/Preview (= 0.0.17) - DKPhotoGallery/Preview (= 0.0.15)
- DKPhotoGallery/Resource (= 0.0.17) - DKPhotoGallery/Resource (= 0.0.15)
- SDWebImage - SDWebImage
- SwiftyGif - SDWebImageFLPlugin
- DKPhotoGallery/Core (0.0.17): - DKPhotoGallery/Core (0.0.15):
- DKPhotoGallery/Model - DKPhotoGallery/Model
- DKPhotoGallery/Preview - DKPhotoGallery/Preview
- SDWebImage - SDWebImage
- SwiftyGif - SDWebImageFLPlugin
- DKPhotoGallery/Model (0.0.17): - DKPhotoGallery/Model (0.0.15):
- SDWebImage - SDWebImage
- SwiftyGif - SDWebImageFLPlugin
- DKPhotoGallery/Preview (0.0.17): - DKPhotoGallery/Preview (0.0.15):
- DKPhotoGallery/Model - DKPhotoGallery/Model
- DKPhotoGallery/Resource - DKPhotoGallery/Resource
- SDWebImage - SDWebImage
- SwiftyGif - SDWebImageFLPlugin
- DKPhotoGallery/Resource (0.0.17): - DKPhotoGallery/Resource (0.0.15):
- SDWebImage - SDWebImage
- SwiftyGif - SDWebImageFLPlugin
- 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)
- flutter_barcode_scanner (2.0.0): - MTBBarcodeScanner (5.0.11)
- Flutter
- package_info (0.0.1): - package_info (0.0.1):
- Flutter - Flutter
- path_provider_ios (0.0.1): - path_provider (0.0.1):
- Flutter - Flutter
- SDWebImage (5.15.5): - SDWebImage (5.8.0):
- SDWebImage/Core (= 5.15.5) - SDWebImage/Core (= 5.8.0)
- SDWebImage/Core (5.15.5) - SDWebImage/Core (5.8.0)
- share_plus (0.0.1): - SDWebImageFLPlugin (0.4.0):
- Flutter - FLAnimatedImage (>= 1.0.11)
- SwiftyGif (5.4.4) - SDWebImage/Core (~> 5.6)
- SwiftProtobuf (1.8.0)
- SwiftyJSON (5.0.1) - SwiftyJSON (5.0.1)
- url_launcher_ios (0.0.1): - url_launcher (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`)
- 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_ios (from `.symlinks/plugins/path_provider_ios/ios`) - path_provider (from `.symlinks/plugins/path_provider/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- SwiftyJSON (~> 5.0) - SwiftyJSON (~> 5.0)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - url_launcher (from `.symlinks/plugins/url_launcher/ios`)
SPEC REPOS: SPEC REPOS:
trunk: trunk:
- DKImagePickerController - DKImagePickerController
- DKPhotoGallery - DKPhotoGallery
- FLAnimatedImage
- MTBBarcodeScanner
- SDWebImage - SDWebImage
- SwiftyGif - SDWebImageFLPlugin
- SwiftProtobuf
- SwiftyJSON - 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_ios: path_provider:
:path: ".symlinks/plugins/path_provider_ios/ios" :path: ".symlinks/plugins/path_provider/ios"
share_plus: url_launcher:
:path: ".symlinks/plugins/share_plus/ios" :path: ".symlinks/plugins/url_launcher/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
SPEC CHECKSUMS: SPEC CHECKSUMS:
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac barcode_scan: a5c27959edfafaa0c771905bad0b29d6d39e4479
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 DKImagePickerController: 397702a3590d4958fad336e9a77079935c500ddb
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95 DKPhotoGallery: e880aef16c108333240e1e7327896f2ea380f4f0
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
flutter_barcode_scanner: 7a1144744c28dc0c57a8de7218ffe5ec59a9e4bf FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31
Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62 package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5 path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe SDWebImage: 84000f962cbfa70c07f19d2234cbfcf5d779b5dc
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68 SDWebImageFLPlugin: 6c2295fb1242d44467c6c87dc5db6b0a13228fd8
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f SwiftProtobuf: 2cbd9409689b7df170d82a92a33443c8e3e14a70
SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e
url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
PODFILE CHECKSUM: b4b37a776e1b487bf31fc5e5014fa5a74f5a022a PODFILE CHECKSUM: 87c61886589bcc4c3c709db9ee22a607d81c4861
COCOAPODS: 1.11.3 COCOAPODS: 1.10.1

View File

@ -3,36 +3,29 @@
archiveVersion = 1; archiveVersion = 1;
classes = { classes = {
}; };
objectVersion = 52; objectVersion = 46;
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 */; };
43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; }; 43AD63F424EB3802000FB47E /* Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD63F324EB3802000FB47E /* Share.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 */; };
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 */; }; E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5C0A96949A0B117C4ACE752C /* Pods_NebulaNetworkExtension.framework */; };
/* End PBXBuildFile section */ /* End PBXBuildFile section */
@ -76,11 +69,9 @@
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>"; }; 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; };
@ -88,9 +79,11 @@
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; };
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNUpdate.swift; sourceTree = "<group>"; }; 43B828DA249C08DC00CA229C /* MMWormhole.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MMWormhole.framework; sourceTree = BUILT_PRODUCTS_DIR; };
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>"; }; 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; }; 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>"; };
@ -106,8 +99,6 @@
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>"; };
/* End PBXFileReference section */ /* End PBXFileReference section */
@ -117,7 +108,7 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */, 43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */,
43498726289B484C00476B19 /* MobileNebula.xcframework in Frameworks */, 43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */,
E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */, E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
@ -127,8 +118,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;
}; };
@ -138,6 +129,8 @@
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 */,
@ -151,13 +144,11 @@
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>";
@ -208,9 +199,7 @@
74858FAE1ED2DC5600515810 /* AppDelegate.swift */, 74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
43871C9C2444E2EC004F9075 /* Sites.swift */, 43871C9C2444E2EC004F9075 /* Sites.swift */,
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */, 43AD63F324EB3802000FB47E /* Share.swift */,
BE45F625291AEAB300902884 /* PackageInfo.swift */,
BE5BC105291C41E600B6FE5B /* APIClient.swift */,
); );
path = Runner; path = Runner;
sourceTree = "<group>"; sourceTree = "<group>";
@ -243,6 +232,7 @@
buildConfigurationList = 43AA895D2444DA6500EDC39C /* Build configuration list for PBXNativeTarget "NebulaNetworkExtension" */; buildConfigurationList = 43AA895D2444DA6500EDC39C /* Build configuration list for PBXNativeTarget "NebulaNetworkExtension" */;
buildPhases = ( buildPhases = (
2C0A52E24BC9F327251CBAD2 /* [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 */,
@ -287,7 +277,7 @@
isa = PBXProject; isa = PBXProject;
attributes = { attributes = {
LastSwiftUpdateCheck = 1140; LastSwiftUpdateCheck = 1140;
LastUpgradeCheck = 1300; LastUpgradeCheck = 1020;
ORGANIZATIONNAME = "The Chromium Authors"; ORGANIZATIONNAME = "The Chromium Authors";
TargetAttributes = { TargetAttributes = {
43AA89532444DA6500EDC39C = { 43AA89532444DA6500EDC39C = {
@ -352,29 +342,33 @@
"${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",
"${BUILT_PRODUCTS_DIR}/MTBBarcodeScanner/MTBBarcodeScanner.framework",
"${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework", "${BUILT_PRODUCTS_DIR}/SDWebImage/SDWebImage.framework",
"${BUILT_PRODUCTS_DIR}/SwiftyGif/SwiftyGif.framework", "${BUILT_PRODUCTS_DIR}/SDWebImageFLPlugin/SDWebImageFLPlugin.framework",
"${BUILT_PRODUCTS_DIR}/SwiftProtobuf/SwiftProtobuf.framework",
"${BUILT_PRODUCTS_DIR}/SwiftyJSON/SwiftyJSON.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_ios/path_provider_ios.framework", "${BUILT_PRODUCTS_DIR}/path_provider/path_provider.framework",
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", "${BUILT_PRODUCTS_DIR}/url_launcher/url_launcher.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}/MTBBarcodeScanner.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyGif.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageFLPlugin.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftProtobuf.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.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_ios.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher.framework",
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework",
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh; shellPath = /bin/sh;
@ -417,6 +411,24 @@
shellPath = /bin/sh; 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"; 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 */ = { 9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase; isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -460,12 +472,8 @@
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;
@ -475,14 +483,11 @@
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
432D0E3E291C562200752563 /* SiteList.swift in Sources */, 43AD63F424EB3802000FB47E /* Share.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;
}; };
@ -574,7 +579,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 = 4; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 576H3XS7FP; DEVELOPMENT_TEAM = 576H3XS7FP;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
@ -584,15 +589,12 @@
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = ( LIBRARY_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
); );
MARKETING_VERSION = 0.1.0; MARKETING_VERSION = 0.0.40;
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";
@ -612,7 +614,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 = 4; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 576H3XS7FP; DEVELOPMENT_TEAM = 576H3XS7FP;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
@ -622,12 +624,8 @@
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 = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)", MARKETING_VERSION = 0.0.40;
"@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,7 +633,6 @@
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";
@ -653,7 +650,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 = 4; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 576H3XS7FP; DEVELOPMENT_TEAM = 576H3XS7FP;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
@ -663,18 +660,13 @@
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 = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)", MARKETING_VERSION = 0.0.40;
"@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";
}; };
@ -691,7 +683,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 = 4; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 576H3XS7FP; DEVELOPMENT_TEAM = 576H3XS7FP;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
@ -701,18 +693,13 @@
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 = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
"$(inherited)", MARKETING_VERSION = 0.0.40;
"@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";
}; };
@ -832,7 +819,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 = 4; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 576H3XS7FP; DEVELOPMENT_TEAM = 576H3XS7FP;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
@ -842,15 +829,12 @@
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = ( LIBRARY_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
); );
MARKETING_VERSION = 0.1.0; MARKETING_VERSION = 0.0.40;
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";
@ -868,7 +852,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 = 4; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_TEAM = 576H3XS7FP; DEVELOPMENT_TEAM = 576H3XS7FP;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
FRAMEWORK_SEARCH_PATHS = ( FRAMEWORK_SEARCH_PATHS = (
@ -878,15 +862,12 @@
); );
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
IPHONEOS_DEPLOYMENT_TARGET = 14.0; IPHONEOS_DEPLOYMENT_TARGET = 14.0;
LD_RUNPATH_SEARCH_PATHS = ( LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
"$(inherited)",
"@executable_path/Frameworks",
);
LIBRARY_SEARCH_PATHS = ( LIBRARY_SEARCH_PATHS = (
"$(inherited)", "$(inherited)",
"$(PROJECT_DIR)/Flutter", "$(PROJECT_DIR)/Flutter",
); );
MARKETING_VERSION = 0.1.0; MARKETING_VERSION = 0.0.40;
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";

View File

@ -1,28 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1300" LastUpgradeVersion = "1020"
version = "1.7"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
buildImplicitDependencies = "YES"> buildImplicitDependencies = "YES">
<PreActions>
<ExecutionAction
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
<ActionContent
title = "Run Script"
scriptText = "cd &quot;$PROJECT_DIR&quot;/..&#10;./gen-artifacts.sh ios&#10;">
<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"

View File

@ -1,54 +0,0 @@
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
}
}
}

View File

@ -14,11 +14,7 @@ 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 ui: FlutterMethodChannel?
override func application( override func application(
_ application: UIApplication, _ application: UIApplication,
@ -26,35 +22,18 @@ 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)
ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger) let channel = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
ui!.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in 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)
@ -68,6 +47,9 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
case "active.setRemoteForTunnel": self.vpnRequest(command: "setRemoteForTunnel", arguments: call.arguments, result: result) case "active.setRemoteForTunnel": self.vpnRequest(command: "setRemoteForTunnel", arguments: call.arguments, result: result)
case "active.closeTunnel": self.vpnRequest(command: "closeTunnel", arguments: call.arguments, 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)
} }
@ -89,21 +71,6 @@ 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)
@ -126,25 +93,6 @@ 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) {
@ -184,9 +132,7 @@ 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)
}
} }
} }

View File

@ -1,10 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<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"> <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">
<device id="retina6_0" orientation="portrait" appearance="light"/>
<dependencies> <dependencies>
<deployment identifier="iOS"/> <deployment identifier="iOS"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/> <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies> </dependencies>
<scenes> <scenes>
<!--Flutter View Controller--> <!--Flutter View Controller-->
@ -16,14 +14,13 @@
<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="390" height="844"/> <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/> <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/> <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
</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>

View File

@ -1,136 +0,0 @@
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()
}
}

View File

@ -2,8 +2,6 @@
<!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>
@ -20,23 +18,8 @@
<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>

View File

@ -1,26 +0,0 @@
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)"
}
}

View File

@ -2,10 +2,6 @@
<!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>

150
ios/Runner/Share.swift Normal file
View File

@ -0,0 +1,150 @@
// 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
}
}
}
}

View File

@ -12,7 +12,7 @@ class SiteContainer {
} }
class Sites { class Sites {
private var containers = [String: SiteContainer]() private var sites = [String: SiteContainer]()
private var messenger: FlutterBinaryMessenger? private var messenger: FlutterBinaryMessenger?
init(messenger: FlutterBinaryMessenger?) { init(messenger: FlutterBinaryMessenger?) {
@ -20,44 +20,77 @@ class Sites {
} }
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) { func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
_ = SiteList { (sites, err) in #if targetEnvironment(simulator)
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)
} }
sites?.values.forEach{ site in newManagers?.forEach { manager in
var updater = self.containers[site.id]?.updater do {
if (updater != nil) { let site = try Site(manager: manager)
updater!.setSite(site: site) // Load the private key to make sure we can
} else { _ = try site.getKey()
updater = SiteUpdater(messenger: self.messenger!, site: site) let 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.containers.mapValues { let justSites = self.sites.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.containers.removeValue(forKey: id) { if let site = self.sites.removeValue(forKey: id) {
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials") #if targetEnvironment(simulator)
_ = KeyChain.delete(key: "\(site.site.id).key") let fileManager = FileManager.default
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(site.site.id)
do { try? fileManager.removeItem(at: sitePath)
let fileManager = FileManager.default #else
let siteDir = try SiteList.getSiteDir(id: site.site.id) _ = KeyChain.delete(key: site.site.id)
try fileManager.removeItem(at: siteDir)
} catch {
print("Failed to delete site from fs: \(error.localizedDescription)")
}
#if !targetEnvironment(simulator)
site.site.manager!.removeFromPreferences(completionHandler: callback) site.site.manager!.removeFromPreferences(completionHandler: callback)
return
#endif #endif
} }
@ -66,15 +99,15 @@ class Sites {
} }
func getSite(id: String) -> Site? { func getSite(id: String) -> Site? {
return self.containers[id]?.site return self.sites[id]?.site
} }
func getUpdater(id: String) -> SiteUpdater? { func getUpdater(id: String) -> SiteUpdater? {
return self.containers[id]?.updater return self.sites[id]?.updater
} }
func getContainer(id: String) -> SiteContainer? { func getContainer(id: String) -> SiteContainer? {
return self.containers[id] return self.sites[id]
} }
} }
@ -84,72 +117,39 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
private var site: Site private var site: Site
private var notification: Any? private var notification: Any?
public var startFunc: (() -> Void)? 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 /// 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;
#if !targetEnvironment(simulator)
if site.manager == nil {
//TODO: The dn updater path seems to race to build a site that lacks a manager. The UI does not display this error
// and a another listen should occur and succeed.
return FlutterError(code: "Internal Error", message: "Flutter manager was not present", details: nil)
}
self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in self.notification = NotificationCenter.default.addObserver(forName: NSNotification.Name.NEVPNStatusDidChange, object: site.manager!.connection , queue: nil) { n in
let oldConnected = self.site.connected let connected = self.site.connected
self.site.status = statusString[self.site.manager!.connection.status] self.site.status = statusString[self.site.manager!.connection.status]
self.site.connected = statusMap[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 // 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 { if self.site.connected! && connected != self.site.connected && self.startFunc != nil {
self.startFunc!() self.startFunc!()
self.startFunc = nil self.startFunc = nil
} }
self.update(connected: self.site.connected!) let d: Dictionary<String, Any> = [
"connected": self.site.connected!,
"status": self.site.status!,
]
self.eventSink?(d)
} }
#endif
return nil return nil
} }
/// onCancel is called when the flutter listener stops listening /// onCancel is called when the flutter listener stops listening
func onCancel(withArguments arguments: Any?) -> FlutterError? { func onCancel(withArguments arguments: Any?) -> FlutterError? {
if (self.notification != nil) { if (self.notification != nil) {
@ -159,27 +159,11 @@ class SiteUpdater: NSObject, FlutterStreamHandler {
} }
/// update is a way to send information to the flutter listener and generally should not be used directly /// update is a way to send information to the flutter listener and generally should not be used directly
func update(connected: Bool, replaceSite: Site? = nil) { func update(connected: Bool) {
if (replaceSite != nil) { let d: Dictionary<String, Any> = [
site = replaceSite! "connected": connected,
} "status": connected ? "Connected" : "Disconnected",
site.connected = connected ]
site.status = connected ? "Connected" : "Disconnected" self.eventSink?(d)
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)
} }
} }

View File

@ -1,6 +0,0 @@
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

View File

@ -1,84 +0,0 @@
# 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

View File

@ -1,14 +0,0 @@
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

View File

@ -1,5 +1,7 @@
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';
@ -8,7 +10,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,
@ -21,12 +23,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();
@ -44,7 +46,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 ?? "") ?? 0; cidr.bits = int.tryParse(widget.bitsController?.text ?? "");
super.initState(); super.initState();
} }
@ -54,50 +56,42 @@ class _CIDRFieldState extends State<CIDRField> {
return Container( return Container(
child: Row(children: <Widget>[ child: Row(children: <Widget>[
Expanded( Expanded(
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB(6, 6, 2, 6), padding: EdgeInsets.fromLTRB(6, 6, 2, 6),
child: IPField( child: IPField(
help: widget.ipHelp, help: widget.ipHelp,
ipOnly: true, ipOnly: true,
textPadding: EdgeInsets.all(0), textPadding: EdgeInsets.all(0),
textInputAction: TextInputAction.next, textInputAction: TextInputAction.next,
textAlign: TextAlign.end, textAlign: TextAlign.end,
focusNode: widget.focusNode, focusNode: widget.focusNode,
nextFocusNode: bitsFocus, nextFocusNode: bitsFocus,
onChanged: (val) {
cidr.ip = val;
widget.onChanged(cidr);
},
controller: widget.ipController,
))),
Text("/"),
Container(
width: Utils.textSize("bits", textStyle).width + 12,
padding: EdgeInsets.fromLTRB(2, 6, 6, 6),
child: SpecialTextField(
keyboardType: TextInputType.number,
focusNode: bitsFocus,
nextFocusNode: widget.nextFocusNode,
controller: widget.bitsController,
onChanged: (val) { onChanged: (val) {
if (widget.onChanged == null) { cidr.bits = int.tryParse(val ?? "");
return; widget.onChanged(cidr);
}
cidr.ip = val;
widget.onChanged!(cidr);
}, },
controller: widget.ipController, maxLength: 2,
))), inputFormatters: [FilteringTextInputFormatter.digitsOnly],
Text("/"), textInputAction: widget.textInputAction ?? TextInputAction.done,
Container( placeholder: 'bits',
width: Utils.textSize("bits", textStyle).width + 12, ))
padding: EdgeInsets.fromLTRB(2, 6, 6, 6), ]));
child: SpecialTextField(
keyboardType: TextInputType.number,
focusNode: bitsFocus,
nextFocusNode: widget.nextFocusNode,
controller: widget.bitsController,
onChanged: (val) {
if (widget.onChanged == null) {
return;
}
cidr.bits = int.tryParse(val) ?? 0;
widget.onChanged!(cidr);
},
maxLength: 2,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
textInputAction: widget.textInputAction ?? TextInputAction.done,
placeholder: 'bits',
))
]));
} }
@override @override

View File

@ -1,20 +1,21 @@
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, auto-validate, enabled? //TODO: onSaved, validator, autovalidate, enabled?
CIDRFormField({ CIDRFormField({
Key? key, Key key,
autoFocus = false, autoFocus = false,
enableIPV6 = 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(
@ -30,14 +31,14 @@ class CIDRFormField extends FormField<CIDR> {
return 'Please enter a valid ip address'; return 'Please enter a valid ip address';
} }
if (cidr.bits > 32 || cidr.bits < 0) { if (cidr.bits == null || 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 as _CIDRFormField; final _CIDRFormField state = field;
void onChangedHandler(CIDR value) { void onChangedHandler(CIDR value) {
if (onChanged != null) { if (onChanged != null) {
@ -57,50 +58,50 @@ class CIDRFormField extends FormField<CIDR> {
bitsController: state._effectiveBitsController, bitsController: state._effectiveBitsController,
), ),
field.hasError field.hasError
? Text(field.errorText ?? "Unknown error", ? Text(field.errorText,
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(); TextEditingController _ipController;
TextEditingController? _bitsController = TextEditingController(); TextEditingController _bitsController;
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 as CIDRFormField; CIDRFormField get widget => super.widget;
@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 ?? "") ?? 0); var update = CIDR(ip: widget.ipController?.text, bits: int.tryParse(widget.bitsController?.text ?? "") ?? null);
bool shouldUpdate = false; bool shouldUpdate = false;
if (widget.ipController != oldWidget.ipController) { if (widget.ipController != oldWidget.ipController) {
@ -108,12 +109,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 +124,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 +150,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,11 +164,7 @@ 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 (value == null) { if (_effectiveIPController.text != value.ip || effectiveBits != value.bits) {
return;
}
if (_effectiveIPController.text != value!.ip || effectiveBits != value!.bits) {
didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits)); didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits));
} }
} }

View File

@ -8,19 +8,12 @@ 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, {Key key, this.title, @required this.child, @required this.onSave, @required this.changed, this.hideSave = false})
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;
@ -57,8 +50,7 @@ class _FormPageState extends State<FormPage> {
child: SimplePage( child: SimplePage(
leadingAction: _buildLeader(context), leadingAction: _buildLeader(context),
trailingActions: _buildTrailer(context), trailingActions: _buildTrailer(context),
scrollController: widget.scrollController, title: widget.title,
title: Text(widget.title),
child: Form( child: Form(
key: _formKey, key: _formKey,
onChanged: () => setState(() { onChanged: () => setState(() {
@ -90,15 +82,11 @@ class _FormPageState extends State<FormPage> {
Utils.trailingSaveWidget( Utils.trailingSaveWidget(
context, context,
() { () {
if (_formKey.currentState == null) { if (!_formKey.currentState.validate()) {
return; return;
} }
if (!_formKey.currentState!.validate()) { _formKey.currentState.save();
return;
}
_formKey.currentState!.save();
widget.onSave(); widget.onSave();
}, },
) )

View File

@ -1,5 +1,7 @@
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';
@ -8,13 +10,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,
required this.onChanged, this.onChanged,
this.textInputAction, this.textInputAction,
this.noBorder = false, this.noBorder = false,
this.ipTextAlign, this.ipTextAlign,
@ -25,14 +27,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();
@ -87,7 +89,7 @@ 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,

View File

@ -1,4 +1,5 @@
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';
@ -6,19 +7,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, auto-validate, enabled? //TODO: onSaved, validator, autovalidate, enabled?
IPAndPortFormField({ IPAndPortFormField({
Key? key, Key key,
ipOnly = false, ipOnly = false,
enableIPV6 = 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,
@ -36,14 +37,14 @@ class IPAndPortFormField extends FormField<IPAndPort> {
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 as _IPAndPortFormField; final _IPAndPortFormField state = field;
void onChangedHandler(IPAndPort value) { void onChangedHandler(IPAndPort value) {
if (onChanged != null) { if (onChanged != null) {
@ -67,42 +68,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 as IPAndPortFormField; IPAndPortFormField get widget => super.widget;
@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 +119,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 +134,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 +160,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,11 +174,7 @@ 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 (value == null) { if (_effectiveIPController.text != value.ip || effectivePort != value.port) {
return;
}
if (_effectiveIPController.text != value!.ip || effectivePort != value!.port) {
didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort)); didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort));
} }
} }

View File

@ -1,23 +1,24 @@
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 '../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,
@ -33,12 +34,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, signed: true) : null, keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true) : null,
textAlign: textAlign, textAlign: textAlign,
autofocus: autoFocus, autofocus: autoFocus,
focusNode: focusNode, focusNode: focusNode,
@ -46,8 +47,10 @@ class IPField extends StatelessWidget {
controller: controller, controller: controller,
onChanged: onChanged, onChanged: onChanged,
maxLength: ipOnly ? 15 : null, maxLength: ipOnly ? 15 : null,
maxLengthEnforcement: ipOnly ? MaxLengthEnforcement.enforced : MaxLengthEnforcement.none, maxLengthEnforced: ipOnly ? true : false,
inputFormatters: ipOnly ? [IPTextInputFormatter()] : [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))], inputFormatters: ipOnly
? [IPTextInputFormatter()]
: [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))],
textInputAction: this.textInputAction, textInputAction: this.textInputAction,
placeholder: help, placeholder: help,
)); ));
@ -64,28 +67,33 @@ 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() .join().replaceAll(RegExp(r','), '.');
.replaceAll(RegExp(r','), '.');
}, },
); );
} }
} }
TextEditingValue _selectionAwareTextManipulation( TextEditingValue _selectionAwareTextManipulation(
TextEditingValue value, TextEditingValue value,
String substringManipulation(String substring), String substringManipulation(String substring),
) { ) {
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(value.text.substring(0, selectionStartIndex)); final String beforeSelection = substringManipulation(
final String inSelection = substringManipulation(value.text.substring(selectionStartIndex, selectionEndIndex)); value.text.substring(0, selectionStartIndex)
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(
@ -102,6 +110,8 @@ 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 ? value.composing : TextRange.empty, composing: manipulatedText == value.text
? value.composing
: TextRange.empty,
); );
} }

View File

@ -1,4 +1,6 @@
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';
@ -7,17 +9,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, auto-validate, enabled? //TODO: validator, autovalidate, enabled?
IPFormField({ IPFormField({
Key? key, Key key,
ipOnly = false, ipOnly = false,
enableIPV6 = 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,
@ -41,7 +43,7 @@ class IPFormField extends FormField<String> {
return null; return null;
}, },
builder: (FormFieldState<String> field) { builder: (FormFieldState<String> field) {
final _IPFormField state = field as _IPFormField; final _IPFormField state = field;
void onChangedHandler(String value) { void onChangedHandler(String value) {
if (onChanged != null) { if (onChanged != null) {
@ -64,7 +66,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,
) )
@ -72,19 +74,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 as IPFormField; IPFormField get widget => super.widget;
@override @override
void initState() { void initState() {
@ -92,7 +94,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);
} }
} }
@ -104,9 +106,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;
} }
} }
@ -122,7 +124,7 @@ class _IPFormField extends FormFieldState<String> {
void reset() { void reset() {
super.reset(); super.reset();
setState(() { setState(() {
_effectiveController.text = widget.initialValue ?? ""; _effectiveController.text = widget.initialValue;
}); });
} }

View File

@ -1,34 +1,36 @@
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: auto-validate, enabled? //TODO: autovalidate, 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,
maxLengthEnforcement, maxLengthEnforced,
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 ?? ''),
@ -41,7 +43,7 @@ class PlatformTextFormField extends FormField<String> {
return null; return null;
}, },
builder: (FormFieldState<String> field) { builder: (FormFieldState<String> field) {
final _PlatformTextFormFieldState state = field as _PlatformTextFormFieldState; final _PlatformTextFormFieldState state = field;
void onChangedHandler(String value) { void onChangedHandler(String value) {
if (onChanged != null) { if (onChanged != null) {
@ -62,7 +64,7 @@ class PlatformTextFormField extends FormField<String> {
autofocus: autofocus, autofocus: autofocus,
maxLines: maxLines, maxLines: maxLines,
maxLength: maxLength, maxLength: maxLength,
maxLengthEnforcement: maxLengthEnforcement, maxLengthEnforced: maxLengthEnforced,
onChanged: onChangedHandler, onChanged: onChangedHandler,
keyboardAppearance: keyboardAppearance, keyboardAppearance: keyboardAppearance,
minLines: minLines, minLines: minLines,
@ -73,7 +75,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,
) )
@ -81,19 +83,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 as PlatformTextFormField; PlatformTextFormField get widget => super.widget;
@override @override
void initState() { void initState() {
@ -101,7 +103,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);
} }
} }
@ -113,9 +115,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;
} }
} }
@ -131,7 +133,7 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
void reset() { void reset() {
super.reset(); super.reset();
setState(() { setState(() {
_effectiveController.text = widget.initialValue ?? ""; _effectiveController.text = widget.initialValue;
}); });
} }

View File

@ -13,9 +13,9 @@ enum SimpleScrollable {
class SimplePage extends StatelessWidget { class SimplePage extends StatelessWidget {
const SimplePage( const SimplePage(
{Key? key, {Key key,
required this.title, 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,28 +24,27 @@ 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 Widget title; final String 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 Reorder-able listviews /// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorderable 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) {
@ -53,10 +52,7 @@ 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( realChild = SingleChildScrollView(scrollDirection: Axis.vertical, child: realChild, controller: refreshController == null ? scrollController : null);
scrollDirection: Axis.vertical,
child: realChild,
controller: refreshController == null ? scrollController : null);
addScrollbar = true; addScrollbar = true;
} }
@ -71,15 +67,15 @@ class SimplePage extends StatelessWidget {
footerTriggerDistance: -100, footerTriggerDistance: -100,
maxUnderScrollExtent: 100, maxUnderScrollExtent: 100,
child: SmartRefresher( child: SmartRefresher(
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,
footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading), footer: ClassicFooter(loadStyle: LoadStyle.ShowWhenLoading),
)); ));
addScrollbar = true; addScrollbar = true;
} }
@ -87,21 +83,17 @@ 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: title, title: Text(title),
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context), leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
trailingActions: trailingActions, trailingActions: trailingActions,
cupertino: (_, __) => CupertinoNavigationBarData( cupertino: (_, __) => CupertinoNavigationBarData(

View File

@ -1,12 +1,11 @@
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, required this.site, this.onPressed}) : super(key: key); const SiteItem({Key key, this.site, this.onPressed}) : super(key: key);
final Site site; final Site site;
final onPressed; final onPressed;
@ -28,7 +27,10 @@ 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));
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg'; var ip = "Error";
if (site.primaryCertInfo.cert != null && site.primaryCertInfo.cert.details.ips.length > 0) {
ip = site.primaryCertInfo.cert.details.ips[0];
}
return SpecialButton( return SpecialButton(
decoration: decoration:
@ -39,10 +41,8 @@ class SiteItem extends StatelessWidget {
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: <Widget>[ children: <Widget>[
site.managed ? Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)),
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) : Expanded(child: Text(ip, textAlign: TextAlign.end)),
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)
], ],

View File

@ -5,15 +5,14 @@ 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}) const SpecialButton({Key key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration}) : super(key: key);
: 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 GestureTapCallback? onPressed; final Function onPressed;
@override @override
_SpecialButtonState createState() => _SpecialButtonState(); _SpecialButtonState createState() => _SpecialButtonState();
@ -34,12 +33,12 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
return Material( return Material(
textStyle: textStyle, textStyle: textStyle,
child: Ink( child: Ink(
decoration: widget.decoration, decoration: widget.decoration,
color: widget.color, color: widget.color,
child: InkWell( child: InkWell(
child: widget.child, child: widget.child,
onTap: widget.onPressed, onTap: widget.onPressed,
))); )));
} }
Widget _buildGeneric() { Widget _buildGeneric() {
@ -49,21 +48,22 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
} }
return Container( return Container(
decoration: widget.decoration, decoration: widget.decoration,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTapDown: _handleTapDown, onTapDown: _handleTapDown,
onTapUp: _handleTapUp, onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel, onTapCancel: _handleTapCancel,
onTap: widget.onPressed, onTap: widget.onPressed,
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,7 +98,8 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
@override @override
void dispose() { void dispose() {
_animationController?.dispose(); _animationController.dispose();
_animationController = null;
super.dispose(); super.dispose();
} }
@ -126,14 +127,14 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
} }
void _animate() { void _animate() {
if (_animationController == null || _animationController!.isAnimating) { if (_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) {

View File

@ -0,0 +1,684 @@
// 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';
//TODO: please let us delete this file
/// 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: TextSelectionTheme.of(context).selectionColor,
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;
}
}
}
}

View File

@ -1,11 +1,14 @@
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 looks the same on all platforms //TODO: please let us delete this file
/// A normal TextField or CupertinoTextField that watches for copy, paste, cut, or select all keyboard actions
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,
@ -15,7 +18,7 @@ class SpecialTextField extends StatefulWidget {
this.minLines, this.minLines,
this.maxLines, this.maxLines,
this.maxLength, this.maxLength,
this.maxLengthEnforcement, this.maxLengthEnforced,
this.style, this.style,
this.keyboardType, this.keyboardType,
this.textInputAction, this.textInputAction,
@ -30,44 +33,50 @@ 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 MaxLengthEnforcement? maxLengthEnforcement; final bool maxLengthEnforced;
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> {
List<TextInputFormatter> formatters = []; FocusNode _focusNode = FocusNode();
List<TextInputFormatter> formatters;
@override
void dispose() {
_focusNode.dispose();
super.dispose();
}
@override @override
void initState() { void initState() {
if (widget.inputFormatters == null || formatters.length == 0) { formatters = widget.inputFormatters;
if (formatters == null || formatters.length == 0) {
formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))]; formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))];
} else {
formatters = widget.inputFormatters!;
} }
super.initState(); super.initState();
@ -75,43 +84,121 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PlatformTextField( return RawKeyboardListener(
autocorrect: widget.autocorrect, focusNode: _focusNode,
minLines: widget.minLines, onKey: _onKey,
maxLines: widget.maxLines, child: PlatformTextField(
maxLength: widget.maxLength, autocorrect: widget.autocorrect,
maxLengthEnforcement: widget.maxLengthEnforcement, minLines: widget.minLines,
keyboardType: widget.keyboardType, maxLines: widget.maxLines,
keyboardAppearance: widget.keyboardAppearance, maxLength: widget.maxLength,
textInputAction: widget.textInputAction, maxLengthEnforced: widget.maxLengthEnforced,
textCapitalization: widget.textCapitalization, keyboardType: widget.keyboardType,
textAlign: widget.textAlign, keyboardAppearance: widget.keyboardAppearance,
textAlignVertical: widget.textAlignVertical, textInputAction: widget.textInputAction,
autofocus: widget.autofocus, textCapitalization: widget.textCapitalization,
focusNode: widget.focusNode, textAlign: widget.textAlign,
onChanged: widget.onChanged, textAlignVertical: widget.textAlignVertical,
enabled: widget.enabled, autofocus: widget.autofocus,
onSubmitted: (_) { focusNode: widget.focusNode,
if (widget.nextFocusNode != null) { onChanged: widget.onChanged,
FocusScope.of(context).requestFocus(widget.nextFocusNode); enabled: widget.enabled,
onSubmitted: (_) {
if (widget.nextFocusNode != null) {
FocusScope.of(context).requestFocus(widget.nextFocusNode);
}
},
expands: widget.expands,
inputFormatters: formatters,
material: (_, __) => MaterialTextFieldData(
decoration: InputDecoration(
border: InputBorder.none,
contentPadding: EdgeInsets.zero,
isDense: true,
hintText: widget.placeholder,
counterText: '',
suffix: widget.suffix)),
cupertino: (_, __) => CupertinoTextFieldData(
decoration: BoxDecoration(),
padding: EdgeInsets.zero,
placeholder: widget.placeholder,
suffix: widget.suffix),
style: widget.style,
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);
}
} }
},
expands: widget.expands, // If maxLength took us to 0 then bail
inputFormatters: formatters, if (text.length == 0) {
material: (_, __) => MaterialTextFieldData( return;
decoration: InputDecoration( }
border: InputBorder.none,
contentPadding: EdgeInsets.zero, var end = widget.controller.selection.end;
isDense: true, var start = widget.controller.selection.start;
hintText: widget.placeholder,
counterText: '', // Insert our paste buffer into the selection, which can be 0 selected text (normal caret)
suffix: widget.suffix)), widget.controller.text = widget.controller.selection.textBefore(widget.controller.text) +
cupertino: (_, __) => CupertinoTextFieldData( text +
decoration: BoxDecoration(), widget.controller.selection.textAfter(widget.controller.text);
padding: EdgeInsets.zero,
placeholder: widget.placeholder, // Adjust our caret to be at the end of the pasted contents, need to take into account the size of the selection
suffix: widget.suffix), // We may want runes instead of
style: widget.style, end += text.length - (end - start);
controller: widget.controller); 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;
}
}
} }
} }

View File

@ -5,9 +5,9 @@ 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

View File

@ -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( const ConfigCheckboxItem({Key key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked})
{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();
} }
}, },
); );

View File

@ -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.subtitle1!; 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, required this.label, this.color}) : super(key: key); const ConfigHeader({Key key, 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) {

View File

@ -4,14 +4,10 @@ import 'package:mobile_nebula/services/utils.dart';
class ConfigItem extends StatelessWidget { class ConfigItem extends StatelessWidget {
const ConfigItem( const ConfigItem(
{Key? key, {Key key, this.label, this.content, this.labelWidth = 100, this.crossAxisAlignment = CrossAxisAlignment.center})
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;

View File

@ -7,21 +7,19 @@ 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) {
@ -30,8 +28,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: origTheme.textTheme textTheme:
.copyWith(button: origTheme.textTheme.button!.copyWith(fontWeight: FontWeight.normal))); origTheme.textTheme.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);
@ -42,7 +40,7 @@ class ConfigPageItem extends StatelessWidget {
Widget _buildContent(BuildContext context) { Widget _buildContent(BuildContext context) {
return SpecialButton( return SpecialButton(
onPressed: this.disabled ? null : onPressed, onPressed: onPressed,
color: Utils.configItemBackground(context), color: Utils.configItemBackground(context),
child: Container( child: Container(
padding: EdgeInsets.only(left: 15), padding: EdgeInsets.only(left: 15),
@ -52,7 +50,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))),
this.disabled ? Container() : Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18) Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
], ],
)), )),
); );

View File

@ -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, required this.children, this.borderColor, this.labelColor}) const ConfigSection({Key key, this.label, this.children, this.borderColor, this.labelColor}) : super(key: key);
: 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)),

View File

@ -5,13 +5,10 @@ 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( const ConfigTextItem({Key key, this.placeholder, this.controller}) : super(key: key);
{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) {
@ -22,7 +19,7 @@ class ConfigTextItem extends StatelessWidget {
minLines: 3, minLines: 3,
maxLines: 10, maxLines: 10,
placeholder: placeholder, placeholder: placeholder,
style: style, style: TextStyle(fontFamily: 'RobotoMono'),
controller: controller)); controller: controller));
} }
} }

View File

@ -1,5 +1,3 @@
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;
@ -8,16 +6,11 @@ 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 streamcontroller we are using now
void main() { void main() => runApp(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.
@ -33,7 +26,6 @@ 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() {
@ -49,12 +41,6 @@ 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(
@ -107,25 +93,7 @@ class _AppState extends State<App> {
cupertino: (_, __) => CupertinoAppData( cupertino: (_, __) => CupertinoAppData(
theme: CupertinoThemeData(brightness: brightness), theme: CupertinoThemeData(brightness: brightness),
), ),
onGenerateRoute: (settings) { home: MainScreen(),
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;
},
), ),
), ),
); );

View File

@ -1,5 +1,5 @@
class CIDR { class CIDR {
CIDR({this.ip = '', this.bits = 0}); CIDR({this.ip, this.bits});
String ip; String ip;
int bits; int bits;
@ -13,15 +13,13 @@ class CIDR {
return toString(); return toString();
} }
factory CIDR.fromString(String val) { 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';
} }
return CIDR( ip = parts[0];
ip: parts[0], bits = int.parse(parts[1]);
bits: int.parse(parts[1]),
);
} }
} }

View File

@ -1,7 +1,11 @@
class CertificateInfo { class CertificateInfo {
Certificate cert; Certificate cert;
String? rawCert; String rawCert;
CertificateValidity? validity; CertificateValidity validity;
bool primary;
// Key is only present when a new certificate is being installed, provided to the backend by the UI
String key;
CertificateInfo.debug({this.rawCert = ""}) CertificateInfo.debug({this.rawCert = ""})
: this.cert = Certificate.debug(), : this.cert = Certificate.debug(),
@ -10,12 +14,22 @@ class CertificateInfo {
CertificateInfo.fromJson(Map<String, dynamic> json) CertificateInfo.fromJson(Map<String, dynamic> json)
: cert = Certificate.fromJson(json['Cert']), : cert = Certificate.fromJson(json['Cert']),
rawCert = json['RawCert'], rawCert = json['RawCert'],
primary = json['primary'],
validity = CertificateValidity.fromJson(json['Validity']); validity = CertificateValidity.fromJson(json['Validity']);
CertificateInfo({required this.cert, this.rawCert, this.validity}); CertificateInfo({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)).toList(); return list.map((v) => CertificateInfo.fromJson(v));
}
Map<String, dynamic> toJson() {
return {
'cert': rawCert,
'key': key,
'primary': primary,
'fingerprint': cert.fingerprint
};
} }
} }
@ -59,8 +73,8 @@ class CertificateDetails {
CertificateDetails.fromJson(Map<String, dynamic> json) CertificateDetails.fromJson(Map<String, dynamic> json)
: name = json['name'], : name = json['name'],
notBefore = DateTime.parse(json['notBefore']), notBefore = DateTime.tryParse(json['notBefore']),
notAfter = DateTime.parse(json['notAfter']), notAfter = DateTime.tryParse(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']),
@ -80,4 +94,4 @@ class CertificateValidity {
CertificateValidity.fromJson(Map<String, dynamic> json) CertificateValidity.fromJson(Map<String, dynamic> json)
: valid = json['Valid'], : valid = json['Valid'],
reason = json['Reason']; reason = json['Reason'];
} }

View File

@ -6,48 +6,31 @@ 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({ HostInfo.fromJson(Map<String, dynamic> json) {
required this.vpnIp, vpnIp = json['vpnIp'];
required this.localIndex, localIndex = json['localIndex'];
required this.remoteIndex, remoteIndex = json['remoteIndex'];
required this.remoteAddresses, cachedPackets = json['cachedPackets'];
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'];
List<UDPAddress> remoteAddresses = []; remoteAddresses = [];
addrs?.forEach((val) { addrs?.forEach((val) {
remoteAddresses.add(UDPAddress.fromJson(val)); remoteAddresses.add(UDPAddress.fromJson(val));
}); });
return HostInfo( messageCounter = json['messageCounter'];
vpnIp: json['vpnIp'],
localIndex: json['localIndex'],
remoteIndex: json['remoteIndex'],
remoteAddresses: remoteAddresses,
cachedPackets: json['cachedPackets'],
messageCounter: json['messageCounter'],
cert: cert,
currentRemote: currentRemote,
);
} }
} }

View File

@ -5,5 +5,5 @@ class Hostmap {
List<IPAndPort> destinations; List<IPAndPort> destinations;
bool lighthouse; bool lighthouse;
Hostmap({required this.nebulaIp, required this.destinations, required this.lighthouse}); Hostmap({this.nebulaIp, this.destinations, this.lighthouse});
} }

View File

@ -1,12 +1,12 @@
class IPAndPort { class IPAndPort {
String? ip; String ip;
int? port; int port;
IPAndPort({this.ip, this.port}); IPAndPort({this.ip, this.port});
@override @override
String toString() { String toString() {
if (ip != null && ip!.contains(':')) { if (ip.contains(':')) {
return '[$ip]:$port'; return '[$ip]:$port';
} }
@ -17,13 +17,10 @@ class IPAndPort {
return toString(); return toString();
} }
factory IPAndPort.fromString(String val) { IPAndPort.fromString(String val) {
//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 //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
final uri = Uri.parse("ugh://$val"); final uri = Uri.parse("ugh://$val");
this.ip = uri.host;
return IPAndPort( this.port = uri.port;
ip: uri.host,
port: uri.port,
);
} }
} }

View File

@ -4,7 +4,6 @@ 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';
@ -13,217 +12,133 @@ var uuid = Uuid();
class Site { class Site {
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
late EventChannel _updates; 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
late String name; String name;
late String id; String id;
// static_host_map // static_host_map
late Map<String, StaticHost> staticHostmap; Map<String, StaticHost> staticHostmap;
late List<UnsafeRoute> unsafeRoutes; List<UnsafeRoute> unsafeRoutes;
late List<String> dnsResolvers;
// pki fields // pki fields
late List<CertificateInfo> ca; List<CertificateInfo> caInfos;
String? key; List<CertificateInfo> certInfos;
late CertificateInfo? certInfo; CertificateInfo primaryCertInfo;
// lighthouse options // lighthouse options
late int lhDuration; // in seconds int lhDuration; // in seconds
// listen settings // listen settings
late int port; int port;
late int mtu; int mtu;
late String cipher; String cipher;
late int sortKey; int sortKey;
late bool connected; bool connected;
late String status; String status;
late String logFile; String logFile;
late String logVerbosity; String logVerbosity;
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
late List<String> errors; List<String> errors;
Site({ Site(
String name = '', {this.name,
String? id, id,
Map<String, StaticHost>? staticHostmap, staticHostmap,
List<CertificateInfo>? ca, caInfos,
CertificateInfo? certInfo, certInfos,
int lhDuration = 0, this.lhDuration = 0,
int port = 0, this.port = 0,
String cipher = "aes", this.cipher = "aes",
int sortKey = 0, this.sortKey,
int mtu = 1300, this.mtu = 1300,
bool connected = false, this.connected,
String status = '', this.status,
String logFile = '', this.logFile,
String logVerbosity = 'info', this.logVerbosity = 'info',
List<String>? errors, errors,
List<UnsafeRoute>? unsafeRoutes, unsafeRoutes})
List<String>? dnsResolvers, : staticHostmap = staticHostmap ?? {},
bool managed = false, unsafeRoutes = unsafeRoutes ?? [],
String? rawConfig, errors = errors ?? [],
DateTime? lastManagedUpdate, caInfos = caInfos ?? [],
}) { certInfos = certInfos ?? [],
this.name = name; id = id ?? uuid.v4();
this.id = id ?? uuid.v4();
this.staticHostmap = staticHostmap ?? {}; Site.fromJson(Map<String, dynamic> json) {
this.ca = ca ?? []; name = json['name'];
this.certInfo = certInfo; id = json['id'];
this.lhDuration = lhDuration;
this.port = port; Map<String, dynamic> rawHostmap = json['staticHostmap'];
this.cipher = cipher; staticHostmap = {};
this.sortKey = sortKey; rawHostmap.forEach((key, val) {
this.mtu = mtu; staticHostmap[key] = StaticHost.fromJson(val);
this.connected = connected; });
this.status = status;
this.logFile = logFile; List<dynamic> rawUnsafeRoutes = json['unsafeRoutes'];
this.logVerbosity = logVerbosity; unsafeRoutes = [];
this.errors = errors ?? []; if (rawUnsafeRoutes != null) {
this.unsafeRoutes = unsafeRoutes ?? []; rawUnsafeRoutes.forEach((val) {
this.dnsResolvers = dnsResolvers ?? []; unsafeRoutes.add(UnsafeRoute.fromJson(val));
this.managed = managed; });
this.rawConfig = rawConfig; }
this.lastManagedUpdate = lastManagedUpdate;
List<dynamic> rawCA = json['caInfos'];
caInfos = [];
rawCA.forEach((val) {
caInfos.add(CertificateInfo.fromJson(val));
});
List<dynamic> rawCerts = json['certInfos'];
certInfos = [];
rawCerts.forEach((val) {
final certInfo = CertificateInfo.fromJson(val);
if (certInfo.primary) {
primaryCertInfo = certInfo;
}
certInfos.add(certInfo);
});
lhDuration = json['lhDuration'];
port = json['port'];
mtu = json['mtu'];
cipher = json['cipher'];
sortKey = json['sortKey'];
logFile = json['logFile'];
logVerbosity = json['logVerbosity'];
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 {
_updateFromJson(d); this.status = d['status'];
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;
_change.addError(error.message ?? 'An unexpected error occurred'); this.status = error.details['status'];
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;
} }
@ -234,20 +149,17 @@ class Site {
'id': id, 'id': id,
'staticHostmap': staticHostmap, 'staticHostmap': staticHostmap,
'unsafeRoutes': unsafeRoutes, 'unsafeRoutes': unsafeRoutes,
'dnsResolvers': dnsResolvers, 'ca': caInfos?.map((cert) {
'ca': ca.map((cert) { return cert.rawCert;
return cert.rawCert; })?.join('\n') ??
}).join('\n'), "",
'cert': certInfo?.rawCert, 'certs': certInfos,
'key': key,
'lhDuration': lhDuration, 'lhDuration': lhDuration,
'port': port, 'port': port,
'mtu': mtu, 'mtu': mtu,
'cipher': cipher, 'cipher': cipher,
'sortKey': sortKey, 'sortKey': sortKey,
'logVerbosity': logVerbosity, 'logVerbosity': logVerbosity,
'managed': managed,
'rawConfig': rawConfig,
}; };
} }
@ -299,7 +211,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 || ret == "null") { if (ret == null) {
return []; return [];
} }
@ -310,6 +222,7 @@ 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();
@ -321,7 +234,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 || ret == "null") { if (ret == null) {
return []; return [];
} }
@ -332,6 +245,7 @@ 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) {
@ -343,6 +257,7 @@ 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) {
@ -354,16 +269,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 var ret = await platform.invokeMethod("active.getHostInfo", <String, dynamic>{"id": id, "vpnIp": vpnIp, "pending": pending});
.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) {
@ -371,16 +286,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 var ret = await platform.invokeMethod("active.setRemoteForTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp, "addr": addr});
.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) {
@ -391,6 +306,7 @@ 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) {

View File

@ -4,9 +4,11 @@ class StaticHost {
bool lighthouse; bool lighthouse;
List<IPAndPort> destinations; List<IPAndPort> destinations;
StaticHost({required this.lighthouse, required this.destinations}); StaticHost({this.lighthouse, 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 = <IPAndPort>[]; var result = <IPAndPort>[];
@ -14,10 +16,7 @@ class StaticHost {
result.add(IPAndPort.fromString(item)); result.add(IPAndPort.fromString(item));
}); });
return StaticHost( destinations = result;
lighthouse: json['lighthouse'],
destinations: result,
);
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {

View File

@ -1,14 +1,12 @@
class UnsafeRoute { class UnsafeRoute {
String? route; String route;
String? via; String via;
UnsafeRoute({this.route, this.via}); UnsafeRoute({this.route, this.via});
factory UnsafeRoute.fromJson(Map<String, dynamic> json) { UnsafeRoute.fromJson(Map<String, dynamic> json) {
return UnsafeRoute( route = json['route'];
route: json['route'], via = json['via'];
via: json['via'],
);
} }
Map<String, dynamic> toJson() { Map<String, dynamic> toJson() {
@ -17,4 +15,4 @@ class UnsafeRoute {
'via': via, 'via': via,
}; };
} }
} }

View File

@ -1,6 +1,9 @@
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';
@ -9,7 +12,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();
@ -17,7 +20,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() {
@ -33,7 +36,6 @@ 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(cupertino: (_, __) { child: PlatformCircularProgressIndicator(cupertino: (_, __) {
@ -43,44 +45,26 @@ class _AboutScreenState extends State<AboutScreen> {
} }
return SimplePage( return SimplePage(
title: Text('About'), title: 'About',
child: Column(children: [ child: Column(children: [
ConfigSection(children: <Widget>[ ConfigSection(children: <Widget>[
ConfigItem( ConfigItem(label: Text('App version'), labelWidth: 150, content: _buildText('${packageInfo.version}-${packageInfo.buildNumber} (sha: $gitSha)')),
label: Text('App version'), ConfigItem(label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')),
labelWidth: 150, ConfigItem(label: Text('Flutter version'), labelWidth: 150, content: _buildText(flutterVersion['frameworkVersion'])),
content: _buildText('${packageInfo!.version}-${packageInfo!.buildNumber} (sha: $gitSha)')), ConfigItem(label: Text('Dart version'), labelWidth: 150, content: _buildText(flutterVersion['dartSdkVersion'])),
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( ConfigPageItem(label: Text('Privacy policy'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/privacy-policy', context)),
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(padding: EdgeInsets.only(top: 20), child: Text('Copyright © 2020 Defined Networking, Inc', textAlign: TextAlign.center,)),
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: SelectableText(str)); return Align(alignment: AlignmentDirectional.centerEnd, child: SpecialSelectableText(str));
} }
} }

View File

@ -1,186 +0,0 @@
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();
});
},
)
]);
}
}

View File

@ -1,7 +1,9 @@
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';
@ -14,24 +16,15 @@ 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({ const HostInfoScreen({Key key, this.hostInfo, this.isLighthouse, this.pending, this.onChanged, this.site})
Key? key, : super(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();
} }
@ -39,7 +32,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> {
late HostInfo hostInfo; HostInfo hostInfo;
RefreshController refreshController = RefreshController(initialRefresh: false); RefreshController refreshController = RefreshController(initialRefresh: false);
@override @override
@ -53,7 +46,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
final title = widget.pending ? 'Pending' : 'Active'; final title = widget.pending ? 'Pending' : 'Active';
return SimplePage( return SimplePage(
title: Text('$title Host Info'), title: '$title Host Info',
refreshController: refreshController, refreshController: refreshController,
onRefresh: () async { onRefresh: () async {
await _getHostInfo(); await _getHostInfo();
@ -68,17 +61,14 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
Widget _buildMain() { Widget _buildMain() {
return ConfigSection(children: [ return ConfigSection(children: [
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)), ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SpecialSelectableText(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( context, (context) => CertificateDetailsScreen(CertificateInfo(cert: hostInfo.cert))))
certInfo: CertificateInfo(cert: hostInfo.cert!),
supportsQRScanning: widget.supportsQRScanning,
)))
: Container(), : Container(),
]); ]);
} }
@ -86,12 +76,18 @@ 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: SelectableText(widget.isLighthouse ? 'Yes' : 'No')), label: Text('Lighthouse'),
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')), labelWidth: 150,
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')), content: SpecialSelectableText(widget.isLighthouse ? 'Yes' : 'No')),
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.localIndex}')),
ConfigItem( ConfigItem(
label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')), label: Text('Remote Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.remoteIndex}')),
ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SelectableText('${hostInfo.cachedPackets}')), ConfigItem(
label: Text('Message Counter'),
labelWidth: 150,
content: SpecialSelectableText('${hostInfo.messageCounter}')),
ConfigItem(
label: Text('Cached Packets'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.cachedPackets}')),
]); ]);
} }
@ -128,7 +124,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
_setHostInfo(h); _setHostInfo(h);
} }
} catch (err) { } catch (err) {
Utils.popError(context, 'Error while changing the remote', err.toString()); Utils.popError(context, 'Error while changing the remote', err);
} }
}, },
)); ));
@ -161,20 +157,20 @@ 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: PlatformElevatedButton( child: PlatformButton(
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.toString()); Utils.popError(context, 'Error while trying to close the tunnel', err);
} }
}, deleteLabel: 'Close')))); }, deleteLabel: 'Close'))));
} }
_getHostInfo() async { _getHostInfo() async {
@ -186,7 +182,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
_setHostInfo(h); _setHostInfo(h);
} catch (err) { } catch (err) {
Utils.popError(context, 'Failed to refresh host info', err.toString()); Utils.popError(context, 'Failed to refresh host info', err);
} }
} }

View File

@ -1,11 +1,12 @@
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';
@ -14,167 +15,71 @@ 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';
/// Contains an expired CA and certificate //TODO: add refresh
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(this.dnEnrollStream, {Key? key}) : super(key: key); const MainScreen({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> {
List<Site>? sites; bool ready = false;
// A set of widgets to display in a column that represents an error blocking us from moving forward entirely List<Site> sites;
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: Text('Nebula'), title: 'Nebula',
scrollable: SimpleScrollable.vertical, scrollable: SimpleScrollable.none,
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(widget.dnEnrollStream)), onPressed: () => Utils.openPage(context, (_) => SettingsScreen()),
), ),
], ],
bottomBar: debugSite, bottomBar: kDebugMode ? _debugSave() : null,
child: _buildBody(), child: _buildBody(),
); );
} }
Widget _buildBody() { Widget _buildBody() {
if (error != null) { if (!ready) {
return Center( return Center(
child: Padding( child: PlatformCircularProgressIndicator(cupertino: (_, __) {
child: Column( return CupertinoProgressIndicatorData(radius: 500);
mainAxisAlignment: MainAxisAlignment.center, }),
crossAxisAlignment: CrossAxisAlignment.center, );
children: error!, }
),
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10))); if (sites == null || sites.length == 0) {
return _buildNoSites();
} }
return _buildSites(); return _buildSites();
@ -182,45 +87,34 @@ 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: Center(child: Column(
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), child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
),
Text('You don\'t have any site configurations installed yet. Hit the plus button above to get started.',
textAlign: TextAlign.center),
],
), ),
)); Text('You don\'t have any site configurations installed yet. Hit the plus button above to get started.',
textAlign: TextAlign.center),
],
),
));
} }
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( return SiteDetailScreen(site: site, onChanged: () => _loadSites());
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 {
@ -230,21 +124,17 @@ 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 = 0; i < sites!.length; i++) { for (var i = min(oldI, newI); i <= max(oldI, newI); i++) {
if (sites![i].sortKey == i) { 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');
} }
} }
@ -256,28 +146,48 @@ 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(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child); return Theme(
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
child: child
);
} }
Widget _debugSave(Map<String, String> siteConfig) { Widget _debugSave() {
return CupertinoButton( return CupertinoButton(
child: Text(siteConfig['name']!), key: Key('debug-save'),
child: Text("DEBUG SAVE"),
onPressed: () async { onPressed: () async {
var uuid = Uuid(); var uuid = Uuid();
var s = Site( var cert = '''-----BEGIN NEBULA CERTIFICATE-----
name: siteConfig['name']!, CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa
id: uuid.v4(), A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp
staticHostmap: { VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU
"10.1.0.1": StaticHost( Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ==
lighthouse: true, -----END NEBULA CERTIFICATE-----''';
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
},
ca: [CertificateInfo.debug(rawCert: siteConfig['ca'])],
certInfo: CertificateInfo.debug(rawCert: siteConfig['cert']),
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]);
s.key = siteConfig['key']; var ca = '''-----BEGIN NEBULA CERTIFICATE-----
CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
-----END NEBULA CERTIFICATE-----''';
var certInfo = CertificateInfo.debug(rawCert: cert);
certInfo.primary = true;
certInfo.key = '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
-----END NEBULA X25519 PRIVATE KEY-----''';
var s = Site(
name: "DEBUG TEST",
id: uuid.v4(),
staticHostmap: {
"10.1.0.1": StaticHost(lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
},
caInfos: [CertificateInfo.debug(rawCert: ca)],
certInfos: [certInfo],
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]
);
var err = await s.save(); var err = await s.save();
if (err != null) { if (err != null) {
@ -289,17 +199,11 @@ class _MainScreenState extends State<MainScreen> {
); );
} }
Widget _debugClearKeys() {
return CupertinoButton(
child: Text("Clear Keys"),
onPressed: () async {
await platform.invokeMethod("debug.clearKeys", null);
},
);
}
_loadSites() async { _loadSites() async {
if (Platform.isAndroid) {
await platform.invokeMethod("android.requestPermissions");
}
//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;
@ -317,13 +221,12 @@ class _MainScreenState extends State<MainScreen> {
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 site config: $rawSite"); print("$err site config: $rawSite");
@ -332,19 +235,16 @@ class _MainScreenState extends State<MainScreen> {
} }
}); });
if (Platform.isAndroid) { if (hasErrors) {
// Android suffers from a race to discover the active site and attach site specific event listeners Utils.popError(context, "Site Error(s)", "1 or more sites have errors and need your attention, problem sites have a red border.");
platform.invokeMethod("android.registerActiveSite");
} }
sites!.sort((a, b) { sites.sort((a, b) {
if (a.sortKey == b.sortKey) {
return a.name.compareTo(b.name);
}
return a.sortKey - b.sortKey; return a.sortKey - b.sortKey;
}); });
setState(() {}); setState(() {
ready = true;
});
} }
} }

View File

@ -1,24 +1,20 @@
import 'dart:async'; import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.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/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(); _SettingsScreenState createState() {
return _SettingsScreenState();
}
} }
class _SettingsScreenState extends State<SettingsScreen> { class _SettingsScreenState extends State<SettingsScreen> {
@ -85,16 +81,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
}, },
)), )),
)); ));
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: [ items.add(ConfigSection(children: [
ConfigPageItem( ConfigPageItem(
label: Text('About'), label: Text('About'),
@ -103,7 +89,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
])); ]));
return SimplePage( return SimplePage(
title: Text('Settings'), title: 'Settings',
child: Column(children: items), child: Column(children: items),
); );
} }

View File

@ -3,9 +3,10 @@ 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';
@ -21,48 +22,47 @@ 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({ const SiteDetailScreen({Key key, this.site, this.onChanged}) : super(key: key);
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> {
late Site site; Site site;
late StreamSubscription onChange; 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((_) {
// 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') {
_listHostmap(); lastState = true;
} else { _listHostmap();
activeHosts = null; } else {
pendingHosts = null; lastState = false;
activeHosts = null;
pendingHosts = null;
}
} }
setState(() {}); setState(() {});
}, onError: (err) { }, onError: (err) {
setState(() {}); setState(() {});
Utils.popError(context, "Error", err); Utils.popError(context, "Error", err);
@ -79,19 +79,11 @@ 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: title, title: site.name,
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);
}), }),
@ -119,7 +111,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: SelectableText(error)))); labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SpecialSelectableText(error))));
}); });
return ConfigSection( return ConfigSection(
@ -173,13 +165,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(
@ -187,41 +179,33 @@ 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))
@ -240,10 +224,7 @@ 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,
);
}); });
}, },
), ),
@ -255,7 +236,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: PlatformElevatedButton( child: PlatformButton(
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 {
@ -272,7 +253,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.toString()); Utils.popError(context, 'Error while fetching hostmaps', err);
} }
} }
@ -289,7 +270,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
} }
if (widget.onChanged != null) { if (widget.onChanged != null) {
widget.onChanged!(); widget.onChanged();
} }
return true; return true;
} }

View File

@ -2,9 +2,10 @@ 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/settings.dart';
import 'package:mobile_nebula/services/share.dart'; import 'package:mobile_nebula/services/share.dart';
@ -12,7 +13,7 @@ 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, required this.site}) : super(key: key); const SiteLogsScreen({Key key, this.site}) : super(key: key);
final Site site; final Site site;
@ -40,16 +41,8 @@ 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: title, title: widget.site.name,
scrollable: SimpleScrollable.both, scrollable: SimpleScrollable.both,
scrollController: controller, scrollController: controller,
onRefresh: () async { onRefresh: () async {
@ -61,10 +54,7 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
refreshController.loadComplete(); refreshController.loadComplete();
}, },
refreshController: refreshController, refreshController: refreshController,
child: Container( child: Container(padding: EdgeInsets.all(5), constraints: logBoxConstraints(context), child: SpecialSelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
padding: EdgeInsets.all(5),
constraints: logBoxConstraints(context),
child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
bottomBar: _buildBottomBar(), bottomBar: _buildBottomBar(),
); );
} }
@ -84,28 +74,28 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
), ),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[ child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
Expanded( Expanded(
child: Builder( child: PlatformIconButton(
builder: (BuildContext context) { padding: padding,
return PlatformIconButton( icon: Icon(context.platformIcons.share, size: 30),
padding: padding, onPressed: () {
icon: Icon(context.platformIcons.share, size: 30), Share.shareFile(title: '${widget.site.name} logs', filePath: widget.site.logFile, filename: '${widget.site.name}.log');
onPressed: () { },
Share.shareFile(context,
title: '${widget.site.name} logs',
filePath: widget.site.logFile,
filename: '${widget.site.name}.log');
},
);
}
)), )),
Expanded( Expanded(
child: PlatformIconButton( child: PlatformIconButton(
padding: padding, padding: padding,
icon: Icon(context.platformIcons.downArrow, size: 30), icon: Icon(context.platformIcons.delete, size: Platform.isIOS ? 38 : 30),
onPressed: () async { onPressed: () {
controller.animateTo(controller.position.maxScrollExtent, Utils.confirmDelete(context, 'Are you sure you want to clear all logs?', () => deleteLogs());
duration: const Duration(milliseconds: 500), curve: Curves.linearToEaseOut); },
}, )),
Expanded(
child: PlatformIconButton(
padding: padding,
icon: Icon(context.platformIcons.downArrow, size: 30),
onPressed: () async {
controller.animateTo(controller.position.maxScrollExtent, duration: const Duration(milliseconds: 500), curve: Curves.linearToEaseOut);
},
)), )),
])); ]));
} }
@ -118,8 +108,6 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
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());
} }

View File

@ -1,5 +1,6 @@
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';
@ -10,43 +11,32 @@ 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({ const SiteTunnelsScreen({Key key, this.site, this.tunnels, this.pending, this.onChanged}) : super(key: key);
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> {
late Site site; Site site;
late List<HostInfo> tunnels; 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();
} }
@ -75,13 +65,10 @@ 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 ?? "")),
)); ));
}); });
@ -95,7 +82,7 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
final title = widget.pending ? 'Pending' : 'Active'; final title = widget.pending ? 'Pending' : 'Active';
return SimplePage( return SimplePage(
title: Text('$title Tunnels'), title: "$title Tunnels",
leadingAction: Utils.leadingBackWidget(context, onPressed: () { leadingAction: Utils.leadingBackWidget(context, onPressed: () {
Navigator.pop(context); Navigator.pop(context);
}), }),
@ -135,11 +122,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.toString()); Utils.popError(context, 'Error while fetching hostmap', err);
} }
} }
} }

View File

@ -1,11 +1,13 @@
import 'dart:convert'; import 'dart:convert';
import 'package:barcode_scan/barcode_scan.dart';
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_barcode_scanner/flutter_barcode_scanner.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/ConfigButtonItem.dart'; import 'package:mobile_nebula/components/config/ConfigButtonItem.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';
@ -16,71 +18,71 @@ import 'package:mobile_nebula/services/utils.dart';
import 'CertificateDetailsScreen.dart'; import 'CertificateDetailsScreen.dart';
class CertificateResult {
CertificateInfo certInfo;
String key;
CertificateResult({required this.certInfo, required this.key});
}
class AddCertificateScreen extends StatefulWidget { class AddCertificateScreen extends StatefulWidget {
const AddCertificateScreen({ const AddCertificateScreen({Key key, this.onSave, this.choosePrimary = false}) : super(key: key);
Key? key,
this.onSave,
this.onReplace,
required this.pubKey,
required this.privKey,
required this.supportsQRScanning,
}) : super(key: key);
// onSave will pop a new CertificateDetailsScreen. final ValueChanged<CertificateInfo> onSave;
// If onSave is null, onReplace must be set. final choosePrimary;
final ValueChanged<CertificateResult>? onSave;
// onReplace will return the CertificateResult, assuming the previous screen is a CertificateDetailsScreen.
// If onReplace is null, onSave must be set.
final ValueChanged<CertificateResult>? onReplace;
final String pubKey;
final String privKey;
final bool supportsQRScanning;
@override @override
_AddCertificateScreenState createState() => _AddCertificateScreenState(); _AddCertificateScreenState createState() => _AddCertificateScreenState();
} }
class _AddCertificateScreenState extends State<AddCertificateScreen> { class _AddCertificateScreenState extends State<AddCertificateScreen> {
late String pubKey; String pubKey;
bool showKey = false; String privKey;
CertificateInfo certInfo;
String inputType = 'paste'; String inputType = 'paste';
final keyController = TextEditingController();
final pasteController = TextEditingController(); final pasteController = TextEditingController();
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService'); static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
@override @override
void initState() { void initState() {
pubKey = widget.pubKey; _generateKeys();
keyController.text = widget.privKey;
super.initState(); super.initState();
} }
@override @override
void dispose() { void dispose() {
pasteController.dispose(); pasteController.dispose();
keyController.dispose();
super.dispose(); super.dispose();
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (pubKey == null) {
return Center(
child: PlatformCircularProgressIndicator(cupertino: (_, __) {
return CupertinoProgressIndicatorData(radius: 500);
}),
);
}
List<Widget> items = []; List<Widget> items = [];
items.addAll(_buildShare()); items.addAll(_buildShare());
items.add(_buildKey());
items.addAll(_buildLoadCert()); items.addAll(_buildLoadCert());
return SimplePage(title: Text('Certificate'), child: Column(children: items)); return SimplePage(
title: 'Certificate',
child: Column(children: items));
}
_generateKeys() async {
try {
var kp = await platform.invokeMethod("nebula.generateKeyPair");
Map<String, dynamic> keyPair = jsonDecode(kp);
setState(() {
pubKey = keyPair['PublicKey'];
privKey = keyPair['PrivateKey'];
});
} on PlatformException catch (err) {
Utils.popError(context, 'Failed to generate key pair', err.details ?? err.message);
}
} }
List<Widget> _buildShare() { List<Widget> _buildShare() {
@ -90,49 +92,34 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
children: [ children: [
ConfigItem( ConfigItem(
labelWidth: 0, labelWidth: 0,
content: SelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)), content: SpecialSelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
), ),
Builder( ConfigButtonItem(
builder: (BuildContext context) { content: Text('Share Public Key'),
return ConfigButtonItem( onPressed: () async {
content: Text('Share Public Key'), await Share.share(title: 'Please sign and return a certificate', text: pubKey, filename: 'device.pub');
onPressed: () async { },
await Share.share(context,
title: 'Please sign and return a certificate',
text: pubKey,
filename: 'device.pub');
},
);
},
), ),
]) ])
]; ];
} }
List<Widget> _buildLoadCert() { List<Widget> _buildLoadCert() {
Map<String, Widget> children = {
'paste': Text('Copy/Paste'),
'file': Text('File'),
};
// not all devices have a camera for QR codes
if (widget.supportsQRScanning) {
children['qr'] = Text('QR Code');
}
List<Widget> items = [ List<Widget> items = [
Padding( Padding(
padding: EdgeInsets.fromLTRB(10, 25, 10, 0), padding: EdgeInsets.fromLTRB(10, 25, 10, 0),
child: CupertinoSlidingSegmentedControl( child: CupertinoSlidingSegmentedControl(
groupValue: inputType, groupValue: inputType,
onValueChanged: (v) { onValueChanged: (v) {
if (v != null) { setState(() {
setState(() { inputType = v;
inputType = v; });
}); },
} children: {
'paste': Text('Copy/Paste'),
'file': Text('File'),
'qr': Text('QR Code'),
}, },
children: children,
)) ))
]; ];
@ -140,37 +127,13 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
items.addAll(_addPaste()); items.addAll(_addPaste());
} else if (inputType == 'file') { } else if (inputType == 'file') {
items.addAll(_addFile()); items.addAll(_addFile());
} else if (inputType == 'qr') { } else {
items.addAll(_addQr()); items.addAll(_addQr());
} }
return items; return items;
} }
Widget _buildKey() {
if (!showKey) {
return Padding(
padding: EdgeInsets.only(top: 20, bottom: 10, left: 10, right: 10),
child: SizedBox(
width: double.infinity,
child: PlatformElevatedButton(
child: Text('Show/Import Private Key'),
color: CupertinoColors.secondaryLabel.resolveFrom(context),
onPressed: () => Utils.confirmDelete(context, 'Show/Import Private Key?', () {
setState(() {
showKey = true;
});
}, deleteLabel: 'Yes'))));
}
return ConfigSection(
label: 'Import a private key generated on another device',
children: [
ConfigTextItem(controller: keyController, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
],
);
}
List<Widget> _addPaste() { List<Widget> _addPaste() {
return [ return [
ConfigSection( ConfigSection(
@ -219,13 +182,13 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
ConfigButtonItem( ConfigButtonItem(
content: Text('Scan a QR code'), content: Text('Scan a QR code'),
onPressed: () async { onPressed: () async {
try { var options = ScanOptions(
var result = await FlutterBarcodeScanner.scanBarcode('#ff6666', 'Cancel', true, ScanMode.QR); restrictFormat: [BarcodeFormat.qr],
if (result != "") { );
_addCertEntry(result);
} var result = await BarcodeScanner.scan(options: options);
} catch (err) { if (result.rawContent != "") {
return Utils.popError(context, 'Error scanning QR code', err.toString()); _addCertEntry(result.rawContent);
} }
}), }),
], ],
@ -236,52 +199,38 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
_addCertEntry(String rawCert) async { _addCertEntry(String rawCert) async {
// Allow for app store review testing cert to override the generated key // Allow for app store review testing cert to override the generated key
if (rawCert.trim() == _testCert) { if (rawCert.trim() == _testCert) {
keyController.text = _testKey; privKey = _testKey;
} }
try { try {
var rawCerts = await platform.invokeMethod("nebula.parseCerts", <String, String>{"certs": rawCert}); var rawCerts = await platform.invokeMethod("nebula.parseCerts", <String, String>{"certs": rawCert});
List<dynamic> certs = jsonDecode(rawCerts); List<dynamic> certs = jsonDecode(rawCerts);
if (certs.length > 0) { if (certs.length > 0) {
var tryCertInfo = CertificateInfo.fromJson(certs.first); var tryCertInfo = CertificateInfo.fromJson(certs.first);
if (tryCertInfo.cert.details.isCa) { if (tryCertInfo.cert.details.isCa) {
return Utils.popError(context, 'Error loading certificate content', return Utils.popError(context, 'Error loading certificate content', 'A certificate authority is not appropriate for a client certificate.');
'A certificate authority is not appropriate for a client certificate.'); } else if (!tryCertInfo.validity.valid) {
} else if (!tryCertInfo.validity!.valid) { return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity.reason);
return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity!.reason);
} }
var certMatch = await platform //TODO: test that the pubkey we generated equals the pub key in the cert
.invokeMethod("nebula.verifyCertAndKey", <String, String>{"cert": rawCert, "key": keyController.text}); certInfo = tryCertInfo;
if (!certMatch) { certInfo.primary = !widget.choosePrimary;
// The method above will throw if there is a mismatch, this is just here in case we introduce a bug in the future certInfo.key = privKey;
return Utils.popError(context, 'Error loading certificate content',
'The provided certificates public key is not compatible with the private key.');
}
if (widget.onReplace != null) {
// If we are replacing we just return the results now
Navigator.pop(context);
widget.onReplace!(CertificateResult(certInfo: tryCertInfo, key: keyController.text));
return;
} else if (widget.onSave != null) {
// We have a cert, pop the details screen where they can hit save
Utils.openPage(context, (context) {
return CertificateDetailsScreen(
certInfo: tryCertInfo,
onSave: () {
Navigator.pop(context);
widget.onSave!(CertificateResult(certInfo: tryCertInfo, key: keyController.text));
},
supportsQRScanning: widget.supportsQRScanning,
);
});
}
} }
} on PlatformException catch (err) { } on PlatformException catch (err) {
return Utils.popError(context, 'Error loading certificate content', err.details ?? err.message); return Utils.popError(context, 'Error loading certificate content', err.details ?? err.message);
} }
// We have a cert, pop this screen and replace it with the cert detail screen and a save dialog
Utils.openPage(context, (context) {
//TODO: thread on save
return CertificateDetailsScreen(certInfo, newCert: true, choosePrimary: widget.choosePrimary, onSave: (isPrimary) {
Navigator.pop(context);
certInfo.primary = isPrimary;
widget.onSave(certInfo);
});
});
} }
} }
@ -296,4 +245,4 @@ uDneQqytYS+BUfgNnGX5wsMxOEst/kkC
const _testKey = '''-----BEGIN NEBULA X25519 PRIVATE KEY----- const _testKey = '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
UlyDdFn/2mLFykeWjCEwWVRSDHtMF7nz3At3O77Faf4= UlyDdFn/2mLFykeWjCEwWVRSDHtMF7nz3At3O77Faf4=
-----END NEBULA X25519 PRIVATE KEY-----'''; -----END NEBULA X25519 PRIVATE KEY-----''';

Some files were not shown because too many files have changed in this diff Show More