forked from core/mobile_nebula
Compare commits
73 Commits
Author | SHA1 | Date |
---|---|---|
core | 1ecaaa60ff | |
John Maguire | ec1af2974a | |
John Maguire | fbd2759d4f | |
Nate Brown | 83145f6fa4 | |
Nate Brown | 81901b5dae | |
John Maguire | dcf54b1c38 | |
John Maguire | 096bad25ee | |
John Maguire | 693c7b6346 | |
Nate Brown | 69d0641874 | |
Nate Brown | 450e7c714c | |
John Maguire | cfca253ec1 | |
John Maguire | f7a7093879 | |
Nate Brown | 5ae4c20c70 | |
John Maguire | 7f5fa57b93 | |
John Maguire | 5cc41f1129 | |
John Maguire | 4bbd6c01ea | |
John Maguire | ffe2db283c | |
John Maguire | b36914ef17 | |
John Maguire | 9947676fd0 | |
John Maguire | a435517e3e | |
John Maguire | a5139c4335 | |
Nate Brown | 6b1bbf7352 | |
Caleb Jasik | 84d1256656 | |
Nate Brown | 8fc3a40467 | |
John Maguire | 17cc3477b7 | |
John Maguire | 4924888879 | |
Nate Brown | bb457c7c83 | |
John Maguire | a5ec4f5ed5 | |
John Maguire | 974c7a4eed | |
Nate Brown | 6108bf801b | |
John Maguire | 37758d4a01 | |
John Maguire | 9dd5b9cad9 | |
John Maguire | e4bbd0a31c | |
John Maguire | a5684e1978 | |
John Maguire | c7a53c3905 | |
John Maguire | c3f5c39d83 | |
John Maguire | 5ec6004a9f | |
John Maguire | 552d16bce2 | |
Nate Brown | 7bf9ee553e | |
Nate Brown | 469195cdf7 | |
Nate Brown | d8e90a1b4b | |
Nate Brown | 562e715a9a | |
Nate Brown | dabca6dd32 | |
Nate Brown | b29661abff | |
Nate Brown | dbe67c2f81 | |
Nate Brown | e3780bda1e | |
Nate Brown | 958b15d711 | |
Nate Brown | 457952b5ed | |
Nate Brown | d37bdd9032 | |
Nate Brown | 64b056618c | |
Nate Brown | 2831c84b57 | |
John Maguire | 3e0da2a8f0 | |
John Maguire | fec77613bf | |
John Maguire | 145a6c9b4f | |
Nate Brown | 87ce46df19 | |
Nate Brown | 6c188ff1d1 | |
Nate Brown | a1694727aa | |
Nate Brown | 69830d64cd | |
Nate Brown | 93b106fd88 | |
Nate Brown | cff26eb679 | |
Nate Brown | de43f11ab2 | |
Nate Brown | bde3f6fdfa | |
John Maguire | 47865d568b | |
Nate Brown | f176047510 | |
Nate Brown | 9934f226e3 | |
Nate Brown | 3a37802f4d | |
John Maguire | f3fab82e34 | |
Nate Brown | 0bb2a30829 | |
Nate Brown | a283bf8010 | |
John Maguire | 3194028a78 | |
John Maguire | 1d044a1e36 | |
John Maguire | 3123ce5f9a | |
Nate Brown | a7c32f5bd4 |
|
@ -0,0 +1,2 @@
|
||||||
|
# Big flutter format run
|
||||||
|
9934f226e3e79c3567ce07dbab9e9f6443e7afc5
|
|
@ -0,0 +1,10 @@
|
||||||
|
#!/bin/sh
|
||||||
|
DIRS="lib test"
|
||||||
|
EXIT=0
|
||||||
|
|
||||||
|
for DIR in $DIRS; do
|
||||||
|
OUT="$(flutter format -l 120 --suppress-analytics "$DIR" | sed -e "s/^Formatted \(.*\)/::error file=$DIR\/\1::Not formatted/g")"
|
||||||
|
echo "$OUT" | grep "::error" && EXIT=1
|
||||||
|
done
|
||||||
|
|
||||||
|
exit $EXIT
|
|
@ -0,0 +1,27 @@
|
||||||
|
name: Flutter format
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/flutterfmt.yml'
|
||||||
|
- '.github/workflows/flutterfmt.sh'
|
||||||
|
- '**.dart'
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
gofmt:
|
||||||
|
name: Run flutter format
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Install flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.3.5'
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: flutter format
|
||||||
|
run: $GITHUB_WORKSPACE/.github/workflows/flutterfmt.sh
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
if [ -z "$1" ]; then
|
||||||
|
rm -f ./gofmterr
|
||||||
|
find . -iname '*.go' ! -name '*.pb.go' -exec "$0" {} \;
|
||||||
|
[ -f ./gofmterr ] && exit 1
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT="$(./nebula/goimports -d "$1" | awk '{printf "%s%%0A",$0}')"
|
||||||
|
if [ -n "$OUT" ]; then
|
||||||
|
echo "::error file=$1::$OUT"
|
||||||
|
touch ./gofmterr
|
||||||
|
fi
|
|
@ -0,0 +1,34 @@
|
||||||
|
name: gofmt
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
pull_request:
|
||||||
|
paths:
|
||||||
|
- '.github/workflows/gofmt.yml'
|
||||||
|
- '.github/workflows/gofmt.sh'
|
||||||
|
- '**.go'
|
||||||
|
jobs:
|
||||||
|
|
||||||
|
gofmt:
|
||||||
|
name: Run gofmt
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
|
||||||
|
- name: Set up Go 1.20
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: "1.20"
|
||||||
|
id: go
|
||||||
|
|
||||||
|
- name: Check out code into the Go module directory
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Install goimports
|
||||||
|
working-directory: nebula
|
||||||
|
run: |
|
||||||
|
go get golang.org/x/tools/cmd/goimports
|
||||||
|
go build golang.org/x/tools/cmd/goimports
|
||||||
|
|
||||||
|
- name: gofmt
|
||||||
|
run: $GITHUB_WORKSPACE/.github/workflows/gofmt.sh
|
|
@ -0,0 +1,154 @@
|
||||||
|
name: Create release and upload to Apple and Google
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
# Only builds for tags with a meaningless build number suffix: v1.0.0-1
|
||||||
|
- 'v[0-9]+.[0-9]+.[0-9]+-*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
name: Build ios and android package
|
||||||
|
runs-on: macos-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Set up Go 1.20
|
||||||
|
uses: actions/setup-go@v4
|
||||||
|
with:
|
||||||
|
go-version: "1.20"
|
||||||
|
|
||||||
|
- uses: actions/setup-java@v2
|
||||||
|
with:
|
||||||
|
distribution: 'zulu'
|
||||||
|
java-version: '11'
|
||||||
|
|
||||||
|
- name: Install flutter
|
||||||
|
uses: subosito/flutter-action@v2
|
||||||
|
with:
|
||||||
|
flutter-version: '3.3.5'
|
||||||
|
|
||||||
|
- uses: nttld/setup-ndk@v1
|
||||||
|
id: setup-ndk
|
||||||
|
with:
|
||||||
|
ndk-version: r21b
|
||||||
|
# r21b is 21.1.6352462, if it is not published here https://developer.android.com/ndk/downloads
|
||||||
|
# or here https://github.com/android/ndk/wiki/Unsupported-Downloads then you must download them and look at
|
||||||
|
# source.properties to determine the version
|
||||||
|
|
||||||
|
- name: Check out code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- name: Configure git for private modules
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.MACHINE_USER_PAT }}
|
||||||
|
run: git config --global url."https://defined-machine:${TOKEN}@github.com".insteadOf "https://github.com"
|
||||||
|
|
||||||
|
- name: Install the appstore connect key material
|
||||||
|
env:
|
||||||
|
AC_API_KEY_SECRET_BASE64: ${{ secrets.AC_API_KEY_SECRET_BASE64 }}
|
||||||
|
run: |
|
||||||
|
AC_API_KEY_SECRET_PATH="$RUNNER_TEMP/key.p8"
|
||||||
|
echo "APP_STORE_CONNECT_API_KEY_KEY_FILEPATH=$AC_API_KEY_SECRET_PATH" >> $GITHUB_ENV
|
||||||
|
echo -n "$AC_API_KEY_SECRET_BASE64" | base64 --decode --output "$AC_API_KEY_SECRET_PATH"
|
||||||
|
|
||||||
|
- name: Install the google play key material
|
||||||
|
env:
|
||||||
|
GOOGLE_PLAY_API_JWT_BASE64: ${{ secrets.GOOGLE_PLAY_API_JWT_BASE64 }}
|
||||||
|
GOOGLE_PLAY_KEYSTORE_BASE64: ${{ secrets.GOOGLE_PLAY_KEYSTORE_BASE64 }}
|
||||||
|
run: |
|
||||||
|
GOOGLE_PLAY_API_JWT_PATH="$RUNNER_TEMP/gp_api.json"
|
||||||
|
echo "GOOGLE_PLAY_API_JWT_PATH=$GOOGLE_PLAY_API_JWT_PATH" >> $GITHUB_ENV
|
||||||
|
echo -n "$GOOGLE_PLAY_API_JWT_BASE64" | base64 --decode --output "$GOOGLE_PLAY_API_JWT_PATH"
|
||||||
|
|
||||||
|
GOOGLE_PLAY_KEYSTORE_PATH="$RUNNER_TEMP/gp_signing.jks"
|
||||||
|
echo "GOOGLE_PLAY_KEYSTORE_PATH=$GOOGLE_PLAY_KEYSTORE_PATH" >> $GITHUB_ENV
|
||||||
|
echo -n "$GOOGLE_PLAY_KEYSTORE_BASE64" | base64 --decode --output "$GOOGLE_PLAY_KEYSTORE_PATH"
|
||||||
|
|
||||||
|
- name: Get build name and number, install dependencies
|
||||||
|
run: |
|
||||||
|
go install golang.org/x/mobile/cmd/gomobile@latest
|
||||||
|
gomobile init
|
||||||
|
|
||||||
|
flutter pub get
|
||||||
|
|
||||||
|
touch env.sh
|
||||||
|
|
||||||
|
cd android
|
||||||
|
fastlane release_build_number
|
||||||
|
echo "BUILD_NUMBER=$(cat ../release_build_number)" >> $GITHUB_ENV
|
||||||
|
BUILD_NAME="${GITHUB_REF#refs/tags/v}" # strip the front refs/tags/v off
|
||||||
|
BUILD_NAME="${BUILD_NAME%-*}" # strip the junk build number off
|
||||||
|
echo "BUILD_NAME=$BUILD_NAME" >> $GITHUB_ENV
|
||||||
|
|
||||||
|
- name: Build iOS
|
||||||
|
env:
|
||||||
|
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
cd ios
|
||||||
|
pod install
|
||||||
|
fastlane build
|
||||||
|
|
||||||
|
- name: Collect iOS artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: MobileNebula.ipa
|
||||||
|
path: ios/MobileNebula.ipa
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Build Android
|
||||||
|
env:
|
||||||
|
ANDROID_NDK_HOME: ${{ steps.setup-ndk.outputs.ndk-path }}
|
||||||
|
GOOGLE_PLAY_KEYSTORE_PASSWORD: ${{ secrets.GOOGLE_PLAY_KEYSTORE_PASSWORD }}
|
||||||
|
run: |
|
||||||
|
flutter build appbundle --build-number="$BUILD_NUMBER" --build-name="$BUILD_NAME"
|
||||||
|
|
||||||
|
- name: Collect Android artifacts
|
||||||
|
uses: actions/upload-artifact@v3
|
||||||
|
with:
|
||||||
|
name: MobileNebula.aab
|
||||||
|
path: build/app/outputs/bundle/release/app-release.aab
|
||||||
|
retention-days: 5
|
||||||
|
|
||||||
|
- name: Publish to iOS TestFlight
|
||||||
|
env:
|
||||||
|
APP_STORE_CONNECT_API_KEY_KEY_ID: ${{ secrets.AC_API_KEY_ID }}
|
||||||
|
APP_STORE_CONNECT_API_KEY_ISSUER_ID: ${{ secrets.AC_API_KEY_ISSUER_ID }}
|
||||||
|
run: |
|
||||||
|
cd ios
|
||||||
|
fastlane release
|
||||||
|
|
||||||
|
- name: Publish to Android internal track
|
||||||
|
run: |
|
||||||
|
cd android
|
||||||
|
fastlane release
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
id: create_release
|
||||||
|
uses: actions/create-release@v1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tag_name: ${{ github.ref }}
|
||||||
|
release_name: Release ${{ github.ref }}
|
||||||
|
draft: true
|
||||||
|
prerelease: false
|
||||||
|
|
||||||
|
- name: Upload release Android app
|
||||||
|
uses: actions/upload-release-asset@v1.0.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_path: build/app/outputs/bundle/release/app-release.aab
|
||||||
|
asset_name: MobileNebula.aab
|
||||||
|
asset_content_type: text/plain
|
||||||
|
|
||||||
|
- name: Upload release iOS app
|
||||||
|
uses: actions/upload-release-asset@v1.0.1
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
||||||
|
asset_path: ios/MobileNebula.ipa
|
||||||
|
asset_name: MobileNebula.ipa
|
||||||
|
asset_content_type: text/plain
|
||||||
|
|
|
@ -48,3 +48,6 @@ 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/
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to this project will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.0.41] - 2021-06-09
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added an option to wrap logs in the hamburger menu. (#10)
|
||||||
|
|
||||||
|
- IPv6 and better roaming support. (#24)
|
||||||
|
|
||||||
|
- Certificates can now be replaced. (#33)
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded to Flutter 2. (#26)
|
||||||
|
|
||||||
|
- Upgraded core Nebula to 1.4.1. (#41)
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- iOS: Reworked vpn process IPC for more reliable communication. (#28)
|
||||||
|
|
||||||
|
- Android: Detecting the active vpn site on app boot is now more reliable. (#29)
|
||||||
|
|
||||||
|
- Android: Quickly toggling site connection status no longer presents an error. (#16)
|
||||||
|
|
||||||
|
- Android: Better vpn shutdown support. (#34)
|
||||||
|
|
||||||
|
- Android: System DNS will continue to work when moving between IPv4 only and IPv6 networks. (#40)
|
||||||
|
|
||||||
|
## [0.0.38] - 2020-09-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Initial public release.
|
||||||
|
|
||||||
|
[0.0.38]: https://github.com/DefinedNet/mobile_nebula/releases/tag/v0.0.38
|
||||||
|
[0.0.41]: https://github.com/DefinedNet/mobile_nebula/releases/tag/v0.0.41
|
35
README.md
35
README.md
|
@ -1,16 +1,34 @@
|
||||||
# Dependencies
|
# Mobile Nebula
|
||||||
|
|
||||||
- [`flutter`](https://flutter.dev/docs/get-started/install)
|
[Play Store](https://play.google.com/store/apps/details?id=net.defined.mobile_nebula&hl=en_US&gl=US) | [App Store](https://apps.apple.com/us/app/mobile-nebula/id1509587936)
|
||||||
- [`gomobile`](https://godoc.org/golang.org/x/mobile/cmd/gomobile)
|
|
||||||
|
## Setting up dev environment
|
||||||
|
|
||||||
|
Install all of the following things:
|
||||||
|
|
||||||
|
- [`xcode`](https://apps.apple.com/us/app/xcode/)
|
||||||
- [`android-studio`](https://developer.android.com/studio)
|
- [`android-studio`](https://developer.android.com/studio)
|
||||||
- [Enable NDK](https://developer.android.com/studio/projects/install-ndk) Check local.properties for current NDK version
|
- [`flutter` 3.3.5](https://docs.flutter.dev/get-started/install)
|
||||||
|
- [`gomobile`](https://pkg.go.dev/golang.org/x/mobile/cmd/gomobile)
|
||||||
|
- [Flutter Android Studio Extension](https://docs.flutter.dev/get-started/editor?tab=androidstudio)
|
||||||
|
|
||||||
Currently using flutter 2.0.5
|
Ensure your path is set up correctly to execute flutter
|
||||||
|
|
||||||
Copy env.sh.example to env.sh and update your PATH variable to expose both flutter and go bin directories
|
Run `flutter doctor` and fix everything it complains before proceeding
|
||||||
|
|
||||||
```export PATH="$PATH:/path/to/go/bin:/path/to/flutter/bin```
|
*NOTE* on iOS, always open `Runner.xcworkspace` and NOT the `Runner.xccodeproj`
|
||||||
|
|
||||||
|
### Before first compile
|
||||||
|
|
||||||
|
- Copy `env.sh.example` and set it up for your machine
|
||||||
|
- Ensure you have run `gomobile init`
|
||||||
|
- In Android Studio, make sure you have the current ndk installed by going to Tools -> SDK Manager, go to the SDK Tools tab, check the `Show package details` box, expand the NDK section and select `21.1.6352462` version.
|
||||||
|
- Ensure you have downloaded an ndk via android studio, this is likely not the default one and you need to check the
|
||||||
|
`Show package details` box to select the correct version. The correct version comes from the error when you try and compile
|
||||||
|
- Make sure you have `gem` installed with `sudo gem install`
|
||||||
|
- If on MacOS arm, `sudo gem install ffi -- --enable-libffi-alloc`
|
||||||
|
|
||||||
|
If you are having issues with iOS pods, try blowing it all away! `cd ios && rm -rf Pods/ Podfile.lock && pod install --repo-update`
|
||||||
|
|
||||||
# Formatting
|
# Formatting
|
||||||
|
|
||||||
|
@ -21,14 +39,13 @@ 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 --no-shrink`
|
`flutter build appbundle`
|
||||||
|
|
||||||
This will create an android app bundle at `build/app/outputs/bundle/release/`
|
This will create an android app bundle at `build/app/outputs/bundle/release/`
|
||||||
|
|
||||||
|
|
|
@ -6,3 +6,4 @@ gradle-wrapper.jar
|
||||||
/local.properties
|
/local.properties
|
||||||
GeneratedPluginRegistrant.java
|
GeneratedPluginRegistrant.java
|
||||||
/build/build-attribution/
|
/build/build-attribution/
|
||||||
|
/mobileNebula/mobileNebula.aar
|
||||||
|
|
|
@ -0,0 +1,10 @@
|
||||||
|
# Autogenerated by fastlane
|
||||||
|
#
|
||||||
|
# Ensure this file is checked in to source control!
|
||||||
|
|
||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem 'fastlane'
|
||||||
|
|
||||||
|
plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
|
||||||
|
eval_gemfile(plugins_path) if File.exist?(plugins_path)
|
|
@ -0,0 +1,220 @@
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.5)
|
||||||
|
rexml
|
||||||
|
addressable (2.8.1)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
artifactory (3.0.15)
|
||||||
|
atomos (0.1.3)
|
||||||
|
aws-eventstream (1.2.0)
|
||||||
|
aws-partitions (1.635.0)
|
||||||
|
aws-sdk-core (3.153.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
aws-partitions (~> 1, >= 1.525.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
aws-sdk-kms (1.58.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sdk-s3 (1.114.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sigv4 (1.5.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
babosa (1.0.4)
|
||||||
|
claide (1.1.0)
|
||||||
|
colored (1.2)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
commander (4.6.0)
|
||||||
|
highline (~> 2.0.0)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.6.4)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
emoji_regex (3.2.3)
|
||||||
|
excon (0.92.5)
|
||||||
|
faraday (1.10.2)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-cookie_jar (0.0.7)
|
||||||
|
faraday (>= 0.8.0)
|
||||||
|
http-cookie (~> 1.0.0)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
faraday_middleware (1.2.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
fastimage (2.2.6)
|
||||||
|
fastlane (2.210.1)
|
||||||
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
artifactory (~> 3.0)
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
|
colored
|
||||||
|
commander (~> 4.6)
|
||||||
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
|
faraday_middleware (~> 1.0)
|
||||||
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-storage (~> 1.31)
|
||||||
|
highline (~> 2.0)
|
||||||
|
json (< 3.0.0)
|
||||||
|
jwt (>= 2.1.0, < 3)
|
||||||
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multipart-post (~> 2.0.0)
|
||||||
|
naturally (~> 2.2)
|
||||||
|
optparse (~> 0.1.1)
|
||||||
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
|
security (= 0.1.3)
|
||||||
|
simctl (~> 1.6.3)
|
||||||
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
|
word_wrap (~> 1.0.0)
|
||||||
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
|
xcpretty (~> 0.3.0)
|
||||||
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
|
fastlane-plugin-match_keystore (0.2.1)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
google-apis-androidpublisher_v3 (0.27.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-core (0.9.0)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
webrick
|
||||||
|
google-apis-iamcredentials_v1 (0.14.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-playcustomapp_v1 (0.10.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.17.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-cloud-core (1.6.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
|
google-cloud-errors (1.3.0)
|
||||||
|
google-cloud-storage (1.42.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
google-apis-storage_v1 (~> 0.17.0)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.2.0)
|
||||||
|
faraday (>= 0.17.3, < 3.a)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
|
highline (2.0.3)
|
||||||
|
http-cookie (1.0.5)
|
||||||
|
domain_name (~> 0.5)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
jmespath (1.6.1)
|
||||||
|
json (2.6.2)
|
||||||
|
jwt (2.5.0)
|
||||||
|
memoist (0.16.2)
|
||||||
|
mini_magick (4.11.0)
|
||||||
|
mini_mime (1.1.2)
|
||||||
|
multi_json (1.15.0)
|
||||||
|
multipart-post (2.0.0)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
naturally (2.2.1)
|
||||||
|
optparse (0.1.1)
|
||||||
|
os (1.1.4)
|
||||||
|
plist (3.6.0)
|
||||||
|
public_suffix (5.0.0)
|
||||||
|
rake (13.0.6)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.1.2)
|
||||||
|
rexml (3.2.5)
|
||||||
|
rouge (2.0.7)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
security (0.1.3)
|
||||||
|
signet (0.17.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
|
simctl (1.6.8)
|
||||||
|
CFPropertyList
|
||||||
|
naturally
|
||||||
|
terminal-notifier (2.0.0)
|
||||||
|
terminal-table (1.8.0)
|
||||||
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
|
tty-cursor (0.7.1)
|
||||||
|
tty-screen (0.8.1)
|
||||||
|
tty-spinner (0.9.3)
|
||||||
|
tty-cursor (~> 0.7)
|
||||||
|
uber (0.1.0)
|
||||||
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.7.0)
|
||||||
|
word_wrap (1.0.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
xcpretty (0.3.0)
|
||||||
|
rouge (~> 2.0.7)
|
||||||
|
xcpretty-travis-formatter (1.0.1)
|
||||||
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
arm64-darwin-21
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
fastlane
|
||||||
|
fastlane-plugin-match_keystore
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.3.11
|
|
@ -25,51 +25,52 @@ apply plugin: 'com.android.application'
|
||||||
apply plugin: 'kotlin-android'
|
apply plugin: 'kotlin-android'
|
||||||
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle"
|
||||||
|
|
||||||
def keystoreProperties = new Properties()
|
|
||||||
def keystorePropertiesFile = rootProject.file('key.properties')
|
|
||||||
if (keystorePropertiesFile.exists()) {
|
|
||||||
keystoreProperties.load(new FileInputStream(keystorePropertiesFile))
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
android {
|
||||||
compileSdkVersion 29
|
namespace "net.defined.mobile_nebula"
|
||||||
|
|
||||||
|
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 25
|
minSdkVersion 26 //flutter.minSdkVersion
|
||||||
targetSdkVersion 29
|
targetSdkVersion 33 //flutter.targetSdkVersion
|
||||||
versionCode flutterVersionCode.toInteger()
|
versionCode flutterVersionCode.toInteger()
|
||||||
versionName flutterVersionName
|
versionName flutterVersionName
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
release {
|
release {
|
||||||
keyAlias keystoreProperties['keyAlias']
|
keyAlias 'key'
|
||||||
keyPassword keystoreProperties['password']
|
storeFile System.getenv('GOOGLE_PLAY_KEYSTORE_PATH') ? file(System.getenv('GOOGLE_PLAY_KEYSTORE_PATH')) : null
|
||||||
storeFile keystoreProperties['storeFile'] ? file(keystoreProperties['storeFile']) : null
|
keyPassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD')
|
||||||
storePassword keystoreProperties['password']
|
storePassword System.getenv('GOOGLE_PLAY_KEYSTORE_PASSWORD')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
release {
|
release {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
|
|
||||||
// We are disabling minification and proguard because it wrecks the crypto for storing keys
|
|
||||||
// Ideally we would turn these on. We had issues with gson as well but resolved those with proguardFiles
|
|
||||||
shrinkResources false
|
|
||||||
minifyEnabled false
|
|
||||||
useProguard false
|
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
||||||
|
resValue 'string', 'app_name', '"Nebula"'
|
||||||
|
}
|
||||||
|
|
||||||
|
debug {
|
||||||
|
resValue 'string', 'app_name', '"Nebula-DEBUG"'
|
||||||
|
applicationIdSuffix '.debug'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -78,26 +79,13 @@ flutter {
|
||||||
source '../..'
|
source '../..'
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
|
||||||
flatDir {
|
|
||||||
dirs 'src/main/libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion"
|
||||||
implementation "androidx.security:security-crypto:1.0.0-rc02"
|
implementation "androidx.security:security-crypto:1.0.0"
|
||||||
implementation 'com.google.code.gson:gson:2.8.6'
|
implementation "androidx.work:work-runtime-ktx:$workVersion"
|
||||||
|
implementation 'com.google.code.gson:gson:2.8.9'
|
||||||
|
implementation "com.google.guava:guava:31.0.1-android"
|
||||||
|
implementation project(':mobileNebula')
|
||||||
|
|
||||||
testImplementation 'junit:junit:4.12'
|
|
||||||
androidTestImplementation 'androidx.test:runner:1.1.1'
|
|
||||||
androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1'
|
|
||||||
implementation (name:'mobileNebula', ext:'aar') {
|
|
||||||
exec {
|
|
||||||
workingDir '../../'
|
|
||||||
environment("ANDROID_NDK_HOME", android.ndkDirectory)
|
|
||||||
environment("ANDROID_HOME", android.sdkDirectory)
|
|
||||||
commandLine './gen-artifacts.sh', 'android'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="net.defined.mobile_nebula">
|
|
||||||
<!-- Flutter needs it to communicate with the running application
|
<!-- Flutter needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="ic_launcher_background">#f2c10d</color>
|
||||||
|
</resources>
|
|
@ -1,5 +1,5 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="net.defined.mobile_nebula">
|
xmlns:tools="http://schemas.android.com/tools">
|
||||||
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
<!-- io.flutter.app.FlutterApplication is an android.app.Application that
|
||||||
calls FlutterMain.startInitialization(this); in its onCreate method.
|
calls FlutterMain.startInitialization(this); in its onCreate method.
|
||||||
In most cases you can leave this as-is, but you if you want to provide
|
In most cases you can leave this as-is, but you if you want to provide
|
||||||
|
@ -7,20 +7,31 @@
|
||||||
FlutterApplication and put your custom class here. -->
|
FlutterApplication and put your custom class here. -->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-feature android:name="android.hardware.camera" android:required="false" />
|
<uses-feature android:name="android.hardware.camera" android:required="false"
|
||||||
|
tools:replace="required" />
|
||||||
|
<queries>
|
||||||
|
<intent>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<data android:scheme="mailto" />
|
||||||
|
</intent>
|
||||||
|
</queries>
|
||||||
<application
|
<application
|
||||||
android:name="io.flutter.app.FlutterApplication"
|
android:name="MyApplication"
|
||||||
android:label="Nebula"
|
android:label="@string/app_name"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<service android:name=".NebulaVpnService"
|
<service android:name=".NebulaVpnService"
|
||||||
android:permission="android.permission.BIND_VPN_SERVICE"
|
android:permission="android.permission.BIND_VPN_SERVICE"
|
||||||
|
android:exported="false"
|
||||||
android:process=":nebulaVpnBg">
|
android:process=":nebulaVpnBg">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.net.VpnService"/>
|
<action android:name="android.net.VpnService"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<meta-data android:name="android.net.VpnService.SUPPORTS_ALWAYS_ON"
|
||||||
|
android:value="false"/>
|
||||||
</service>
|
</service>
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
android:launchMode="singleTop"
|
android:launchMode="singleTop"
|
||||||
android:theme="@style/LaunchTheme"
|
android:theme="@style/LaunchTheme"
|
||||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
|
@ -30,8 +41,16 @@
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<!-- App linking -->
|
||||||
|
<meta-data android:name="flutter_deeplinking_enabled" android:value="true" />
|
||||||
|
<intent-filter android:autoVerify="true">
|
||||||
|
<action android:name="android.intent.action.VIEW"/>
|
||||||
|
<category android:name="android.intent.category.DEFAULT"/>
|
||||||
|
<category android:name="android.intent.category.BROWSABLE"/>
|
||||||
|
<data android:scheme="http" android:host="api.defined.net" android:pathPrefix="/v1/mobile-enrollment"/>
|
||||||
|
<data android:scheme="https"/>
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<receiver android:name=".ShareReceiver" android:exported="false"/>
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.provider"
|
android:authorities="${applicationId}.provider"
|
||||||
|
@ -41,6 +60,18 @@
|
||||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||||
android:resource="@xml/provider_paths"/>
|
android:resource="@xml/provider_paths"/>
|
||||||
</provider>
|
</provider>
|
||||||
|
<provider
|
||||||
|
android:name="androidx.startup.InitializationProvider"
|
||||||
|
android:authorities="${applicationId}.androidx-startup"
|
||||||
|
android:exported="false"
|
||||||
|
tools:node="merge">
|
||||||
|
<!-- If you are using androidx.startup to initialize other components -->
|
||||||
|
<meta-data
|
||||||
|
android:name="androidx.work.WorkManagerInitializer"
|
||||||
|
android:value="androidx.startup"
|
||||||
|
tools:node="remove" />
|
||||||
|
</provider>
|
||||||
|
|
||||||
<!-- Don't delete the meta-data below.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.google.gson.Gson
|
||||||
|
|
||||||
|
class InvalidCredentialsException: Exception("Invalid credentials")
|
||||||
|
|
||||||
|
class APIClient(context: Context) {
|
||||||
|
private val packageInfo = PackageInfo(context)
|
||||||
|
private val client = mobileNebula.MobileNebula.newAPIClient(
|
||||||
|
"MobileNebula/%s (Android %s)".format(
|
||||||
|
packageInfo.getVersion(),
|
||||||
|
packageInfo.getSystemVersion(),
|
||||||
|
))
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
fun enroll(code: String): IncomingSite {
|
||||||
|
val res = client.enroll(code)
|
||||||
|
return decodeIncomingSite(res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Long, trustedKeys: String): IncomingSite? {
|
||||||
|
val res: mobileNebula.TryUpdateResult
|
||||||
|
try {
|
||||||
|
res = client.tryUpdate(siteName, hostID, privateKey, counter, trustedKeys)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// type information from Go is not available, use string matching instead
|
||||||
|
if (e.message == "invalid credentials") {
|
||||||
|
throw InvalidCredentialsException()
|
||||||
|
}
|
||||||
|
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.fetchedUpdate) {
|
||||||
|
return decodeIncomingSite(res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeIncomingSite(jsonSite: String): IncomingSite {
|
||||||
|
return gson.fromJson(jsonSite, IncomingSite::class.java)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,129 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.Worker
|
||||||
|
import androidx.work.WorkerParameters
|
||||||
|
import java.io.Closeable
|
||||||
|
import java.nio.channels.FileChannel
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
|
||||||
|
class DNUpdateWorker(ctx: Context, params: WorkerParameters) : Worker(ctx, params) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "DNUpdateWorker"
|
||||||
|
}
|
||||||
|
|
||||||
|
private val context = applicationContext
|
||||||
|
private val apiClient: APIClient = APIClient(ctx)
|
||||||
|
private val updater = DNSiteUpdater(context, apiClient)
|
||||||
|
private val sites = SiteList(context)
|
||||||
|
|
||||||
|
override fun doWork(): Result {
|
||||||
|
var failed = false
|
||||||
|
|
||||||
|
sites.getSites().values.forEach { site ->
|
||||||
|
try {
|
||||||
|
updateSite(site)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
failed = true
|
||||||
|
Log.e(TAG, "Error while updating site ${site.id}: ${e.stackTraceToString()}")
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return if (failed) Result.failure() else Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateSite(site: Site) {
|
||||||
|
try {
|
||||||
|
DNUpdateLock(site).use {
|
||||||
|
val res = updater.updateSite(site)
|
||||||
|
|
||||||
|
// Reload Nebula if this is the currently active site
|
||||||
|
if (res == DNSiteUpdater.Result.CONFIG_UPDATED) {
|
||||||
|
Intent().also { intent ->
|
||||||
|
intent.action = NebulaVpnService.ACTION_RELOAD
|
||||||
|
intent.putExtra("id", site.id)
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the UI on any change
|
||||||
|
if (res != DNSiteUpdater.Result.NOOP) {
|
||||||
|
Intent().also { intent ->
|
||||||
|
intent.action = MainActivity.ACTION_REFRESH_SITES
|
||||||
|
context.sendBroadcast(intent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: java.nio.channels.OverlappingFileLockException) {
|
||||||
|
Log.w(TAG, "Can't lock site ${site.name}, skipping it...")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DNUpdateLock(site: Site): Closeable {
|
||||||
|
private val fileChannel = FileChannel.open(
|
||||||
|
Paths.get(site.path+"/update.lock"),
|
||||||
|
StandardOpenOption.CREATE,
|
||||||
|
StandardOpenOption.WRITE,
|
||||||
|
)
|
||||||
|
private val fileLock = fileChannel.tryLock()
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
fileLock.close()
|
||||||
|
fileChannel.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class DNSiteUpdater(
|
||||||
|
private val context: Context,
|
||||||
|
private val apiClient: APIClient,
|
||||||
|
) {
|
||||||
|
enum class Result {
|
||||||
|
CONFIG_UPDATED, CREDENTIALS_UPDATED, NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSite(site: Site): Result {
|
||||||
|
if (!site.managed) {
|
||||||
|
return Result.NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
val credentials = site.getDNCredentials(context)
|
||||||
|
|
||||||
|
val newSite: IncomingSite?
|
||||||
|
try {
|
||||||
|
newSite = apiClient.tryUpdate(
|
||||||
|
site.name,
|
||||||
|
credentials.hostID,
|
||||||
|
credentials.privateKey,
|
||||||
|
credentials.counter.toLong(),
|
||||||
|
credentials.trustedKeys,
|
||||||
|
)
|
||||||
|
} catch (e: InvalidCredentialsException) {
|
||||||
|
if (!credentials.invalid) {
|
||||||
|
site.invalidateDNCredentials(context)
|
||||||
|
Log.d(TAG, "Invalidated credentials in site ${site.name}")
|
||||||
|
return Result.CREDENTIALS_UPDATED
|
||||||
|
}
|
||||||
|
return Result.NOOP
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newSite != null) {
|
||||||
|
newSite.save(context)
|
||||||
|
Log.d(TAG, "Updated site ${site.id}: ${site.name}")
|
||||||
|
return Result.CONFIG_UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.invalid) {
|
||||||
|
site.validateDNCredentials(context)
|
||||||
|
Log.d(TAG, "Revalidated credentials in site ${site.id}: ${site.name}")
|
||||||
|
return Result.CREDENTIALS_UPDATED
|
||||||
|
}
|
||||||
|
|
||||||
|
return Result.NOOP
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,57 @@
|
||||||
package net.defined.mobile_nebula
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
import androidx.security.crypto.EncryptedFile
|
import androidx.security.crypto.EncryptedFile
|
||||||
import androidx.security.crypto.MasterKeys
|
import androidx.security.crypto.MasterKeys
|
||||||
import java.io.*
|
import java.io.*
|
||||||
|
import java.security.KeyStore
|
||||||
|
|
||||||
|
class EncFile(private val context: Context) {
|
||||||
|
companion object {
|
||||||
|
// Borrowed from androidx.security.crypto.MasterKeys
|
||||||
|
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
|
||||||
|
|
||||||
|
// Borrowed from androidx.security.crypto.EncryptedFile
|
||||||
|
private const val KEYSET_PREF_NAME = "__androidx_security_crypto_encrypted_file_pref__"
|
||||||
|
}
|
||||||
|
|
||||||
class EncFile(var context: Context) {
|
|
||||||
private val scheme = EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
|
private val scheme = EncryptedFile.FileEncryptionScheme.AES256_GCM_HKDF_4KB
|
||||||
private val master: String = MasterKeys.getOrCreate(MasterKeys.AES256_GCM_SPEC)
|
private val spec = MasterKeys.AES256_GCM_SPEC
|
||||||
|
private var master: String = MasterKeys.getOrCreate(spec)
|
||||||
|
|
||||||
fun openRead(file: File): BufferedReader {
|
fun openRead(file: File): BufferedReader {
|
||||||
val eFile = EncryptedFile.Builder(file, context, master, scheme).build()
|
// We may fail to decrypt the file, in which case we'll raise an exception.
|
||||||
return eFile.openFileInput().bufferedReader()
|
// Callers should handle this exception by deleting the invalid file.
|
||||||
|
return build(file).openFileInput().bufferedReader()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun openWrite(file: File): BufferedWriter {
|
fun openWrite(file: File): BufferedWriter {
|
||||||
val eFile = EncryptedFile.Builder(file, context, master, scheme).build()
|
return try {
|
||||||
return eFile.openFileOutput().bufferedWriter()
|
build(file).openFileOutput().bufferedWriter()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// If we fail to open the file, it's likely because the master key no longer works.
|
||||||
|
// We'll try to reset the master key and try again.
|
||||||
|
resetMasterKey()
|
||||||
|
|
||||||
|
build(file).openFileOutput().bufferedWriter()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun build(file: File): EncryptedFile {
|
||||||
|
return EncryptedFile.Builder(file, context, master, scheme).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun resetMasterKey() {
|
||||||
|
// Reset the master key
|
||||||
|
KeyStore.getInstance(ANDROID_KEYSTORE).apply {
|
||||||
|
load(null)
|
||||||
|
deleteEntry(master)
|
||||||
|
}
|
||||||
|
// And reset the shared preference containing the file encryption key
|
||||||
|
context.deleteSharedPreferences(KEYSET_PREF_NAME)
|
||||||
|
|
||||||
|
// Re-create the master key now so future calls don't fail
|
||||||
|
master = MasterKeys.getOrCreate(spec)
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -1,57 +1,76 @@
|
||||||
package net.defined.mobile_nebula
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.net.VpnService
|
import android.net.VpnService
|
||||||
import android.os.*
|
import android.os.*
|
||||||
import androidx.annotation.NonNull
|
import android.util.Log
|
||||||
|
import androidx.work.*
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import io.flutter.embedding.android.FlutterActivity
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
import io.flutter.plugin.common.MethodChannel
|
import io.flutter.plugin.common.MethodChannel
|
||||||
import io.flutter.plugins.GeneratedPluginRegistrant
|
import io.flutter.plugins.GeneratedPluginRegistrant
|
||||||
|
import java.io.File
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
const val TAG = "nebula"
|
const val TAG = "nebula"
|
||||||
const val VPN_PERMISSIONS_CODE = 0x0F
|
|
||||||
const val VPN_START_CODE = 0x10
|
const val VPN_START_CODE = 0x10
|
||||||
const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService"
|
const val CHANNEL = "net.defined.mobileNebula/NebulaVpnService"
|
||||||
|
const val UPDATE_WORKER = "dnUpdater"
|
||||||
|
|
||||||
class MainActivity: FlutterActivity() {
|
class MainActivity: FlutterActivity() {
|
||||||
private var sites: Sites? = null
|
private var ui: MethodChannel? = null
|
||||||
private var permResult: MethodChannel.Result? = null
|
|
||||||
|
|
||||||
private var inMessenger: Messenger? = Messenger(IncomingHandler())
|
private var inMessenger: Messenger? = Messenger(IncomingHandler())
|
||||||
private var outMessenger: Messenger? = null
|
private var outMessenger: Messenger? = null
|
||||||
|
|
||||||
|
private var apiClient: APIClient? = null
|
||||||
|
private var sites: Sites? = null
|
||||||
|
|
||||||
|
// When starting a site we may need to request VPN permissions. These variables help us
|
||||||
|
// maintain state while waiting for a permission result.
|
||||||
|
private var startResult: MethodChannel.Result? = null
|
||||||
|
private var startingSiteContainer: SiteContainer? = null
|
||||||
|
|
||||||
private var activeSiteId: String? = null
|
private var activeSiteId: String? = null
|
||||||
|
|
||||||
|
private val workManager = WorkManager.getInstance(application)
|
||||||
|
private val refreshReceiver: BroadcastReceiver = RefreshReceiver()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
const val ACTION_REFRESH_SITES = "net.defined.mobileNebula.REFRESH_SITES"
|
||||||
|
|
||||||
private var appContext: Context? = null
|
private var appContext: Context? = null
|
||||||
fun getContext(): Context? { return appContext }
|
fun getContext(): Context? { return appContext }
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun configureFlutterEngine(@NonNull flutterEngine: FlutterEngine) {
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
appContext = context
|
appContext = context
|
||||||
//TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this
|
//TODO: Initializing in the constructor leads to a context lacking info we need, figure out the right way to do this
|
||||||
sites = Sites(flutterEngine)
|
sites = Sites(flutterEngine)
|
||||||
|
|
||||||
// Bind against our service to detect which site is running on app boot
|
|
||||||
val intent = Intent(this, NebulaVpnService::class.java)
|
|
||||||
bindService(intent, connection, 0)
|
|
||||||
|
|
||||||
GeneratedPluginRegistrant.registerWith(flutterEngine);
|
|
||||||
|
|
||||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->
|
GeneratedPluginRegistrant.registerWith(flutterEngine)
|
||||||
|
|
||||||
|
ui = MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL)
|
||||||
|
ui!!.setMethodCallHandler { call, result ->
|
||||||
when(call.method) {
|
when(call.method) {
|
||||||
"android.requestPermissions" -> androidPermissions(result)
|
"android.registerActiveSite" -> registerActiveSite(result)
|
||||||
|
"android.deviceHasCamera" -> deviceHasCamera(result)
|
||||||
|
|
||||||
"nebula.parseCerts" -> nebulaParseCerts(call, result)
|
"nebula.parseCerts" -> nebulaParseCerts(call, result)
|
||||||
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
|
"nebula.generateKeyPair" -> nebulaGenerateKeyPair(result)
|
||||||
"nebula.renderConfig" -> nebulaRenderConfig(call, result)
|
"nebula.renderConfig" -> nebulaRenderConfig(call, result)
|
||||||
|
"nebula.verifyCertAndKey" -> nebulaVerifyCertAndKey(call, result)
|
||||||
|
|
||||||
|
"dn.enroll" -> dnEnroll(call, result)
|
||||||
|
|
||||||
"listSites" -> listSites(result)
|
"listSites" -> listSites(result)
|
||||||
"deleteSite" -> deleteSite(call, result)
|
"deleteSite" -> deleteSite(call, result)
|
||||||
|
@ -65,14 +84,52 @@ class MainActivity: FlutterActivity() {
|
||||||
"active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result)
|
"active.setRemoteForTunnel" -> activeSetRemoteForTunnel(call, result)
|
||||||
"active.closeTunnel" -> activeCloseTunnel(call, result)
|
"active.closeTunnel" -> activeCloseTunnel(call, result)
|
||||||
|
|
||||||
"share" -> Share.share(call, result)
|
"debug.clearKeys" -> {
|
||||||
"shareFile" -> Share.shareFile(call, result)
|
EncFile(context).resetMasterKey()
|
||||||
|
}
|
||||||
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
apiClient = APIClient(context)
|
||||||
|
|
||||||
|
registerReceiver(refreshReceiver, IntentFilter(ACTION_REFRESH_SITES))
|
||||||
|
|
||||||
|
enqueueDNUpdater()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
|
||||||
|
unregisterReceiver(refreshReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enqueueDNUpdater() {
|
||||||
|
val workRequest = PeriodicWorkRequestBuilder<DNUpdateWorker>(15, TimeUnit.MINUTES).build()
|
||||||
|
workManager.enqueueUniquePeriodicWork(
|
||||||
|
UPDATE_WORKER,
|
||||||
|
ExistingPeriodicWorkPolicy.KEEP,
|
||||||
|
workRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is called by the UI _after_ it has finished rendering the site list to avoid a race condition with detecting
|
||||||
|
// the current active site and attaching site specific event channels in the event the UI app was quit
|
||||||
|
private fun registerActiveSite(result: MethodChannel.Result) {
|
||||||
|
// Bind against our service to detect which site is running on app boot
|
||||||
|
val intent = Intent(this, NebulaVpnService::class.java)
|
||||||
|
bindService(intent, connection, 0)
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deviceHasCamera(result: MethodChannel.Result) {
|
||||||
|
result.success(context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY))
|
||||||
|
}
|
||||||
|
|
||||||
private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) {
|
private fun nebulaParseCerts(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val certs = call.argument<String>("certs")
|
val certs = call.argument<String>("certs")
|
||||||
if (certs == "") {
|
if (certs == "") {
|
||||||
|
@ -98,6 +155,47 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(yaml)
|
return result.success(yaml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun nebulaVerifyCertAndKey(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val cert = call.argument<String>("cert")
|
||||||
|
if (cert == "") {
|
||||||
|
return result.error("required_argument", "cert is a required argument", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val key = call.argument<String>("key")
|
||||||
|
if (key == "") {
|
||||||
|
return result.error("required_argument", "key is a required argument", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val json = mobileNebula.MobileNebula.verifyCertAndKey(cert, key)
|
||||||
|
result.success(json)
|
||||||
|
} catch (err: Exception) {
|
||||||
|
result.error("unhandled_error", err.message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dnEnroll(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
val code = call.arguments as String
|
||||||
|
if (code == "") {
|
||||||
|
return result.error("required_argument", "code is a required argument", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
val site: IncomingSite
|
||||||
|
val siteDir: File
|
||||||
|
try {
|
||||||
|
site = apiClient!!.enroll(code)
|
||||||
|
siteDir = site.save(context)
|
||||||
|
} catch (err: Exception) {
|
||||||
|
return result.error("unhandled_error", err.message, null)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validateOrDeleteSite(siteDir)) {
|
||||||
|
return result.error("failure", "Enrollment failed due to invalid config", null)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.success(null)
|
||||||
|
}
|
||||||
|
|
||||||
private fun listSites(result: MethodChannel.Result) {
|
private fun listSites(result: MethodChannel.Result) {
|
||||||
sites!!.refreshSites(activeSiteId)
|
sites!!.refreshSites(activeSiteId)
|
||||||
val sites = sites!!.getSites()
|
val sites = sites!!.getSites()
|
||||||
|
@ -117,68 +215,66 @@ class MainActivity: FlutterActivity() {
|
||||||
|
|
||||||
private fun saveSite(call: MethodCall, result: MethodChannel.Result) {
|
private fun saveSite(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val site: IncomingSite
|
val site: IncomingSite
|
||||||
|
val siteDir: File
|
||||||
try {
|
try {
|
||||||
val gson = Gson()
|
val gson = Gson()
|
||||||
site = gson.fromJson(call.arguments as String, IncomingSite::class.java)
|
site = gson.fromJson(call.arguments as String, IncomingSite::class.java)
|
||||||
site.save(context)
|
siteDir = site.save(context)
|
||||||
|
|
||||||
} catch (err: Exception) {
|
} catch (err: Exception) {
|
||||||
//TODO: is toString the best or .message?
|
//TODO: is toString the best or .message?
|
||||||
return result.error("failure", err.toString(), null)
|
return result.error("failure", err.toString(), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
val siteDir = context.filesDir.resolve("sites").resolve(site.id)
|
if (!validateOrDeleteSite(siteDir)) {
|
||||||
try {
|
|
||||||
// Try to render a full site, if this fails the config was bad somehow
|
|
||||||
Site(siteDir)
|
|
||||||
} catch (err: Exception) {
|
|
||||||
siteDir.deleteRecursively()
|
|
||||||
return result.error("failure", "Site config was incomplete, please review and try again", null)
|
return result.error("failure", "Site config was incomplete, please review and try again", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sites?.refreshSites()
|
||||||
|
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun validateOrDeleteSite(siteDir: File): Boolean {
|
||||||
|
try {
|
||||||
|
// Try to render a full site, if this fails the config was bad somehow
|
||||||
|
Site(context, siteDir)
|
||||||
|
} catch(err: java.io.FileNotFoundException) {
|
||||||
|
Log.e(TAG, "Site not found at $siteDir")
|
||||||
|
return false
|
||||||
|
} catch(err: Exception) {
|
||||||
|
Log.e(TAG, "Deleting site at $siteDir due to error: $err")
|
||||||
|
siteDir.deleteRecursively()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
private fun startSite(call: MethodCall, result: MethodChannel.Result) {
|
private fun startSite(call: MethodCall, result: MethodChannel.Result) {
|
||||||
val id = call.argument<String>("id")
|
val id = call.argument<String>("id")
|
||||||
if (id == "") {
|
if (id == "") {
|
||||||
return result.error("required_argument", "id is a required argument", null)
|
return result.error("required_argument", "id is a required argument", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var siteContainer: SiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null)
|
startingSiteContainer = sites!!.getSite(id!!) ?: return result.error("unknown_site", "No site with that id exists", null)
|
||||||
|
startingSiteContainer!!.updater.setState(true, "Initializing...")
|
||||||
siteContainer.site.connected = true
|
|
||||||
siteContainer.site.status = "Initializing..."
|
|
||||||
|
|
||||||
|
startResult = result
|
||||||
val intent = VpnService.prepare(this)
|
val intent = VpnService.prepare(this)
|
||||||
if (intent != null) {
|
if (intent != null) {
|
||||||
//TODO: ensure this boots the correct bit, I bet it doesn't and we need to go back to the active symlink
|
|
||||||
intent.putExtra("path", siteContainer.site.path)
|
|
||||||
intent.putExtra("id", siteContainer.site.id)
|
|
||||||
startActivityForResult(intent, VPN_START_CODE)
|
startActivityForResult(intent, VPN_START_CODE)
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
val intent = Intent(this, NebulaVpnService::class.java)
|
onActivityResult(VPN_START_CODE, Activity.RESULT_OK, null)
|
||||||
intent.putExtra("path", siteContainer.site.path)
|
|
||||||
intent.putExtra("id", siteContainer.site.id)
|
|
||||||
onActivityResult(VPN_START_CODE, Activity.RESULT_OK, intent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
result.success(null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopSite() {
|
private fun stopSite() {
|
||||||
val intent = Intent(this, NebulaVpnService::class.java)
|
val intent = Intent(this, NebulaVpnService::class.java).apply {
|
||||||
intent.putExtra("COMMAND", "STOP")
|
action = NebulaVpnService.ACTION_STOP
|
||||||
|
}
|
||||||
|
|
||||||
//This is odd but stopService goes nowhere in my tests and this is correct
|
// We can't stopService because we have to close the fd first. The service will call stopSelf when ready.
|
||||||
// according to the official example https://android.googlesource.com/platform/development/+/master/samples/ToyVpn/src/com/example/android/toyvpn/ToyVpnClient.java#116
|
// See the official example: https://android.googlesource.com/platform/development/+/master/samples/ToyVpn/src/com/example/android/toyvpn/ToyVpnClient.java#116
|
||||||
startService(intent)
|
startService(intent)
|
||||||
//TODO: why doesn't this work!?!?
|
|
||||||
// if (serviceIntent != null) {
|
|
||||||
// Log.e(TAG, "stopping ${serviceIntent.toString()}")
|
|
||||||
// stopService(serviceIntent)
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun activeListHostmap(call: MethodCall, result: MethodChannel.Result) {
|
private fun activeListHostmap(call: MethodCall, result: MethodChannel.Result) {
|
||||||
|
@ -191,9 +287,9 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_LIST_HOSTMAP
|
msg.what = NebulaVpnService.MSG_LIST_HOSTMAP
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -211,9 +307,9 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP
|
msg.what = NebulaVpnService.MSG_LIST_PENDING_HOSTMAP
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -233,16 +329,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_GET_HOSTINFO
|
msg.what = NebulaVpnService.MSG_GET_HOSTINFO
|
||||||
msg.data.putString("vpnIp", vpnIp)
|
msg.data.putString("vpnIp", vpnIp)
|
||||||
msg.data.putBoolean("pending", pending)
|
msg.data.putBoolean("pending", pending)
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
|
@ -262,7 +358,7 @@ class MainActivity: FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val addr = call.argument<String>("addr")
|
val addr = call.argument<String>("addr")
|
||||||
if (vpnIp == "") {
|
if (addr == "") {
|
||||||
return result.error("required_argument", "addr is a required argument", null)
|
return result.error("required_argument", "addr is a required argument", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -270,18 +366,18 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_SET_REMOTE_FOR_TUNNEL
|
msg.what = NebulaVpnService.MSG_SET_REMOTE_FOR_TUNNEL
|
||||||
msg.data.putString("vpnIp", vpnIp)
|
msg.data.putString("vpnIp", vpnIp)
|
||||||
msg.data.putString("addr", addr)
|
msg.data.putString("addr", addr)
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getString("data"))
|
result.success(msg.data.getString("data"))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
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 == "") {
|
||||||
|
@ -297,10 +393,10 @@ class MainActivity: FlutterActivity() {
|
||||||
return result.success(null)
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
var msg = Message.obtain()
|
val msg = Message.obtain()
|
||||||
msg.what = NebulaVpnService.MSG_CLOSE_TUNNEL
|
msg.what = NebulaVpnService.MSG_CLOSE_TUNNEL
|
||||||
msg.data.putString("vpnIp", vpnIp)
|
msg.data.putString("vpnIp", vpnIp)
|
||||||
msg.replyTo = Messenger(object: Handler() {
|
msg.replyTo = Messenger(object: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
result.success(msg.data.getBoolean("data"))
|
result.success(msg.data.getBoolean("data"))
|
||||||
}
|
}
|
||||||
|
@ -308,52 +404,48 @@ class MainActivity: FlutterActivity() {
|
||||||
outMessenger?.send(msg)
|
outMessenger?.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun androidPermissions(result: MethodChannel.Result) {
|
|
||||||
val intent = VpnService.prepare(this)
|
|
||||||
if (intent != null) {
|
|
||||||
permResult = result
|
|
||||||
return startActivityForResult(intent, VPN_PERMISSIONS_CODE)
|
|
||||||
}
|
|
||||||
|
|
||||||
// We already have the permission
|
|
||||||
result.success(null)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||||
// This is where activity results come back to us (startActivityForResult)
|
// This is where activity results come back to us (startActivityForResult)
|
||||||
if (requestCode == VPN_PERMISSIONS_CODE && permResult != null) {
|
if (requestCode == VPN_START_CODE) {
|
||||||
// We are processing a response for vpn permissions and the UI is waiting for feedback
|
// If we are processing a result for VPN permissions and don't get them, let the UI know
|
||||||
//TODO: unlikely we ever register multiple attempts but this could be a trouble spot if we did
|
val result = startResult!!
|
||||||
val result = permResult!!
|
val siteContainer = startingSiteContainer!!
|
||||||
permResult = null
|
startResult = null
|
||||||
if (resultCode == Activity.RESULT_OK) {
|
startingSiteContainer = null
|
||||||
return result.success(null)
|
if (resultCode != Activity.RESULT_OK) {
|
||||||
|
// The user did not grant permissions
|
||||||
|
siteContainer.updater.setState(false, "Disconnected")
|
||||||
|
return result.error("permissions", "Please grant VPN permissions to the app when requested. (If another VPN is running, please disable it now.)", null)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result.error("denied", "User did not grant permission", null)
|
// Start the VPN service
|
||||||
|
val intent = Intent(this, NebulaVpnService::class.java).apply {
|
||||||
} else if (requestCode == VPN_START_CODE) {
|
putExtra("path", siteContainer.site.path)
|
||||||
// We are processing a response for permissions while starting the VPN (or reusing code in the event we already have perms)
|
putExtra("id", siteContainer.site.id)
|
||||||
startService(data)
|
}
|
||||||
|
startService(intent)
|
||||||
if (outMessenger == null) {
|
if (outMessenger == null) {
|
||||||
bindService(data, connection, 0)
|
bindService(intent, connection, 0)
|
||||||
}
|
}
|
||||||
return
|
|
||||||
|
return result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
// The file picker needs us to super
|
// The file picker needs us to super
|
||||||
super.onActivityResult(requestCode, resultCode, data)
|
super.onActivityResult(requestCode, resultCode, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/** Defines callbacks for service binding, passed to bindService() */
|
/** Defines callbacks for service binding, passed to bindService() */
|
||||||
val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
override fun onServiceConnected(className: ComponentName, service: IBinder) {
|
||||||
outMessenger = Messenger(service)
|
outMessenger = Messenger(service)
|
||||||
|
|
||||||
// We want to monitor the service for as long as we are connected to it.
|
// We want to monitor the service for as long as we are connected to it.
|
||||||
try {
|
try {
|
||||||
val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT)
|
val msg = Message.obtain(null, NebulaVpnService.MSG_REGISTER_CLIENT)
|
||||||
msg.replyTo = inMessenger
|
msg.replyTo = inMessenger
|
||||||
outMessenger?.send(msg)
|
outMessenger!!.send(msg)
|
||||||
|
|
||||||
} catch (e: RemoteException) {
|
} catch (e: RemoteException) {
|
||||||
// In this case the service has crashed before we could even
|
// In this case the service has crashed before we could even
|
||||||
|
@ -364,7 +456,7 @@ class MainActivity: FlutterActivity() {
|
||||||
}
|
}
|
||||||
|
|
||||||
val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING)
|
val msg = Message.obtain(null, NebulaVpnService.MSG_IS_RUNNING)
|
||||||
outMessenger?.send(msg)
|
outMessenger!!.send(msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onServiceDisconnected(arg0: ComponentName) {
|
override fun onServiceDisconnected(arg0: ComponentName) {
|
||||||
|
@ -377,7 +469,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() {
|
inner class IncomingHandler: Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
val id = msg.data.getString("id")
|
val id = msg.data.getString("id")
|
||||||
|
|
||||||
|
@ -394,7 +486,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
|
||||||
|
@ -407,6 +499,32 @@ class MainActivity: FlutterActivity() {
|
||||||
private fun serviceExited(site: SiteContainer, msg: Message) {
|
private fun serviceExited(site: SiteContainer, msg: Message) {
|
||||||
activeSiteId = null
|
activeSiteId = null
|
||||||
site.updater.setState(false, "Disconnected", msg.data.getString("error"))
|
site.updater.setState(false, "Disconnected", msg.data.getString("error"))
|
||||||
|
unbindVpnService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun unbindVpnService() {
|
||||||
|
if (outMessenger != null) {
|
||||||
|
// Unregister ourselves
|
||||||
|
val msg = Message.obtain(null, NebulaVpnService.MSG_UNREGISTER_CLIENT)
|
||||||
|
msg.replyTo = inMessenger
|
||||||
|
outMessenger!!.send(msg)
|
||||||
|
// Unbind
|
||||||
|
unbindService(connection)
|
||||||
|
}
|
||||||
|
outMessenger = null
|
||||||
|
}
|
||||||
|
|
||||||
|
inner class RefreshReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != ACTION_REFRESH_SITES) return
|
||||||
|
if (sites == null) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Refreshing sites in MainActivity")
|
||||||
|
|
||||||
|
sites?.refreshSites(activeSiteId)
|
||||||
|
ui?.invokeMethod("refreshSites", null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,19 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import io.flutter.embedding.engine.loader.FlutterLoader
|
||||||
|
import android.app.Application
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
|
||||||
|
class MyApplication : Application() {
|
||||||
|
override fun onCreate() {
|
||||||
|
super.onCreate()
|
||||||
|
|
||||||
|
// In order to use the WorkManager from the nebulaVpnBg process (i.e. NebulaVpnService)
|
||||||
|
// we must explicitly initialize this rather than using the default initializer.
|
||||||
|
val myConfig = Configuration.Builder().build()
|
||||||
|
WorkManager.initialize(this, myConfig)
|
||||||
|
|
||||||
|
FlutterLoader().startInitialization(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
|
@ -5,10 +5,12 @@ 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.annotation.RequiresApi
|
import androidx.work.*
|
||||||
import mobileNebula.CIDR
|
import mobileNebula.CIDR
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
|
@ -16,7 +18,11 @@ import java.io.File
|
||||||
class NebulaVpnService : VpnService() {
|
class NebulaVpnService : VpnService() {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "NebulaVpnService"
|
const val TAG = "NebulaVpnService"
|
||||||
|
|
||||||
|
const val ACTION_STOP = "net.defined.mobile_nebula.STOP"
|
||||||
|
const val ACTION_RELOAD = "net.defined.mobile_nebula.RELOAD"
|
||||||
|
|
||||||
const val MSG_REGISTER_CLIENT = 1
|
const val MSG_REGISTER_CLIENT = 1
|
||||||
const val MSG_UNREGISTER_CLIENT = 2
|
const val MSG_UNREGISTER_CLIENT = 2
|
||||||
const val MSG_IS_RUNNING = 3
|
const val MSG_IS_RUNNING = 3
|
||||||
|
@ -34,47 +40,66 @@ 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?.getStringExtra("COMMAND") == "STOP") {
|
if (intent?.action == ACTION_STOP) {
|
||||||
stopVpn()
|
stopVpn()
|
||||||
return Service.START_NOT_STICKY
|
return Service.START_NOT_STICKY
|
||||||
}
|
}
|
||||||
|
|
||||||
val path = intent?.getStringExtra("path")
|
|
||||||
val id = intent?.getStringExtra("id")
|
val id = intent?.getStringExtra("id")
|
||||||
|
|
||||||
if (running) {
|
if (running) {
|
||||||
announceExit(id, "Trying to run nebula but it is already running")
|
// if the UI triggers this twice, check if we are already running the requested site. if not, return an error.
|
||||||
|
// otherwise, just ignore the request since we handled it the first time.
|
||||||
|
if (site!!.id != id) {
|
||||||
|
announceExit(id, "Trying to run nebula but it is already running")
|
||||||
|
}
|
||||||
|
|
||||||
//TODO: can we signal failure?
|
//TODO: can we signal failure?
|
||||||
return super.onStartCommand(intent, flags, startId)
|
return super.onStartCommand(intent, flags, startId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
path = intent!!.getStringExtra("path")!!
|
||||||
//TODO: if we fail to start, android will attempt a restart lacking all the intent data we need.
|
//TODO: if we fail to start, android will attempt a restart lacking all the intent data we need.
|
||||||
// Link active site config in Main to avoid this
|
// Link active site config in Main to avoid this
|
||||||
site = Site(File(path))
|
site = Site(this, File(path!!))
|
||||||
|
|
||||||
if (site!!.certInfos == null || site!!.certInfos.isEmpty()) {
|
if (site!!.cert == null) {
|
||||||
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() {
|
||||||
var ipNet: CIDR
|
val ipNet: CIDR
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.primaryCertInfo!!.cert.details.ips[0])
|
ipNet = mobileNebula.MobileNebula.parseCIDR(site!!.cert!!.cert.details.ips[0])
|
||||||
} catch (err: Exception) {
|
} catch (err: Exception) {
|
||||||
return announceExit(site!!.id, err.message ?: "$err")
|
return announceExit(site!!.id, err.message ?: "$err")
|
||||||
}
|
}
|
||||||
|
@ -84,21 +109,36 @@ class NebulaVpnService : VpnService() {
|
||||||
.addRoute(ipNet.network, ipNet.maskSize.toInt())
|
.addRoute(ipNet.network, ipNet.maskSize.toInt())
|
||||||
.setMtu(site!!.mtu)
|
.setMtu(site!!.mtu)
|
||||||
.setSession(TAG)
|
.setSession(TAG)
|
||||||
|
.allowFamily(OsConstants.AF_INET)
|
||||||
|
.allowFamily(OsConstants.AF_INET6)
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
builder.setMetered(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disallow some common, known-problematic apps
|
||||||
|
// TODO Make this user configurable
|
||||||
|
// Android Auto Wireless (https://github.com/DefinedNet/mobile_nebula/issues/102)
|
||||||
|
disallowApp(builder, "com.google.android.projection.gearhead")
|
||||||
|
// Chromecast (https://github.com/DefinedNet/mobile_nebula/issues/102)
|
||||||
|
disallowApp(builder, "com.google.android.apps.chromecast.app")
|
||||||
|
// RCS / Jibe
|
||||||
|
disallowApp(builder, "com.google.android.apps.messaging")
|
||||||
|
|
||||||
// Add our unsafe routes
|
// Add our unsafe routes
|
||||||
site!!.unsafeRoutes.forEach { unsafeRoute ->
|
site!!.unsafeRoutes.forEach { unsafeRoute ->
|
||||||
val ipNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route)
|
val unsafeIPNet = mobileNebula.MobileNebula.parseCIDR(unsafeRoute.route)
|
||||||
builder.addRoute(ipNet.network, ipNet.maskSize.toInt())
|
builder.addRoute(unsafeIPNet.network, unsafeIPNet.maskSize.toInt())
|
||||||
}
|
}
|
||||||
|
|
||||||
val cm = getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
|
// Add our DNS resolvers
|
||||||
cm.allNetworks.forEach { network ->
|
site!!.dnsResolvers.forEach { dnsResolver ->
|
||||||
cm.getLinkProperties(network).dnsServers.forEach { builder.addDnsServer(it) }
|
builder.addDnsServer(dnsResolver)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
vpnInterface = builder.establish()
|
vpnInterface = builder.establish()
|
||||||
nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.fd.toLong())
|
nebula = mobileNebula.MobileNebula.newNebula(site!!.config, site!!.getKey(this), site!!.logFile, vpnInterface!!.detachFd().toLong())
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Got an error $e")
|
Log.e(TAG, "Got an error $e")
|
||||||
|
@ -108,14 +148,24 @@ 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, if (running) 1 else 0)
|
sendSimple(MSG_IS_RUNNING, 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun disallowApp(builder: Builder, name: String) {
|
||||||
|
try {
|
||||||
|
builder.addDisallowedApplication(name)
|
||||||
|
} catch (e: PackageManager.NameNotFoundException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// Used to detect network changes (wifi -> cell or vice versa) and rebinds the udp socket/updates LH
|
// 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
|
||||||
|
@ -123,20 +173,27 @@ 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(),
|
connectivityManager.registerNetworkCallback(builder.build(), networkCallback)
|
||||||
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?) {
|
||||||
|
@ -157,14 +214,40 @@ class NebulaVpnService : VpnService() {
|
||||||
registerReceiver(receiver, IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED))
|
registerReceiver(receiver, IntentFilter(PowerManager.ACTION_DEVICE_IDLE_MODE_CHANGED))
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun stopVpn() {
|
private fun registerReloadReceiver() {
|
||||||
nebula?.stop()
|
registerReceiver(reloadReceiver, IntentFilter(ACTION_RELOAD))
|
||||||
vpnInterface?.close()
|
|
||||||
running = false
|
|
||||||
announceExit(site?.id, null)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDestroy() {
|
private fun unregisterReloadReceiver() {
|
||||||
|
unregisterReceiver(reloadReceiver)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reload() {
|
||||||
|
site = Site(this, File(path!!))
|
||||||
|
nebula?.reload(site!!.config, site!!.getKey(this))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopVpn() {
|
||||||
|
if (nebula == null) {
|
||||||
|
return stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
unregisterNetworkCallback()
|
||||||
|
unregisterReloadReceiver()
|
||||||
|
nebula?.stop()
|
||||||
|
nebula = null
|
||||||
|
running = false
|
||||||
|
announceExit(site?.id, null)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRevoke() {
|
||||||
|
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()
|
||||||
|
@ -179,10 +262,22 @@ class NebulaVpnService : VpnService() {
|
||||||
send(msg, id)
|
send(msg, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
inner class ReloadReceiver : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent?) {
|
||||||
|
if (intent?.action != ACTION_RELOAD) return
|
||||||
|
if (!running) return
|
||||||
|
if (intent.getStringExtra("id") != site!!.id) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Reloading Nebula")
|
||||||
|
|
||||||
|
reload()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handler of incoming messages from clients.
|
* Handler of incoming messages from clients.
|
||||||
*/
|
*/
|
||||||
inner class IncomingHandler(context: Context, private val applicationContext: Context = context.applicationContext) : Handler() {
|
inner class IncomingHandler : Handler(Looper.getMainLooper()) {
|
||||||
override fun handleMessage(msg: Message) {
|
override fun handleMessage(msg: Message) {
|
||||||
//TODO: how do we limit what can talk to us?
|
//TODO: how do we limit what can talk to us?
|
||||||
//TODO: Make sure replyTo is actually a messenger
|
//TODO: Make sure replyTo is actually a messenger
|
||||||
|
@ -223,16 +318,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)
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putString("data", res)
|
m.data.putString("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
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"))
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putString("data", res)
|
m.data.putString("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
@ -241,16 +336,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"))
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putString("data", res)
|
m.data.putString("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
|
||||||
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"))
|
||||||
var m = Message.obtain(null, msg.what)
|
val m = Message.obtain(null, msg.what)
|
||||||
m.data.putBoolean("data", res)
|
m.data.putBoolean("data", res)
|
||||||
msg.replyTo.send(m)
|
msg.replyTo.send(m)
|
||||||
}
|
}
|
||||||
|
@ -284,7 +379,7 @@ class NebulaVpnService : VpnService() {
|
||||||
return super.onBind(intent)
|
return super.onBind(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
messenger = Messenger(IncomingHandler(this))
|
messenger = Messenger(IncomingHandler())
|
||||||
return messenger.binder
|
return messenger.binder
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package net.defined.mobile_nebula
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.pm.ApplicationInfo
|
||||||
|
import android.content.pm.PackageInfo
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
class PackageInfo(private val context: Context) {
|
||||||
|
private val pInfo: PackageInfo =
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, PackageManager.PackageInfoFlags.of(0))
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.packageManager.getPackageInfo(context.packageName, 0)
|
||||||
|
|
||||||
|
private val appInfo: ApplicationInfo = context.applicationInfo
|
||||||
|
|
||||||
|
fun getVersion(): String {
|
||||||
|
val version: String = pInfo.versionName
|
||||||
|
val build: Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P)
|
||||||
|
pInfo.longVersionCode
|
||||||
|
else
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
pInfo.versionCode.toLong()
|
||||||
|
return "%s-%d".format(version, build)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getName(): String {
|
||||||
|
val stringId = appInfo.labelRes
|
||||||
|
return if (stringId == 0) appInfo.nonLocalizedLabel.toString() else context.getString(stringId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSystemVersion(): String {
|
||||||
|
return Build.VERSION.RELEASE
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,134 +0,0 @@
|
||||||
package net.defined.mobile_nebula
|
|
||||||
|
|
||||||
import android.app.PendingIntent
|
|
||||||
import android.content.*
|
|
||||||
import android.content.pm.PackageManager
|
|
||||||
import android.content.pm.ResolveInfo
|
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.content.FileProvider
|
|
||||||
import io.flutter.plugin.common.MethodCall
|
|
||||||
import io.flutter.plugin.common.MethodChannel
|
|
||||||
import java.io.File
|
|
||||||
|
|
||||||
class Share {
|
|
||||||
companion object {
|
|
||||||
fun share(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val title = call.argument<String>("title")
|
|
||||||
val text = call.argument<String>("text")
|
|
||||||
val filename = call.argument<String>("filename")
|
|
||||||
|
|
||||||
if (filename == null || filename.isEmpty()) {
|
|
||||||
return result.error("filename was not provided", null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = MainActivity!!.getContext()!!
|
|
||||||
val cacheDir = context.cacheDir.resolve("share")
|
|
||||||
cacheDir.deleteRecursively()
|
|
||||||
cacheDir.mkdir()
|
|
||||||
val newFile = cacheDir.resolve(filename!!)
|
|
||||||
newFile.delete()
|
|
||||||
newFile.writeText(text ?: "")
|
|
||||||
pop(title, newFile, result)
|
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
Log.println(Log.ERROR, "", "Share: Error")
|
|
||||||
result.error(err.message, null, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun shareFile(call: MethodCall, result: MethodChannel.Result) {
|
|
||||||
val title = call.argument<String>("title")
|
|
||||||
val filename = call.argument<String>("filename")
|
|
||||||
val filePath = call.argument<String>("filePath")
|
|
||||||
|
|
||||||
if (filename == null || filename.isEmpty()) {
|
|
||||||
result.error("filename was not provided", null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filePath == null || filePath.isEmpty()) {
|
|
||||||
result.error("filePath was not provided", null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val file = File(filePath)
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = MainActivity!!.getContext()!!
|
|
||||||
val cacheDir = context.cacheDir.resolve("share")
|
|
||||||
cacheDir.deleteRecursively()
|
|
||||||
cacheDir.mkdir()
|
|
||||||
val newFile = cacheDir.resolve(filename!!)
|
|
||||||
newFile.delete()
|
|
||||||
file.copyTo(newFile)
|
|
||||||
|
|
||||||
pop(title, newFile, result)
|
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
Log.println(Log.ERROR, "", "Share: Error")
|
|
||||||
result.error(err.message, null, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun pop(title: String?, file: File, result: MethodChannel.Result) {
|
|
||||||
if (title == null || title.isEmpty()) {
|
|
||||||
result.error("title was not provided", null, null)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
val context = MainActivity!!.getContext()!!
|
|
||||||
|
|
||||||
val fileUri = FileProvider.getUriForFile(context, context.applicationContext.packageName + ".provider", file)
|
|
||||||
val intent = Intent()
|
|
||||||
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
||||||
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
|
|
||||||
intent.action = Intent.ACTION_SEND
|
|
||||||
intent.type = "text/*"
|
|
||||||
|
|
||||||
intent.putExtra(Intent.EXTRA_SUBJECT, title)
|
|
||||||
intent.putExtra(Intent.EXTRA_STREAM, fileUri)
|
|
||||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
|
|
||||||
val receiver = Intent(context, ShareReceiver::class.java)
|
|
||||||
receiver.putExtra(Intent.EXTRA_TEXT, file)
|
|
||||||
val pendingIntent = PendingIntent.getBroadcast(context, 0, receiver, PendingIntent.FLAG_UPDATE_CURRENT)
|
|
||||||
|
|
||||||
val chooserIntent = Intent.createChooser(intent, title, pendingIntent.intentSender)
|
|
||||||
val resInfoList: List<ResolveInfo> = context.packageManager.queryIntentActivities(chooserIntent, PackageManager.MATCH_DEFAULT_ONLY)
|
|
||||||
for (resolveInfo in resInfoList) {
|
|
||||||
val packageName: String = resolveInfo.activityInfo.packageName
|
|
||||||
context.grantUriPermission(packageName, fileUri, Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
|
||||||
}
|
|
||||||
|
|
||||||
context.startActivity(chooserIntent)
|
|
||||||
|
|
||||||
} catch (err: Exception) {
|
|
||||||
Log.println(Log.ERROR, "", "Share: Error")
|
|
||||||
return result.error(err.message, null, null)
|
|
||||||
}
|
|
||||||
|
|
||||||
result.success(true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShareReceiver : BroadcastReceiver() {
|
|
||||||
override fun onReceive(context: Context?, intent: Intent?) {
|
|
||||||
if (intent == null) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val res = intent.extras!!.get(Intent.EXTRA_CHOSEN_COMPONENT) as? ComponentName ?: return
|
|
||||||
when (res.className) {
|
|
||||||
"org.chromium.arc.intent_helper.SendTextToClipboardActivity" -> {
|
|
||||||
val file = intent.extras!![Intent.EXTRA_TEXT] as? File ?: return
|
|
||||||
val clipboard = context?.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
|
||||||
|
|
||||||
clipboard.setPrimaryClip(ClipData.newPlainText("", file.readText()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -3,7 +3,6 @@ package net.defined.mobile_nebula
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.annotations.Expose
|
|
||||||
import com.google.gson.annotations.SerializedName
|
import com.google.gson.annotations.SerializedName
|
||||||
import io.flutter.embedding.engine.FlutterEngine
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
import io.flutter.plugin.common.EventChannel
|
import io.flutter.plugin.common.EventChannel
|
||||||
|
@ -16,7 +15,7 @@ data class SiteContainer(
|
||||||
)
|
)
|
||||||
|
|
||||||
class Sites(private var engine: FlutterEngine) {
|
class Sites(private var engine: FlutterEngine) {
|
||||||
private var sites: HashMap<String, SiteContainer> = HashMap()
|
private var containers: HashMap<String, SiteContainer> = HashMap()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
refreshSites()
|
refreshSites()
|
||||||
|
@ -24,64 +23,115 @@ class Sites(private var engine: FlutterEngine) {
|
||||||
|
|
||||||
fun refreshSites(activeSite: String? = null) {
|
fun refreshSites(activeSite: String? = null) {
|
||||||
val context = MainActivity.getContext()!!
|
val context = MainActivity.getContext()!!
|
||||||
val sitesDir = context.filesDir.resolve("sites")
|
|
||||||
if (!sitesDir.isDirectory) {
|
|
||||||
sitesDir.delete()
|
|
||||||
sitesDir.mkdir()
|
|
||||||
}
|
|
||||||
|
|
||||||
sites = HashMap()
|
val sites = SiteList(context)
|
||||||
sitesDir.listFiles().forEach { siteDir ->
|
val containers: HashMap<String, SiteContainer> = HashMap()
|
||||||
try {
|
sites.getSites().values.forEach { site ->
|
||||||
val site = Site(siteDir)
|
// Don't create a new SiteUpdater or we will lose subscribers
|
||||||
|
var updater = this.containers[site.id]?.updater
|
||||||
// Make sure we can load the private key
|
if (updater != null) {
|
||||||
site.getKey(context)
|
updater.setSite(site)
|
||||||
|
} else {
|
||||||
val updater = SiteUpdater(site, engine)
|
updater = SiteUpdater(site, engine)
|
||||||
if (site.id == activeSite) {
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (site.id == activeSite) {
|
||||||
|
updater.setState(true, "Connected")
|
||||||
|
}
|
||||||
|
|
||||||
|
containers[site.id] = SiteContainer(site, updater)
|
||||||
}
|
}
|
||||||
|
this.containers = containers
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSites(): Map<String, Site> {
|
fun getSites(): Map<String, Site> {
|
||||||
return sites.mapValues { it.value.site }
|
return containers.mapValues { it.value.site }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun deleteSite(id: String) {
|
fun deleteSite(id: String) {
|
||||||
sites.remove(id)
|
val context = MainActivity.getContext()!!
|
||||||
val siteDir = MainActivity.getContext()!!.filesDir.resolve("sites").resolve(id)
|
val site = containers[id]!!.site
|
||||||
|
|
||||||
|
val baseDir = if(site.managed) context.noBackupFilesDir else context.filesDir
|
||||||
|
val siteDir = baseDir.resolve("sites").resolve(id)
|
||||||
siteDir.deleteRecursively()
|
siteDir.deleteRecursively()
|
||||||
|
refreshSites()
|
||||||
//TODO: make sure you stop the vpn
|
//TODO: make sure you stop the vpn
|
||||||
//TODO: make sure you relink the active site if this is the active site
|
//TODO: make sure you relink the active site if this is the active site
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getSite(id: String): SiteContainer? {
|
fun getSite(id: String): SiteContainer? {
|
||||||
return sites[id]
|
return containers[id]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SiteList(context: Context) {
|
||||||
|
private var sites: Map<String, Site>
|
||||||
|
|
||||||
|
init {
|
||||||
|
val nebulaSites = getSites(context, context.filesDir)
|
||||||
|
val dnSites = getSites(context, context.noBackupFilesDir)
|
||||||
|
|
||||||
|
// In case of a conflict, dnSites will take precedence.
|
||||||
|
sites = nebulaSites + dnSites
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getSites(): Map<String, Site> {
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun getSites(context: Context, directory: File): HashMap<String, Site> {
|
||||||
|
val sites = HashMap<String, Site>()
|
||||||
|
|
||||||
|
val sitesDir = directory.resolve("sites")
|
||||||
|
|
||||||
|
if (!sitesDir.isDirectory) {
|
||||||
|
sitesDir.delete()
|
||||||
|
sitesDir.mkdir()
|
||||||
|
}
|
||||||
|
|
||||||
|
sitesDir.listFiles()?.forEach { siteDir ->
|
||||||
|
try {
|
||||||
|
val site = Site(context, siteDir)
|
||||||
|
|
||||||
|
// Make sure we can load the private key
|
||||||
|
site.getKey(context)
|
||||||
|
|
||||||
|
// Make sure we can load the DN credentials if managed
|
||||||
|
if (site.managed) {
|
||||||
|
site.getDNCredentials(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
sites[site.id] = site
|
||||||
|
} catch (err: Exception) {
|
||||||
|
siteDir.deleteRecursively()
|
||||||
|
Log.e(TAG, "Deleting non conforming site ${siteDir.absolutePath}", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return sites
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler {
|
class SiteUpdater(private var site: Site, engine: FlutterEngine): EventChannel.StreamHandler {
|
||||||
|
private val gson = Gson()
|
||||||
// eventSink is how we send info back up to flutter
|
// eventSink is how we send info back up to flutter
|
||||||
private var eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}")
|
private var eventChannel: EventChannel = EventChannel(engine.dartExecutor.binaryMessenger, "net.defined.nebula/${site.id}")
|
||||||
private var eventSink: EventChannel.EventSink? = null
|
private var eventSink: EventChannel.EventSink? = null
|
||||||
|
|
||||||
|
fun setSite(site: Site) {
|
||||||
|
this.site = site
|
||||||
|
}
|
||||||
|
|
||||||
fun setState(connected: Boolean, status: String, err: String? = null) {
|
fun setState(connected: Boolean, status: String, err: String? = null) {
|
||||||
site.connected = connected
|
site.connected = connected
|
||||||
site.status = status
|
site.status = status
|
||||||
val d = mapOf("connected" to site.connected, "status" to site.status)
|
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
eventSink?.error("", err, d)
|
eventSink?.error("", err, gson.toJson(site))
|
||||||
} else {
|
} else {
|
||||||
eventSink?.success(d)
|
eventSink?.success(gson.toJson(site))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,10 +151,9 @@ 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(
|
||||||
|
@ -130,36 +179,55 @@ data class CertificateValidity(
|
||||||
@SerializedName("Reason") val reason: String
|
@SerializedName("Reason") val reason: String
|
||||||
)
|
)
|
||||||
|
|
||||||
class Site {
|
data class DNCredentials(
|
||||||
|
val hostID: String,
|
||||||
|
val privateKey: String,
|
||||||
|
val counter: Int,
|
||||||
|
val trustedKeys: String,
|
||||||
|
var invalid: Boolean,
|
||||||
|
) {
|
||||||
|
fun save(context: Context, siteDir: File) {
|
||||||
|
val jsonCreds = Gson().toJson(this)
|
||||||
|
|
||||||
|
val credsFile = siteDir.resolve("dnCredentials")
|
||||||
|
credsFile.delete()
|
||||||
|
|
||||||
|
EncFile(context).openWrite(credsFile).use { it.write(jsonCreds) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Site(context: Context, siteDir: File) {
|
||||||
val name: String
|
val name: String
|
||||||
val id: String
|
val id: String
|
||||||
val staticHostmap: HashMap<String, StaticHosts>
|
val staticHostmap: HashMap<String, StaticHosts>
|
||||||
val unsafeRoutes: List<UnsafeRoute>
|
val unsafeRoutes: List<UnsafeRoute>
|
||||||
val certInfos: ArrayList<CertificateInfo> = ArrayList()
|
val dnsResolvers: List<String>
|
||||||
lateinit var caInfos: Array<CertificateInfo>
|
var cert: CertificateInfo? = null
|
||||||
|
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
|
||||||
var logVerbosity: String
|
val 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
|
||||||
@Expose(serialize = false)
|
@Transient
|
||||||
val path: String
|
val path: String
|
||||||
|
|
||||||
// Strong representation of the site config
|
// Strong representation of the site config
|
||||||
@Expose(serialize = false)
|
@Transient
|
||||||
val config: String
|
val config: String
|
||||||
|
|
||||||
@Expose(serialize = false)
|
init {
|
||||||
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)
|
||||||
|
@ -169,6 +237,7 @@ class Site {
|
||||||
id = incomingSite.id
|
id = incomingSite.id
|
||||||
staticHostmap = incomingSite.staticHostmap
|
staticHostmap = incomingSite.staticHostmap
|
||||||
unsafeRoutes = incomingSite.unsafeRoutes ?: ArrayList()
|
unsafeRoutes = incomingSite.unsafeRoutes ?: ArrayList()
|
||||||
|
dnsResolvers = incomingSite.dnsResolvers ?: ArrayList()
|
||||||
lhDuration = incomingSite.lhDuration
|
lhDuration = incomingSite.lhDuration
|
||||||
port = incomingSite.port
|
port = incomingSite.port
|
||||||
mtu = incomingSite.mtu ?: 1300
|
mtu = incomingSite.mtu ?: 1300
|
||||||
|
@ -176,41 +245,51 @@ class Site {
|
||||||
sortKey = incomingSite.sortKey ?: 0
|
sortKey = incomingSite.sortKey ?: 0
|
||||||
logFile = siteDir.resolve("log").absolutePath
|
logFile = siteDir.resolve("log").absolutePath
|
||||||
logVerbosity = incomingSite.logVerbosity ?: "info"
|
logVerbosity = incomingSite.logVerbosity ?: "info"
|
||||||
|
rawConfig = incomingSite.rawConfig
|
||||||
|
managed = incomingSite.managed ?: false
|
||||||
|
lastManagedUpdate = incomingSite.lastManagedUpdate
|
||||||
|
|
||||||
connected = false
|
connected = false
|
||||||
status = "Disconnected"
|
status = "Disconnected"
|
||||||
|
|
||||||
incomingSite.certs?.forEach { certContainer ->
|
try {
|
||||||
val certInfo = getCertDetails(certContainer.cert, gson) ?: return
|
val rawDetails = mobileNebula.MobileNebula.parseCerts(incomingSite.cert)
|
||||||
certInfo.primary = certContainer.primary
|
val certs = gson.fromJson(rawDetails, Array<CertificateInfo>::class.java)
|
||||||
if (certInfo.primary) {
|
if (certs.isEmpty()) {
|
||||||
this.primaryCertInfo = certInfo
|
throw IllegalArgumentException("No certificate found")
|
||||||
|
}
|
||||||
|
cert = certs[0]
|
||||||
|
if (!cert!!.validity.valid) {
|
||||||
|
errors.add("Certificate is invalid: ${cert!!.validity.reason}")
|
||||||
}
|
}
|
||||||
this.certInfos.add(certInfo)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Upgrade the old cert property if present
|
} catch (err: Exception) {
|
||||||
upgradeCert(incomingSite, gson)
|
errors.add("Error while loading certificate: ${err.message}")
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca)
|
val rawCa = mobileNebula.MobileNebula.parseCerts(incomingSite.ca)
|
||||||
caInfos = gson.fromJson(rawCa, Array<CertificateInfo>::class.java)
|
ca = gson.fromJson(rawCa, Array<CertificateInfo>::class.java)
|
||||||
var hasErrors = false
|
var hasErrors = false
|
||||||
caInfos.forEach {
|
ca.forEach {
|
||||||
if (!it.validity.valid) {
|
if (!it.validity.valid) {
|
||||||
hasErrors = true
|
hasErrors = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hasErrors) {
|
if (hasErrors && !managed) {
|
||||||
errors.add("There are issues with 1 or more ca certificates")
|
errors.add("There are issues with 1 or more ca certificates")
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (err: Exception) {
|
} catch (err: Exception) {
|
||||||
caInfos = arrayOf()
|
ca = 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()!!))
|
||||||
|
@ -220,69 +299,31 @@ class Site {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrades cert -> certs in the stored site config if needed
|
fun getKey(context: Context): String {
|
||||||
private fun upgradeCert(site: IncomingSite, gson: Gson) {
|
val f = EncFile(context).openRead(File(path).resolve("key"))
|
||||||
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(
|
||||||
|
@ -296,51 +337,50 @@ 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>?,
|
||||||
var certs: List<IncomingCert>?,
|
val dnsResolvers: List<String>?,
|
||||||
|
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?,
|
||||||
var logVerbosity: String?,
|
val logVerbosity: String?,
|
||||||
|
var key: String?,
|
||||||
@Deprecated("certs is the new property")
|
val managed: Boolean?,
|
||||||
var cert: String?
|
// The following fields are present when managed = true
|
||||||
|
val lastManagedUpdate: String?,
|
||||||
|
val rawConfig: String?,
|
||||||
|
var dnCredentials: DNCredentials?,
|
||||||
) {
|
) {
|
||||||
|
fun save(context: Context): File {
|
||||||
fun save(context: Context) {
|
// Don't allow backups of DN-managed sites
|
||||||
val siteDir = context.filesDir.resolve("sites").resolve(id)
|
val baseDir = if(managed == true) context.noBackupFilesDir else context.filesDir
|
||||||
|
val siteDir = baseDir.resolve("sites").resolve(id)
|
||||||
if (!siteDir.exists()) {
|
if (!siteDir.exists()) {
|
||||||
siteDir.mkdir()
|
siteDir.mkdir()
|
||||||
}
|
}
|
||||||
|
|
||||||
certs?.forEach { cert ->
|
if (key != null) {
|
||||||
if (cert.key != null) {
|
val keyFile = siteDir.resolve("key")
|
||||||
val f = EncFile(context).openWrite(siteDir.resolve("key.${cert.fingerprint}"))
|
keyFile.delete()
|
||||||
f.use { it.write(cert.key) }
|
val encFile = EncFile(context).openWrite(keyFile)
|
||||||
f.close()
|
encFile.use { it.write(key) }
|
||||||
}
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
package="net.defined.mobile_nebula">
|
|
||||||
<!-- Flutter needs it to communicate with the running application
|
<!-- Flutter needs it to communicate with the running application
|
||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
buildscript {
|
buildscript {
|
||||||
ext.kotlin_version = '1.3.61'
|
ext {
|
||||||
|
workVersion = "2.7.1"
|
||||||
|
kotlinVersion = '1.7.20'
|
||||||
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath 'com.android.tools.build:gradle:4.0.0'
|
classpath 'com.android.tools.build:gradle:7.3.1'
|
||||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
allprojects {
|
allprojects {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
jcenter()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -26,6 +30,6 @@ subprojects {
|
||||||
project.evaluationDependsOn(':app')
|
project.evaluationDependsOn(':app')
|
||||||
}
|
}
|
||||||
|
|
||||||
task clean(type: Delete) {
|
tasks.register("clean", Delete) {
|
||||||
delete rootProject.buildDir
|
delete rootProject.buildDir
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
package_name("net.defined.mobile_nebula")
|
||||||
|
json_key_file(ENV['GOOGLE_PLAY_API_JWT_PATH'])
|
|
@ -0,0 +1,50 @@
|
||||||
|
# This file contains the fastlane.tools configuration
|
||||||
|
# You can find the documentation at https://docs.fastlane.tools
|
||||||
|
#
|
||||||
|
# For a list of all available actions, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/actions
|
||||||
|
#
|
||||||
|
# For a list of all available plugins, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/plugins/available-plugins
|
||||||
|
#
|
||||||
|
|
||||||
|
# Uncomment the line if you want fastlane to automatically update itself
|
||||||
|
# update_fastlane
|
||||||
|
|
||||||
|
default_platform(:android)
|
||||||
|
|
||||||
|
platform :android do
|
||||||
|
lane :release_build_number do
|
||||||
|
nextCode = sprintf("%s", latest_googleplay_version_code + 1)
|
||||||
|
File.write("../../release_build_number", nextCode)
|
||||||
|
end
|
||||||
|
|
||||||
|
desc "Deploy a new version to the Google Play"
|
||||||
|
lane :release do
|
||||||
|
upload_to_play_store(
|
||||||
|
track: 'internal',
|
||||||
|
aab: '../build/app/outputs/bundle/release/app-release.aab'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def latest_googleplay_version_code
|
||||||
|
productionVersionCodes = google_play_track_version_codes(track: 'production')
|
||||||
|
#NOTE: we do not have a beta track right now
|
||||||
|
#betaVersionCodes = google_play_track_version_codes(track: 'beta')
|
||||||
|
alphaVersionCodes = google_play_track_version_codes(track: 'alpha')
|
||||||
|
internalVersionCodes = google_play_track_version_codes(track: 'internal')
|
||||||
|
|
||||||
|
# puts version codes from all tracks into the same array
|
||||||
|
versionCodes = [
|
||||||
|
productionVersionCodes,
|
||||||
|
#betaVersionCodes,
|
||||||
|
alphaVersionCodes,
|
||||||
|
internalVersionCodes
|
||||||
|
].reduce([], :concat)
|
||||||
|
|
||||||
|
# returns the highest version code from array
|
||||||
|
return versionCodes.max
|
||||||
|
end
|
|
@ -0,0 +1,40 @@
|
||||||
|
fastlane documentation
|
||||||
|
----
|
||||||
|
|
||||||
|
# Installation
|
||||||
|
|
||||||
|
Make sure you have the latest version of the Xcode command line tools installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
For _fastlane_ installation instructions, see [Installing _fastlane_](https://docs.fastlane.tools/#installing-fastlane)
|
||||||
|
|
||||||
|
# Available Actions
|
||||||
|
|
||||||
|
## Android
|
||||||
|
|
||||||
|
### android release_build_number
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android release_build_number
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
### android release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
[bundle exec] fastlane android release
|
||||||
|
```
|
||||||
|
|
||||||
|
Deploy a new version to the Google Play
|
||||||
|
|
||||||
|
----
|
||||||
|
|
||||||
|
This README.md is auto-generated and will be re-generated every time [_fastlane_](https://fastlane.tools) is run.
|
||||||
|
|
||||||
|
More information about _fastlane_ can be found on [fastlane.tools](https://fastlane.tools).
|
||||||
|
|
||||||
|
The documentation of _fastlane_ can be found on [docs.fastlane.tools](https://docs.fastlane.tools).
|
|
@ -1,4 +1,3 @@
|
||||||
org.gradle.jvmargs=-Xmx1536M
|
org.gradle.jvmargs=-Xmx1536M
|
||||||
android.enableR8=true
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.enableJetifier=true
|
android.enableJetifier=true
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
#Fri Jun 05 14:55:48 CDT 2020
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-all.zip
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
configurations.maybeCreate("default")
|
||||||
|
exec {
|
||||||
|
workingDir '../../'
|
||||||
|
commandLine './gen-artifacts.sh', 'android'
|
||||||
|
}
|
||||||
|
artifacts.add("default", file('mobileNebula.aar'))
|
|
@ -1,15 +1,11 @@
|
||||||
include ':app'
|
include ':app', ':mobileNebula'
|
||||||
|
|
||||||
def flutterProjectRoot = rootProject.projectDir.parentFile.toPath()
|
def localPropertiesFile = new File(rootProject.projectDir, "local.properties")
|
||||||
|
def properties = new Properties()
|
||||||
|
|
||||||
def plugins = new Properties()
|
assert localPropertiesFile.exists()
|
||||||
def pluginsFile = new File(flutterProjectRoot.toFile(), '.flutter-plugins')
|
localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) }
|
||||||
if (pluginsFile.exists()) {
|
|
||||||
pluginsFile.withReader('UTF-8') { reader -> plugins.load(reader) }
|
|
||||||
}
|
|
||||||
|
|
||||||
plugins.each { name, path ->
|
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||||
def pluginDirectory = flutterProjectRoot.resolve(path).resolve('android').toFile()
|
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||||
include ":$name"
|
apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle"
|
||||||
project(":$name").projectDir = pluginDirectory
|
|
||||||
}
|
|
|
@ -1 +0,0 @@
|
||||||
include ':app'
|
|
|
@ -9,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.framework
|
make MobileNebula.xcframework
|
||||||
rm -rf ../ios/NebulaNetworkExtension/MobileNebula.framework
|
rm -rf ../ios/MobileNebula.xcframework
|
||||||
cp -r MobileNebula.framework ../ios/NebulaNetworkExtension/
|
cp -r MobileNebula.xcframework ../ios/
|
||||||
|
|
||||||
elif [ "$1" = "android" ]; then
|
elif [ "$1" = "android" ]; then
|
||||||
# Build nebula for android
|
# Build nebula for android
|
||||||
make mobileNebula.aar
|
make mobileNebula.aar
|
||||||
mkdir -p ../android/app/src/main/libs
|
mkdir -p ../android/mobileNebula
|
||||||
rm -rf ../android/app/src/main/libs/mobileNebula.aar
|
rm -rf ../android/mobileNebula/mobileNebula.aar
|
||||||
cp mobileNebula.aar ../android/app/src/main/libs/mobileNebula.aar
|
cp mobileNebula.aar ../android/mobileNebula/mobileNebula.aar
|
||||||
|
|
||||||
else
|
else
|
||||||
echo "Error: unsupported target os $1"
|
echo "Error: unsupported target os $1"
|
||||||
|
|
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="53" height="62" viewBox="0 0 53 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M42.1128 61.2016H25.8226C30.4449 55.8553 42.14 32.9921 36.5151 23.1053C32.4774 15.9477 19.5464 12.8338 0 14.1999V0.323899C25.6196 -1.42992 41.6675 3.94663 48.6585 16.2567C57.4851 31.9077 47.3469 52.4022 42.1128 61.2016Z" fill="white"/>
|
||||||
|
<path d="M0 61.2106H13.9245V21.6453L0 14.0424V61.2106Z" fill="#6E7D91"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 421 B |
|
@ -0,0 +1,4 @@
|
||||||
|
<svg width="53" height="62" viewBox="0 0 53 62" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M42.1128 61.2016H25.8226C30.4449 55.8553 42.14 32.9921 36.5151 23.1053C32.4774 15.9477 19.5464 12.8338 0 14.1999V0.323899C25.6196 -1.42992 41.6675 3.94663 48.6585 16.2567C57.4851 31.9077 47.3469 52.4022 42.1128 61.2016Z" fill="#0B0D0F"/>
|
||||||
|
<path d="M0 61.2106H13.9245V21.6453L0 14.0424V61.2106Z" fill="#6E7D91"/>
|
||||||
|
</svg>
|
After Width: | Height: | Size: 423 B |
|
@ -21,6 +21,6 @@
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>1.0</string>
|
<string>1.0</string>
|
||||||
<key>MinimumOSVersion</key>
|
<key>MinimumOSVersion</key>
|
||||||
<string>8.0</string>
|
<string>11.0</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|
|
@ -0,0 +1,3 @@
|
||||||
|
source "https://rubygems.org"
|
||||||
|
|
||||||
|
gem "fastlane"
|
|
@ -0,0 +1,218 @@
|
||||||
|
GEM
|
||||||
|
remote: https://rubygems.org/
|
||||||
|
specs:
|
||||||
|
CFPropertyList (3.0.5)
|
||||||
|
rexml
|
||||||
|
addressable (2.8.1)
|
||||||
|
public_suffix (>= 2.0.2, < 6.0)
|
||||||
|
artifactory (3.0.15)
|
||||||
|
atomos (0.1.3)
|
||||||
|
aws-eventstream (1.2.0)
|
||||||
|
aws-partitions (1.634.0)
|
||||||
|
aws-sdk-core (3.152.0)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
aws-partitions (~> 1, >= 1.525.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
jmespath (~> 1, >= 1.6.1)
|
||||||
|
aws-sdk-kms (1.58.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sigv4 (~> 1.1)
|
||||||
|
aws-sdk-s3 (1.114.0)
|
||||||
|
aws-sdk-core (~> 3, >= 3.127.0)
|
||||||
|
aws-sdk-kms (~> 1)
|
||||||
|
aws-sigv4 (~> 1.4)
|
||||||
|
aws-sigv4 (1.5.1)
|
||||||
|
aws-eventstream (~> 1, >= 1.0.2)
|
||||||
|
babosa (1.0.4)
|
||||||
|
claide (1.1.0)
|
||||||
|
colored (1.2)
|
||||||
|
colored2 (3.1.2)
|
||||||
|
commander (4.6.0)
|
||||||
|
highline (~> 2.0.0)
|
||||||
|
declarative (0.0.20)
|
||||||
|
digest-crc (0.6.4)
|
||||||
|
rake (>= 12.0.0, < 14.0.0)
|
||||||
|
domain_name (0.5.20190701)
|
||||||
|
unf (>= 0.0.5, < 1.0.0)
|
||||||
|
dotenv (2.8.1)
|
||||||
|
emoji_regex (3.2.3)
|
||||||
|
excon (0.92.5)
|
||||||
|
faraday (1.10.2)
|
||||||
|
faraday-em_http (~> 1.0)
|
||||||
|
faraday-em_synchrony (~> 1.0)
|
||||||
|
faraday-excon (~> 1.1)
|
||||||
|
faraday-httpclient (~> 1.0)
|
||||||
|
faraday-multipart (~> 1.0)
|
||||||
|
faraday-net_http (~> 1.0)
|
||||||
|
faraday-net_http_persistent (~> 1.0)
|
||||||
|
faraday-patron (~> 1.0)
|
||||||
|
faraday-rack (~> 1.0)
|
||||||
|
faraday-retry (~> 1.0)
|
||||||
|
ruby2_keywords (>= 0.0.4)
|
||||||
|
faraday-cookie_jar (0.0.7)
|
||||||
|
faraday (>= 0.8.0)
|
||||||
|
http-cookie (~> 1.0.0)
|
||||||
|
faraday-em_http (1.0.0)
|
||||||
|
faraday-em_synchrony (1.0.0)
|
||||||
|
faraday-excon (1.1.0)
|
||||||
|
faraday-httpclient (1.0.1)
|
||||||
|
faraday-multipart (1.0.4)
|
||||||
|
multipart-post (~> 2)
|
||||||
|
faraday-net_http (1.0.1)
|
||||||
|
faraday-net_http_persistent (1.2.0)
|
||||||
|
faraday-patron (1.0.0)
|
||||||
|
faraday-rack (1.0.0)
|
||||||
|
faraday-retry (1.0.3)
|
||||||
|
faraday_middleware (1.2.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
fastimage (2.2.6)
|
||||||
|
fastlane (2.210.1)
|
||||||
|
CFPropertyList (>= 2.3, < 4.0.0)
|
||||||
|
addressable (>= 2.8, < 3.0.0)
|
||||||
|
artifactory (~> 3.0)
|
||||||
|
aws-sdk-s3 (~> 1.0)
|
||||||
|
babosa (>= 1.0.3, < 2.0.0)
|
||||||
|
bundler (>= 1.12.0, < 3.0.0)
|
||||||
|
colored
|
||||||
|
commander (~> 4.6)
|
||||||
|
dotenv (>= 2.1.1, < 3.0.0)
|
||||||
|
emoji_regex (>= 0.1, < 4.0)
|
||||||
|
excon (>= 0.71.0, < 1.0.0)
|
||||||
|
faraday (~> 1.0)
|
||||||
|
faraday-cookie_jar (~> 0.0.6)
|
||||||
|
faraday_middleware (~> 1.0)
|
||||||
|
fastimage (>= 2.1.0, < 3.0.0)
|
||||||
|
gh_inspector (>= 1.1.2, < 2.0.0)
|
||||||
|
google-apis-androidpublisher_v3 (~> 0.3)
|
||||||
|
google-apis-playcustomapp_v1 (~> 0.1)
|
||||||
|
google-cloud-storage (~> 1.31)
|
||||||
|
highline (~> 2.0)
|
||||||
|
json (< 3.0.0)
|
||||||
|
jwt (>= 2.1.0, < 3)
|
||||||
|
mini_magick (>= 4.9.4, < 5.0.0)
|
||||||
|
multipart-post (~> 2.0.0)
|
||||||
|
naturally (~> 2.2)
|
||||||
|
optparse (~> 0.1.1)
|
||||||
|
plist (>= 3.1.0, < 4.0.0)
|
||||||
|
rubyzip (>= 2.0.0, < 3.0.0)
|
||||||
|
security (= 0.1.3)
|
||||||
|
simctl (~> 1.6.3)
|
||||||
|
terminal-notifier (>= 2.0.0, < 3.0.0)
|
||||||
|
terminal-table (>= 1.4.5, < 2.0.0)
|
||||||
|
tty-screen (>= 0.6.3, < 1.0.0)
|
||||||
|
tty-spinner (>= 0.8.0, < 1.0.0)
|
||||||
|
word_wrap (~> 1.0.0)
|
||||||
|
xcodeproj (>= 1.13.0, < 2.0.0)
|
||||||
|
xcpretty (~> 0.3.0)
|
||||||
|
xcpretty-travis-formatter (>= 0.0.3)
|
||||||
|
gh_inspector (1.1.3)
|
||||||
|
google-apis-androidpublisher_v3 (0.27.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-core (0.9.0)
|
||||||
|
addressable (~> 2.5, >= 2.5.1)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
httpclient (>= 2.8.1, < 3.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
representable (~> 3.0)
|
||||||
|
retriable (>= 2.0, < 4.a)
|
||||||
|
rexml
|
||||||
|
webrick
|
||||||
|
google-apis-iamcredentials_v1 (0.14.0)
|
||||||
|
google-apis-core (>= 0.7.2, < 2.a)
|
||||||
|
google-apis-playcustomapp_v1 (0.10.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-apis-storage_v1 (0.17.0)
|
||||||
|
google-apis-core (>= 0.7, < 2.a)
|
||||||
|
google-cloud-core (1.6.0)
|
||||||
|
google-cloud-env (~> 1.0)
|
||||||
|
google-cloud-errors (~> 1.0)
|
||||||
|
google-cloud-env (1.6.0)
|
||||||
|
faraday (>= 0.17.3, < 3.0)
|
||||||
|
google-cloud-errors (1.3.0)
|
||||||
|
google-cloud-storage (1.42.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
digest-crc (~> 0.4)
|
||||||
|
google-apis-iamcredentials_v1 (~> 0.1)
|
||||||
|
google-apis-storage_v1 (~> 0.17.0)
|
||||||
|
google-cloud-core (~> 1.6)
|
||||||
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
|
mini_mime (~> 1.0)
|
||||||
|
googleauth (1.2.0)
|
||||||
|
faraday (>= 0.17.3, < 3.a)
|
||||||
|
jwt (>= 1.4, < 3.0)
|
||||||
|
memoist (~> 0.16)
|
||||||
|
multi_json (~> 1.11)
|
||||||
|
os (>= 0.9, < 2.0)
|
||||||
|
signet (>= 0.16, < 2.a)
|
||||||
|
highline (2.0.3)
|
||||||
|
http-cookie (1.0.5)
|
||||||
|
domain_name (~> 0.5)
|
||||||
|
httpclient (2.8.3)
|
||||||
|
jmespath (1.6.1)
|
||||||
|
json (2.6.2)
|
||||||
|
jwt (2.5.0)
|
||||||
|
memoist (0.16.2)
|
||||||
|
mini_magick (4.11.0)
|
||||||
|
mini_mime (1.1.2)
|
||||||
|
multi_json (1.15.0)
|
||||||
|
multipart-post (2.0.0)
|
||||||
|
nanaimo (0.3.0)
|
||||||
|
naturally (2.2.1)
|
||||||
|
optparse (0.1.1)
|
||||||
|
os (1.1.4)
|
||||||
|
plist (3.6.0)
|
||||||
|
public_suffix (5.0.0)
|
||||||
|
rake (13.0.6)
|
||||||
|
representable (3.2.0)
|
||||||
|
declarative (< 0.1.0)
|
||||||
|
trailblazer-option (>= 0.1.1, < 0.2.0)
|
||||||
|
uber (< 0.2.0)
|
||||||
|
retriable (3.1.2)
|
||||||
|
rexml (3.2.5)
|
||||||
|
rouge (2.0.7)
|
||||||
|
ruby2_keywords (0.0.5)
|
||||||
|
rubyzip (2.3.2)
|
||||||
|
security (0.1.3)
|
||||||
|
signet (0.17.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
faraday (>= 0.17.5, < 3.a)
|
||||||
|
jwt (>= 1.5, < 3.0)
|
||||||
|
multi_json (~> 1.10)
|
||||||
|
simctl (1.6.8)
|
||||||
|
CFPropertyList
|
||||||
|
naturally
|
||||||
|
terminal-notifier (2.0.0)
|
||||||
|
terminal-table (1.8.0)
|
||||||
|
unicode-display_width (~> 1.1, >= 1.1.1)
|
||||||
|
trailblazer-option (0.1.2)
|
||||||
|
tty-cursor (0.7.1)
|
||||||
|
tty-screen (0.8.1)
|
||||||
|
tty-spinner (0.9.3)
|
||||||
|
tty-cursor (~> 0.7)
|
||||||
|
uber (0.1.0)
|
||||||
|
unf (0.1.4)
|
||||||
|
unf_ext
|
||||||
|
unf_ext (0.0.8.2)
|
||||||
|
unicode-display_width (1.8.0)
|
||||||
|
webrick (1.7.0)
|
||||||
|
word_wrap (1.0.0)
|
||||||
|
xcodeproj (1.22.0)
|
||||||
|
CFPropertyList (>= 2.3.3, < 4.0)
|
||||||
|
atomos (~> 0.1.3)
|
||||||
|
claide (>= 1.0.2, < 2.0)
|
||||||
|
colored2 (~> 3.1)
|
||||||
|
nanaimo (~> 0.3.0)
|
||||||
|
rexml (~> 3.2.4)
|
||||||
|
xcpretty (0.3.0)
|
||||||
|
rouge (~> 2.0.7)
|
||||||
|
xcpretty-travis-formatter (1.0.1)
|
||||||
|
xcpretty (~> 0.2, >= 0.0.7)
|
||||||
|
|
||||||
|
PLATFORMS
|
||||||
|
arm64-darwin-21
|
||||||
|
|
||||||
|
DEPENDENCIES
|
||||||
|
fastlane
|
||||||
|
|
||||||
|
BUNDLED WITH
|
||||||
|
2.3.11
|
|
@ -0,0 +1,16 @@
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/* <sys/kern_control.h> */
|
||||||
|
#define CTLIOCGINFO 0xc0644e03UL
|
||||||
|
struct ctl_info {
|
||||||
|
u_int32_t ctl_id;
|
||||||
|
char ctl_name[96];
|
||||||
|
};
|
||||||
|
struct sockaddr_ctl {
|
||||||
|
u_char sc_len;
|
||||||
|
u_char sc_family;
|
||||||
|
u_int16_t ss_sysaddr;
|
||||||
|
u_int32_t sc_id;
|
||||||
|
u_int32_t sc_unit;
|
||||||
|
u_int32_t sc_reserved[5];
|
||||||
|
};
|
|
@ -3,17 +3,21 @@ import Foundation
|
||||||
let groupName = "group.net.defined.mobileNebula"
|
let groupName = "group.net.defined.mobileNebula"
|
||||||
|
|
||||||
class KeyChain {
|
class KeyChain {
|
||||||
class func save(key: String, data: Data) -> Bool {
|
class func save(key: String, data: Data, managed: Bool) -> Bool {
|
||||||
let query: [String: Any] = [
|
var query: [String: Any] = [
|
||||||
kSecClass as String : kSecClassGenericPassword as String,
|
kSecClass as String : kSecClassGenericPassword as String,
|
||||||
kSecAttrAccount as String : key,
|
kSecAttrAccount as String : key,
|
||||||
kSecValueData as String : data,
|
kSecValueData as String : data,
|
||||||
kSecAttrAccessGroup as String: groupName,
|
kSecAttrAccessGroup as String: groupName,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
if (managed) {
|
||||||
|
query[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock
|
||||||
|
}
|
||||||
|
|
||||||
SecItemDelete(query as CFDictionary)
|
// Attempt to delete an existing key to allow for an overwrite
|
||||||
let val = SecItemAdd(query as CFDictionary, nil)
|
_ = self.delete(key: key)
|
||||||
return val == 0
|
return SecItemAdd(query as CFDictionary, nil) == 0
|
||||||
}
|
}
|
||||||
|
|
||||||
class func load(key: String) -> Data? {
|
class func load(key: String) -> Data? {
|
||||||
|
@ -38,10 +42,8 @@ class KeyChain {
|
||||||
|
|
||||||
class func delete(key: String) -> Bool {
|
class func delete(key: String) -> Bool {
|
||||||
let query: [String: Any] = [
|
let query: [String: Any] = [
|
||||||
kSecClass as String : kSecClassGenericPassword,
|
kSecClass as String : kSecClassGenericPassword as String,
|
||||||
kSecAttrAccount as String : key,
|
kSecAttrAccount as String : key,
|
||||||
kSecReturnData as String : kCFBooleanTrue!,
|
|
||||||
kSecMatchLimit as String : kSecMatchLimitOne,
|
|
||||||
kSecAttrAccessGroup as String: groupName,
|
kSecAttrAccessGroup as String: groupName,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
@ -5,27 +5,20 @@ import SwiftyJSON
|
||||||
|
|
||||||
class PacketTunnelProvider: NEPacketTunnelProvider {
|
class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
private var networkMonitor: NWPathMonitor?
|
private var networkMonitor: NWPathMonitor?
|
||||||
private var ifname: String?
|
|
||||||
|
|
||||||
private var site: Site?
|
private var site: Site?
|
||||||
private var _log = OSLog(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
|
private var log = Logger(subsystem: "net.defined.mobileNebula", category: "PacketTunnelProvider")
|
||||||
private var 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?
|
||||||
|
|
||||||
// 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
|
override func startTunnel(options: [String : NSObject]?, completionHandler: @escaping (Error?) -> Void) {
|
||||||
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 {
|
||||||
startCompleter = completionHandler
|
// The system completion handler must be called before IPC will work
|
||||||
|
completionHandler(nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -40,35 +33,27 @@ 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("Failed to render config from vpn object")
|
log.error("Failed to render config from vpn object")
|
||||||
return completionHandler(error)
|
return completionHandler(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let _site = site!
|
let _site = site!
|
||||||
_log = OSLog(subsystem: "net.defined.mobileNebula:\(_site.name)", category: "PacketTunnelProvider")
|
|
||||||
|
|
||||||
do {
|
do {
|
||||||
key = try _site.getKey()
|
key = try _site.getKey()
|
||||||
} catch {
|
} catch {
|
||||||
return completionHandler(error)
|
return completionHandler(error)
|
||||||
}
|
}
|
||||||
|
|
||||||
let fileDescriptor = (self.packetFlow.value(forKeyPath: "socket.fileDescriptor") as? Int32) ?? -1
|
let fileDescriptor = tunnelFileDescriptor
|
||||||
if fileDescriptor < 0 {
|
if fileDescriptor == nil {
|
||||||
return completionHandler("Starting tunnel failed: Could not determine file descriptor")
|
return completionHandler("Unable to locate the tun 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")
|
||||||
|
@ -93,6 +78,11 @@ 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) {
|
||||||
|
@ -100,19 +90,32 @@ class PacketTunnelProvider: NEPacketTunnelProvider {
|
||||||
}
|
}
|
||||||
|
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, Int(fileDescriptor), &err)
|
self.nebula = MobileNebulaNewNebula(String(data: config, encoding: .utf8), key, self.site!.logFile, tunFD, &err)
|
||||||
self.startNetworkMonitor()
|
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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: Sleep/wake get called aggresively and do nothing to help us here, we should locate why that is and make these work appropriately
|
private func handleDNUpdate(newSite: Site) {
|
||||||
|
do {
|
||||||
|
self.site = newSite
|
||||||
|
try self.nebula?.reload(String(data: newSite.getConfig(), encoding: .utf8), key: newSite.getKey())
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
self.log.error("Got an error while updating nebula \(error.localizedDescription, privacy: .public)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//TODO: Sleep/wake get called aggressively and do nothing to help us here, we should locate why that is and make these work appropriately
|
||||||
// override func sleep(completionHandler: @escaping () -> Void) {
|
// override func sleep(completionHandler: @escaping () -> Void) {
|
||||||
// nebula!.sleep()
|
// nebula!.sleep()
|
||||||
// completionHandler()
|
// completionHandler()
|
||||||
|
@ -164,7 +167,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("Failed to decode IPCRequest from network extension")
|
log.error("Failed to decode IPCRequest from network extension")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,19 +177,6 @@ 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 {
|
||||||
|
@ -194,8 +184,9 @@ 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 {
|
||||||
// Error response has
|
// We failed, notify and shutdown
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -204,7 +195,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("Received command but do not have a nebula instance")
|
log.warning("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)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -249,5 +240,37 @@ 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 initilizer to make error reporting easier
|
/// An empty initializer 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 initilizer to make error reporting easier
|
/// An empty initializer 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: true,
|
NEVPNStatus.connecting: false,
|
||||||
NEVPNStatus.connected: true,
|
NEVPNStatus.connected: true,
|
||||||
NEVPNStatus.reasserting: true,
|
NEVPNStatus.reasserting: true,
|
||||||
NEVPNStatus.disconnecting: true,
|
NEVPNStatus.disconnecting: true,
|
||||||
|
@ -117,10 +117,11 @@ 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
|
||||||
|
@ -132,13 +133,22 @@ 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?
|
||||||
|
|
||||||
// Creates a new site from a vpn manager instance
|
var incomingSite: IncomingSite?
|
||||||
|
|
||||||
|
/// 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
|
||||||
|
@ -147,33 +157,64 @@ 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"
|
||||||
|
@ -182,11 +223,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)
|
||||||
|
@ -194,31 +235,34 @@ 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) {
|
if (hasErrors && !managed) {
|
||||||
errors.append("There are issues with 1 or more ca certificates")
|
errors.append("There are issues with 1 or more ca certificates")
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch {
|
} catch {
|
||||||
ca = []
|
ca = []
|
||||||
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
|
errors.append("Error while loading certificate authorities: \(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
lhDuration = incoming.lhDuration
|
do {
|
||||||
port = incoming.port
|
logFile = try SiteList.getSiteLogFile(id: self.id, createDir: true).path
|
||||||
cipher = incoming.cipher
|
} catch {
|
||||||
sortKey = incoming.sortKey ?? 0
|
logFile = nil
|
||||||
logVerbosity = incoming.logVerbosity ?? "info"
|
errors.append("Unable to create the site directory: \(error.localizedDescription)")
|
||||||
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()
|
||||||
|
@ -226,6 +270,7 @@ 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!
|
||||||
|
@ -235,17 +280,53 @@ 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 material from keychain"
|
throw "failed to get key from keychain"
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: make sure this is valid on return!
|
//TODO: make sure this is valid on return!
|
||||||
return String(decoding: keyData, as: UTF8.self)
|
return String(decoding: keyData, as: UTF8.self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func getDNCredentials() throws -> DNCredentials {
|
||||||
|
if (!managed) {
|
||||||
|
throw "unmanaged site has no dn credentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawDNCredentials = KeyChain.load(key: "\(id).dnCredentials")
|
||||||
|
if rawDNCredentials == nil {
|
||||||
|
throw "failed to find dn credentials in keychain"
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
return try decoder.decode(DNCredentials.self, from: rawDNCredentials!)
|
||||||
|
}
|
||||||
|
|
||||||
|
func invalidateDNCredentials() throws {
|
||||||
|
let creds = try getDNCredentials()
|
||||||
|
creds.invalid = true
|
||||||
|
|
||||||
|
if (!(try creds.save(siteID: self.id))) {
|
||||||
|
throw "failed to store dn credentials in keychain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateDNCredentials() throws {
|
||||||
|
let creds = try getDNCredentials()
|
||||||
|
creds.invalid = false
|
||||||
|
|
||||||
|
if (!(try creds.save(siteID: self.id))) {
|
||||||
|
throw "failed to store dn credentials in keychain"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getConfig() throws -> Data {
|
||||||
|
return try self.incomingSite!.getConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// Limits what we export to the UI
|
// Limits what we export to the UI
|
||||||
private enum CodingKeys: String, CodingKey {
|
private enum CodingKeys: String, CodingKey {
|
||||||
case name
|
case name
|
||||||
|
@ -261,9 +342,13 @@ 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -278,12 +363,41 @@ class UnsafeRoute: Codable {
|
||||||
var mtu: Int?
|
var mtu: Int?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class DNCredentials: Codable {
|
||||||
|
var hostID: String
|
||||||
|
var privateKey: String
|
||||||
|
var counter: Int
|
||||||
|
var trustedKeys: String
|
||||||
|
var invalid: Bool {
|
||||||
|
get { return _invalid ?? false }
|
||||||
|
set { _invalid = newValue }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var _invalid: Bool?
|
||||||
|
|
||||||
|
func save(siteID: String) throws -> Bool {
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let rawDNCredentials = try encoder.encode(self)
|
||||||
|
|
||||||
|
return KeyChain.save(key: "\(siteID).dnCredentials", data: rawDNCredentials, managed: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case hostID
|
||||||
|
case privateKey
|
||||||
|
case counter
|
||||||
|
case trustedKeys
|
||||||
|
case _invalid = "invalid"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site
|
// This class represents a site coming in from flutter, meant only to be saved and re-loaded as a proper Site
|
||||||
struct IncomingSite: Codable {
|
struct IncomingSite: Codable {
|
||||||
var name: String
|
var name: String
|
||||||
var id: String
|
var id: String
|
||||||
var staticHostmap: Dictionary<String, StaticHosts>
|
var staticHostmap: Dictionary<String, StaticHosts>
|
||||||
var unsafeRoutes: [UnsafeRoute]?
|
var unsafeRoutes: [UnsafeRoute]?
|
||||||
|
vat dnsResolvers: [String]?
|
||||||
var cert: String
|
var cert: String
|
||||||
var ca: String
|
var ca: String
|
||||||
var lhDuration: Int
|
var lhDuration: Int
|
||||||
|
@ -293,76 +407,98 @@ struct IncomingSite: Codable {
|
||||||
var sortKey: Int?
|
var sortKey: Int?
|
||||||
var logVerbosity: String?
|
var logVerbosity: String?
|
||||||
var key: String?
|
var key: String?
|
||||||
|
var managed: Bool?
|
||||||
func save(manager: NETunnelProviderManager?, callback: @escaping (Error?) -> ()) {
|
// The following fields are present if managed = true
|
||||||
#if targetEnvironment(simulator)
|
var dnCredentials: DNCredentials?
|
||||||
let fileManager = FileManager.default
|
var lastManagedUpdate: String?
|
||||||
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(self.id)
|
var rawConfig: String?
|
||||||
|
|
||||||
|
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 {
|
||||||
var config = self
|
configPath = try SiteList.getSiteConfigFile(id: self.id, createDir: true)
|
||||||
config.key = nil
|
|
||||||
let rawConfig = try encoder.encode(config)
|
} catch {
|
||||||
try rawConfig.write(to: sitePath)
|
callback(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Saving to \(configPath)")
|
||||||
|
do {
|
||||||
|
if (self.key != nil) {
|
||||||
|
let data = self.key!.data(using: .utf8)
|
||||||
|
if (!KeyChain.save(key: "\(self.id).key", data: data!, managed: self.managed ?? false)) {
|
||||||
|
return callback("failed to store key material in keychain")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
if ((try self.dnCredentials?.save(siteID: self.id)) == false) {
|
||||||
|
return callback("failed to store dn credentials in keychain")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
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.finish(manager: manager!, callback: callback)
|
return self.finishSaveToManager(manager: manager!, callback: callback)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
|
return finishSaveToManager(manager: NETunnelProviderManager(), callback: callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
|
||||||
|
|
||||||
// We tried using NSSecureCoder but that was obnoxious and didn't work so back to JSON
|
proto.providerConfiguration = ["id": self.id]
|
||||||
do {
|
|
||||||
rawConfig = try encoder.encode(config)
|
|
||||||
} catch {
|
|
||||||
return callback(error)
|
|
||||||
}
|
|
||||||
|
|
||||||
proto.providerConfiguration = ["config": rawConfig]
|
|
||||||
proto.serverAddress = "Nebula"
|
proto.serverAddress = "Nebula"
|
||||||
|
|
||||||
// Finish up the manager, this is what stores everything at the system level
|
// Finish up the manager, this is what stores everything at the system level
|
||||||
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 = config.name
|
manager.localizedDescription = self.name
|
||||||
manager.isEnabled = true
|
manager.isEnabled = true
|
||||||
|
|
||||||
manager.saveToPreferences{ error in
|
manager.saveToPreferences{ error in
|
||||||
|
|
|
@ -0,0 +1,140 @@
|
||||||
|
import NetworkExtension
|
||||||
|
|
||||||
|
class SiteList {
|
||||||
|
private var sites = [String: Site]()
|
||||||
|
|
||||||
|
/// Gets the root directory that can be used to share files between the UI and VPN process. Does ensure the directory exists
|
||||||
|
static func getRootDir() throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let rootDir = fileManager.containerURL(forSecurityApplicationGroupIdentifier: "group.net.defined.mobileNebula")!
|
||||||
|
|
||||||
|
if (!fileManager.fileExists(atPath: rootDir.absoluteString)) {
|
||||||
|
try fileManager.createDirectory(at: rootDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return rootDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the directory where all sites live, $rootDir/sites. Does ensure the directory exists
|
||||||
|
static func getSitesDir() throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let sitesDir = try getRootDir().appendingPathComponent("sites", isDirectory: true)
|
||||||
|
if (!fileManager.fileExists(atPath: sitesDir.absoluteString)) {
|
||||||
|
try fileManager.createDirectory(at: sitesDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return sitesDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the directory where a single site would live, $rootDir/sites/$siteID
|
||||||
|
static func getSiteDir(id: String, create: Bool = false) throws -> URL {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
let siteDir = try getSitesDir().appendingPathComponent(id, isDirectory: true)
|
||||||
|
if (create && !fileManager.fileExists(atPath: siteDir.absoluteString)) {
|
||||||
|
try fileManager.createDirectory(at: siteDir, withIntermediateDirectories: true)
|
||||||
|
}
|
||||||
|
return siteDir
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the file that represents the site configuration, $rootDir/sites/$siteID/config.json
|
||||||
|
static func getSiteConfigFile(id: String, createDir: Bool) throws -> URL {
|
||||||
|
return try getSiteDir(id: id, create: createDir).appendingPathComponent("config", isDirectory: false).appendingPathExtension("json")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gets the file that represents the site log output, $rootDir/sites/$siteID/log
|
||||||
|
static func getSiteLogFile(id: String, createDir: Bool) throws -> URL {
|
||||||
|
return try getSiteDir(id: id, create: createDir).appendingPathComponent("logs", isDirectory: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
init(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
|
#if targetEnvironment(simulator)
|
||||||
|
SiteList.loadAllFromFS { sites, err in
|
||||||
|
if sites != nil {
|
||||||
|
self.sites = sites!
|
||||||
|
}
|
||||||
|
completion(sites, err)
|
||||||
|
}
|
||||||
|
#else
|
||||||
|
SiteList.loadAllFromNETPM { sites, err in
|
||||||
|
if sites != nil {
|
||||||
|
self.sites = sites!
|
||||||
|
}
|
||||||
|
completion(sites, err)
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadAllFromFS(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
|
let fileManager = FileManager.default
|
||||||
|
var siteDirs: [URL]
|
||||||
|
var sites = [String: Site]()
|
||||||
|
|
||||||
|
do {
|
||||||
|
siteDirs = try fileManager.contentsOfDirectory(at: getSitesDir(), includingPropertiesForKeys: nil)
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
completion(nil, error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
siteDirs.forEach { path in
|
||||||
|
do {
|
||||||
|
let site = try Site(path: path.appendingPathComponent("config").appendingPathExtension("json"))
|
||||||
|
sites[site.id] = site
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print(error)
|
||||||
|
try? fileManager.removeItem(at: path)
|
||||||
|
print("Deleted non conforming site \(path)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
completion(sites, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func loadAllFromNETPM(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
|
var sites = [String: Site]()
|
||||||
|
|
||||||
|
// dispatchGroup is used to ensure we have migrated all sites before returning them
|
||||||
|
// If there are no sites to migrate, there are never any entrants
|
||||||
|
let dispatchGroup = DispatchGroup()
|
||||||
|
|
||||||
|
NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in
|
||||||
|
if (err != nil) {
|
||||||
|
return completion(nil, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
newManagers?.forEach { manager in
|
||||||
|
do {
|
||||||
|
let site = try Site(manager: manager)
|
||||||
|
if site.needsToMigrateToFS {
|
||||||
|
dispatchGroup.enter()
|
||||||
|
site.incomingSite?.save(manager: manager) { error in
|
||||||
|
if error != nil {
|
||||||
|
print("Error while migrating site to fs: \(error!.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
print("Migrated site to fs: \(site.name)")
|
||||||
|
site.needsToMigrateToFS = false
|
||||||
|
dispatchGroup.leave()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sites[site.id] = site
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
//TODO: notify the user about this
|
||||||
|
print("Deleted non conforming site \(manager) \(error)")
|
||||||
|
manager.removeFromPreferences()
|
||||||
|
//TODO: delete from disk, we need to try and discover the site id though
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchGroup.notify(queue: .main) {
|
||||||
|
completion(sites, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSites() -> [String: Site] {
|
||||||
|
return sites
|
||||||
|
}
|
||||||
|
}
|
12
ios/Podfile
12
ios/Podfile
|
@ -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,6 +41,16 @@ 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
|
||||||
|
|
113
ios/Podfile.lock
113
ios/Podfile.lock
|
@ -1,110 +1,103 @@
|
||||||
PODS:
|
PODS:
|
||||||
- barcode_scan (0.0.1):
|
- DKImagePickerController/Core (4.3.4):
|
||||||
- Flutter
|
|
||||||
- MTBBarcodeScanner
|
|
||||||
- SwiftProtobuf
|
|
||||||
- DKImagePickerController/Core (4.3.0):
|
|
||||||
- DKImagePickerController/ImageDataManager
|
- DKImagePickerController/ImageDataManager
|
||||||
- DKImagePickerController/Resource
|
- DKImagePickerController/Resource
|
||||||
- DKImagePickerController/ImageDataManager (4.3.0)
|
- DKImagePickerController/ImageDataManager (4.3.4)
|
||||||
- DKImagePickerController/PhotoGallery (4.3.0):
|
- DKImagePickerController/PhotoGallery (4.3.4):
|
||||||
- DKImagePickerController/Core
|
- DKImagePickerController/Core
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- DKImagePickerController/Resource (4.3.0)
|
- DKImagePickerController/Resource (4.3.4)
|
||||||
- DKPhotoGallery (0.0.15):
|
- DKPhotoGallery (0.0.17):
|
||||||
- DKPhotoGallery/Core (= 0.0.15)
|
- DKPhotoGallery/Core (= 0.0.17)
|
||||||
- DKPhotoGallery/Model (= 0.0.15)
|
- DKPhotoGallery/Model (= 0.0.17)
|
||||||
- DKPhotoGallery/Preview (= 0.0.15)
|
- DKPhotoGallery/Preview (= 0.0.17)
|
||||||
- DKPhotoGallery/Resource (= 0.0.15)
|
- DKPhotoGallery/Resource (= 0.0.17)
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Core (0.0.15):
|
- DKPhotoGallery/Core (0.0.17):
|
||||||
- DKPhotoGallery/Model
|
- DKPhotoGallery/Model
|
||||||
- DKPhotoGallery/Preview
|
- DKPhotoGallery/Preview
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Model (0.0.15):
|
- DKPhotoGallery/Model (0.0.17):
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Preview (0.0.15):
|
- DKPhotoGallery/Preview (0.0.17):
|
||||||
- DKPhotoGallery/Model
|
- DKPhotoGallery/Model
|
||||||
- DKPhotoGallery/Resource
|
- DKPhotoGallery/Resource
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- DKPhotoGallery/Resource (0.0.15):
|
- DKPhotoGallery/Resource (0.0.17):
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- file_picker (0.0.1):
|
- file_picker (0.0.1):
|
||||||
- DKImagePickerController/PhotoGallery
|
- DKImagePickerController/PhotoGallery
|
||||||
- Flutter
|
- Flutter
|
||||||
- FLAnimatedImage (1.0.12)
|
|
||||||
- Flutter (1.0.0)
|
- Flutter (1.0.0)
|
||||||
- MTBBarcodeScanner (5.0.11)
|
- flutter_barcode_scanner (2.0.0):
|
||||||
|
- Flutter
|
||||||
- package_info (0.0.1):
|
- package_info (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- path_provider (0.0.1):
|
- path_provider_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- SDWebImage (5.8.0):
|
- SDWebImage (5.15.5):
|
||||||
- SDWebImage/Core (= 5.8.0)
|
- SDWebImage/Core (= 5.15.5)
|
||||||
- SDWebImage/Core (5.8.0)
|
- SDWebImage/Core (5.15.5)
|
||||||
- SDWebImageFLPlugin (0.4.0):
|
- share_plus (0.0.1):
|
||||||
- FLAnimatedImage (>= 1.0.11)
|
- Flutter
|
||||||
- SDWebImage/Core (~> 5.6)
|
- SwiftyGif (5.4.4)
|
||||||
- SwiftProtobuf (1.8.0)
|
|
||||||
- SwiftyJSON (5.0.1)
|
- SwiftyJSON (5.0.1)
|
||||||
- url_launcher (0.0.1):
|
- url_launcher_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- barcode_scan (from `.symlinks/plugins/barcode_scan/ios`)
|
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_barcode_scanner (from `.symlinks/plugins/flutter_barcode_scanner/ios`)
|
||||||
- package_info (from `.symlinks/plugins/package_info/ios`)
|
- package_info (from `.symlinks/plugins/package_info/ios`)
|
||||||
- path_provider (from `.symlinks/plugins/path_provider/ios`)
|
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||||
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
- SwiftyJSON (~> 5.0)
|
- SwiftyJSON (~> 5.0)
|
||||||
- url_launcher (from `.symlinks/plugins/url_launcher/ios`)
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
trunk:
|
trunk:
|
||||||
- DKImagePickerController
|
- DKImagePickerController
|
||||||
- DKPhotoGallery
|
- DKPhotoGallery
|
||||||
- FLAnimatedImage
|
|
||||||
- MTBBarcodeScanner
|
|
||||||
- SDWebImage
|
- SDWebImage
|
||||||
- SDWebImageFLPlugin
|
- SwiftyGif
|
||||||
- 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:
|
path_provider_ios:
|
||||||
:path: ".symlinks/plugins/path_provider/ios"
|
:path: ".symlinks/plugins/path_provider_ios/ios"
|
||||||
url_launcher:
|
share_plus:
|
||||||
:path: ".symlinks/plugins/url_launcher/ios"
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
barcode_scan: a5c27959edfafaa0c771905bad0b29d6d39e4479
|
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
|
||||||
DKImagePickerController: 397702a3590d4958fad336e9a77079935c500ddb
|
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
|
||||||
DKPhotoGallery: e880aef16c108333240e1e7327896f2ea380f4f0
|
file_picker: 817ab1d8cd2da9d2da412a417162deee3500fc95
|
||||||
file_picker: 3e6c3790de664ccf9b882732d9db5eaf6b8d4eb1
|
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
|
||||||
FLAnimatedImage: 4a0b56255d9b05f18b6dd7ee06871be5d3b89e31
|
flutter_barcode_scanner: 7a1144744c28dc0c57a8de7218ffe5ec59a9e4bf
|
||||||
Flutter: 434fef37c0980e73bb6479ef766c45957d4b510c
|
|
||||||
MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb
|
|
||||||
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
|
package_info: 873975fc26034f0b863a300ad47e7f1ac6c7ec62
|
||||||
path_provider: abfe2b5c733d04e238b0d8691db0cfd63a27a93c
|
path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
|
||||||
SDWebImage: 84000f962cbfa70c07f19d2234cbfcf5d779b5dc
|
SDWebImage: fd7e1a22f00303e058058278639bf6196ee431fe
|
||||||
SDWebImageFLPlugin: 6c2295fb1242d44467c6c87dc5db6b0a13228fd8
|
share_plus: 056a1e8ac890df3e33cb503afffaf1e9b4fbae68
|
||||||
SwiftProtobuf: 2cbd9409689b7df170d82a92a33443c8e3e14a70
|
SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f
|
||||||
SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e
|
SwiftyJSON: 2f33a42c6fbc52764d96f13368585094bfd8aa5e
|
||||||
url_launcher: 6fef411d543ceb26efce54b05a0a40bfd74cbbef
|
url_launcher_ios: 02f1989d4e14e998335b02b67a7590fa34f971af
|
||||||
|
|
||||||
PODFILE CHECKSUM: 87c61886589bcc4c3c709db9ee22a607d81c4861
|
PODFILE CHECKSUM: b4b37a776e1b487bf31fc5e5014fa5a74f5a022a
|
||||||
|
|
||||||
COCOAPODS: 1.10.1
|
COCOAPODS: 1.11.3
|
||||||
|
|
|
@ -3,29 +3,36 @@
|
||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 46;
|
objectVersion = 52;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
432D0E3E291C562200752563 /* SiteList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432D0E3D291C562200752563 /* SiteList.swift */; };
|
||||||
|
432D0E3F291C562200752563 /* SiteList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 432D0E3D291C562200752563 /* SiteList.swift */; };
|
||||||
|
43498725289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; };
|
||||||
|
43498726289B484C00476B19 /* MobileNebula.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43498724289B484C00476B19 /* MobileNebula.xcframework */; };
|
||||||
437F72592469AAC500A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
437F72592469AAC500A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
||||||
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
||||||
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F72582469AAC500A0C4B9 /* Site.swift */; };
|
||||||
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437F725C2469AC5700A0C4B9 /* Keychain.swift */; };
|
||||||
43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43871C9A2444DD39004F9075 /* MobileNebula.framework */; };
|
|
||||||
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43871C9C2444E2EC004F9075 /* Sites.swift */; };
|
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43871C9C2444E2EC004F9075 /* Sites.swift */; };
|
||||||
43871C9E2444E61F004F9075 /* MobileNebula.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43871C9A2444DD39004F9075 /* MobileNebula.framework */; };
|
|
||||||
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
||||||
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */; };
|
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */; };
|
||||||
43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
43AA895C2444DA6500EDC39C /* NebulaNetworkExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 43AA89542444DA6500EDC39C /* NebulaNetworkExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
|
||||||
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 43AA894E2444D8BC00EDC39C /* NetworkExtension.framework */; };
|
||||||
43AD63F424EB3802000FB47E /* Share.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43AD63F324EB3802000FB47E /* Share.swift */; };
|
43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; };
|
||||||
|
43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43ED87832912D0DD004DAFC5 /* DNUpdate.swift */; };
|
||||||
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; };
|
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 384887B4785D38431E800D3A /* Pods_Runner.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
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 */
|
||||||
|
|
||||||
|
@ -69,9 +76,11 @@
|
||||||
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; };
|
||||||
|
@ -79,11 +88,9 @@
|
||||||
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PacketTunnelProvider.swift; sourceTree = "<group>"; };
|
||||||
43AA89582444DA6500EDC39C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
43AA89582444DA6500EDC39C /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NebulaNetworkExtension.entitlements; sourceTree = "<group>"; };
|
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NebulaNetworkExtension.entitlements; sourceTree = "<group>"; };
|
||||||
43AD63F324EB3802000FB47E /* Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Share.swift; sourceTree = "<group>"; };
|
|
||||||
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
43B66ECA245A0C8400B18C36 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; };
|
||||||
43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
|
43B66ECC245A146300B18C36 /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
|
||||||
43B828DA249C08DC00CA229C /* MMWormhole.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MMWormhole.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DNUpdate.swift; sourceTree = "<group>"; };
|
||||||
43E9BBD0251450C5000BFB8C /* MMWormhole.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = MMWormhole.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
|
||||||
53C42258A2092B55937DCF53 /* Pods-NebulaNetworkExtension.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-NebulaNetworkExtension.profile.xcconfig"; path = "Target Support Files/Pods-NebulaNetworkExtension/Pods-NebulaNetworkExtension.profile.xcconfig"; sourceTree = "<group>"; };
|
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>"; };
|
||||||
|
@ -99,6 +106,8 @@
|
||||||
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 */
|
||||||
|
|
||||||
|
@ -108,7 +117,7 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */,
|
43AA89622444DAA500EDC39C /* NetworkExtension.framework in Frameworks */,
|
||||||
43871C9B2444DD39004F9075 /* MobileNebula.framework in Frameworks */,
|
43498726289B484C00476B19 /* MobileNebula.xcframework in Frameworks */,
|
||||||
E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */,
|
E91B9DAD4A83866D0AF1DAE1 /* Pods_NebulaNetworkExtension.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -118,8 +127,8 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */,
|
43AA894F2444D8BC00EDC39C /* NetworkExtension.framework in Frameworks */,
|
||||||
|
43498725289B484C00476B19 /* MobileNebula.xcframework in Frameworks */,
|
||||||
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */,
|
4CF2F06A02A63B862C9F6F03 /* Pods_Runner.framework in Frameworks */,
|
||||||
43871C9E2444E61F004F9075 /* MobileNebula.framework in Frameworks */,
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -129,8 +138,6 @@
|
||||||
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 */,
|
||||||
|
@ -144,11 +151,13 @@
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
437F725C2469AC5700A0C4B9 /* Keychain.swift */,
|
437F725C2469AC5700A0C4B9 /* Keychain.swift */,
|
||||||
43871C9A2444DD39004F9075 /* MobileNebula.framework */,
|
|
||||||
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */,
|
43AA89562444DA6500EDC39C /* PacketTunnelProvider.swift */,
|
||||||
|
43498724289B484C00476B19 /* MobileNebula.xcframework */,
|
||||||
43AA89582444DA6500EDC39C /* Info.plist */,
|
43AA89582444DA6500EDC39C /* Info.plist */,
|
||||||
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */,
|
43AA89592444DA6500EDC39C /* NebulaNetworkExtension.entitlements */,
|
||||||
437F72582469AAC500A0C4B9 /* Site.swift */,
|
437F72582469AAC500A0C4B9 /* Site.swift */,
|
||||||
|
436DE7A226EFF18500BB2950 /* CtlInfo.h */,
|
||||||
|
432D0E3D291C562200752563 /* SiteList.swift */,
|
||||||
);
|
);
|
||||||
path = NebulaNetworkExtension;
|
path = NebulaNetworkExtension;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -199,7 +208,9 @@
|
||||||
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
|
||||||
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
|
||||||
43871C9C2444E2EC004F9075 /* Sites.swift */,
|
43871C9C2444E2EC004F9075 /* Sites.swift */,
|
||||||
43AD63F324EB3802000FB47E /* Share.swift */,
|
43ED87832912D0DD004DAFC5 /* DNUpdate.swift */,
|
||||||
|
BE45F625291AEAB300902884 /* PackageInfo.swift */,
|
||||||
|
BE5BC105291C41E600B6FE5B /* APIClient.swift */,
|
||||||
);
|
);
|
||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
|
@ -232,7 +243,6 @@
|
||||||
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 */,
|
||||||
|
@ -277,7 +287,7 @@
|
||||||
isa = PBXProject;
|
isa = PBXProject;
|
||||||
attributes = {
|
attributes = {
|
||||||
LastSwiftUpdateCheck = 1140;
|
LastSwiftUpdateCheck = 1140;
|
||||||
LastUpgradeCheck = 1020;
|
LastUpgradeCheck = 1300;
|
||||||
ORGANIZATIONNAME = "The Chromium Authors";
|
ORGANIZATIONNAME = "The Chromium Authors";
|
||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
43AA89532444DA6500EDC39C = {
|
43AA89532444DA6500EDC39C = {
|
||||||
|
@ -342,33 +352,29 @@
|
||||||
"${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}/SDWebImageFLPlugin/SDWebImageFLPlugin.framework",
|
"${BUILT_PRODUCTS_DIR}/SwiftyGif/SwiftyGif.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/path_provider.framework",
|
"${BUILT_PRODUCTS_DIR}/path_provider_ios/path_provider_ios.framework",
|
||||||
"${BUILT_PRODUCTS_DIR}/url_launcher/url_launcher.framework",
|
"${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework",
|
||||||
|
"${BUILT_PRODUCTS_DIR}/url_launcher_ios/url_launcher_ios.framework",
|
||||||
);
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKImagePickerController.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/DKPhotoGallery.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FLAnimatedImage.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/MTBBarcodeScanner.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImage.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SDWebImageFLPlugin.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyGif.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftProtobuf.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/SwiftyJSON.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/barcode_scan.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/file_picker.framework",
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_barcode_scanner.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_ios.framework",
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher.framework",
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework",
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_ios.framework",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
|
@ -411,24 +417,6 @@
|
||||||
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;
|
||||||
|
@ -472,8 +460,12 @@
|
||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
432D0E3F291C562200752563 /* SiteList.swift in Sources */,
|
||||||
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */,
|
43AA89572444DA6500EDC39C /* PacketTunnelProvider.swift in Sources */,
|
||||||
437F72592469AAC500A0C4B9 /* Site.swift in Sources */,
|
437F72592469AAC500A0C4B9 /* Site.swift in Sources */,
|
||||||
|
43ED87852912D0DD004DAFC5 /* DNUpdate.swift in Sources */,
|
||||||
|
BEC5939E291C502F00709118 /* APIClient.swift in Sources */,
|
||||||
|
BEC5939F291C503D00709118 /* PackageInfo.swift in Sources */,
|
||||||
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */,
|
437F725E2469AC5700A0C4B9 /* Keychain.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
@ -483,11 +475,14 @@
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
|
||||||
43AD63F424EB3802000FB47E /* Share.swift in Sources */,
|
432D0E3E291C562200752563 /* SiteList.swift in Sources */,
|
||||||
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */,
|
43871C9D2444E2EC004F9075 /* Sites.swift in Sources */,
|
||||||
|
BE5BC106291C41E600B6FE5B /* APIClient.swift in Sources */,
|
||||||
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */,
|
437F725F2469B4B000A0C4B9 /* Site.swift in Sources */,
|
||||||
|
BE45F626291AEAB300902884 /* PackageInfo.swift in Sources */,
|
||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
|
||||||
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */,
|
437F72602469B4B300A0C4B9 /* Keychain.swift in Sources */,
|
||||||
|
43ED87842912D0DD004DAFC5 /* DNUpdate.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
|
@ -579,7 +574,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -589,12 +584,15 @@
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Flutter",
|
"$(PROJECT_DIR)/Flutter",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.40;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
@ -614,7 +612,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -624,8 +622,12 @@
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 0.0.40;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
|
@ -633,6 +635,7 @@
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = NebulaNetworkExtension/CtlInfo.h;
|
||||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
|
@ -650,7 +653,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -660,13 +663,18 @@
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 0.0.40;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = NebulaNetworkExtension/CtlInfo.h;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
|
@ -683,7 +691,7 @@
|
||||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
CODE_SIGN_ENTITLEMENTS = NebulaNetworkExtension/NebulaNetworkExtension.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -693,13 +701,18 @@
|
||||||
GCC_C_LANGUAGE_STANDARD = gnu11;
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
INFOPLIST_FILE = NebulaNetworkExtension/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks @executable_path/../../Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
MARKETING_VERSION = 0.0.40;
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
"@executable_path/../../Frameworks",
|
||||||
|
);
|
||||||
|
MARKETING_VERSION = 0.1.0;
|
||||||
MTL_FAST_MATH = YES;
|
MTL_FAST_MATH = YES;
|
||||||
OTHER_LDFLAGS = "";
|
OTHER_LDFLAGS = "";
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula.NebulaNetworkExtension;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SKIP_INSTALL = YES;
|
SKIP_INSTALL = YES;
|
||||||
|
SWIFT_OBJC_BRIDGING_HEADER = NebulaNetworkExtension/CtlInfo.h;
|
||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1,2";
|
TARGETED_DEVICE_FAMILY = "1,2";
|
||||||
};
|
};
|
||||||
|
@ -819,7 +832,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -829,12 +842,15 @@
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Flutter",
|
"$(PROJECT_DIR)/Flutter",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.40;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
@ -852,7 +868,7 @@
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 11;
|
CURRENT_PROJECT_VERSION = 4;
|
||||||
DEVELOPMENT_TEAM = 576H3XS7FP;
|
DEVELOPMENT_TEAM = 576H3XS7FP;
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
FRAMEWORK_SEARCH_PATHS = (
|
FRAMEWORK_SEARCH_PATHS = (
|
||||||
|
@ -862,12 +878,15 @@
|
||||||
);
|
);
|
||||||
INFOPLIST_FILE = Runner/Info.plist;
|
INFOPLIST_FILE = Runner/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
IPHONEOS_DEPLOYMENT_TARGET = 14.0;
|
||||||
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
LIBRARY_SEARCH_PATHS = (
|
LIBRARY_SEARCH_PATHS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
"$(PROJECT_DIR)/Flutter",
|
"$(PROJECT_DIR)/Flutter",
|
||||||
);
|
);
|
||||||
MARKETING_VERSION = 0.0.40;
|
MARKETING_VERSION = 0.1.0;
|
||||||
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
PRODUCT_BUNDLE_IDENTIFIER = net.defined.mobileNebula;
|
||||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h";
|
||||||
|
|
|
@ -1,10 +1,28 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<Scheme
|
<Scheme
|
||||||
LastUpgradeVersion = "1020"
|
LastUpgradeVersion = "1300"
|
||||||
version = "1.3">
|
version = "1.7">
|
||||||
<BuildAction
|
<BuildAction
|
||||||
parallelizeBuildables = "YES"
|
parallelizeBuildables = "YES"
|
||||||
buildImplicitDependencies = "YES">
|
buildImplicitDependencies = "YES">
|
||||||
|
<PreActions>
|
||||||
|
<ExecutionAction
|
||||||
|
ActionType = "Xcode.IDEStandardExecutionActionsCore.ExecutionActionType.ShellScriptAction">
|
||||||
|
<ActionContent
|
||||||
|
title = "Run Script"
|
||||||
|
scriptText = "cd "$PROJECT_DIR"/.. ./gen-artifacts.sh ios ">
|
||||||
|
<EnvironmentBuildable>
|
||||||
|
<BuildableReference
|
||||||
|
BuildableIdentifier = "primary"
|
||||||
|
BlueprintIdentifier = "97C146ED1CF9000F007C117D"
|
||||||
|
BuildableName = "Runner.app"
|
||||||
|
BlueprintName = "Runner"
|
||||||
|
ReferencedContainer = "container:Runner.xcodeproj">
|
||||||
|
</BuildableReference>
|
||||||
|
</EnvironmentBuildable>
|
||||||
|
</ActionContent>
|
||||||
|
</ExecutionAction>
|
||||||
|
</PreActions>
|
||||||
<BuildActionEntries>
|
<BuildActionEntries>
|
||||||
<BuildActionEntry
|
<BuildActionEntry
|
||||||
buildForTesting = "YES"
|
buildForTesting = "YES"
|
||||||
|
|
|
@ -0,0 +1,54 @@
|
||||||
|
import MobileNebula
|
||||||
|
|
||||||
|
enum APIClientError: Error {
|
||||||
|
case invalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
class APIClient {
|
||||||
|
let apiClient: MobileNebulaAPIClient
|
||||||
|
let json = JSONDecoder()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
let packageInfo = PackageInfo()
|
||||||
|
apiClient = MobileNebulaNewAPIClient("MobileNebula/\(packageInfo.getVersion()) (iOS \(packageInfo.getSystemVersion()))")!
|
||||||
|
}
|
||||||
|
|
||||||
|
func enroll(code: String) throws -> IncomingSite {
|
||||||
|
let res = try apiClient.enroll(code)
|
||||||
|
return try decodeIncomingSite(jsonSite: res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
func tryUpdate(siteName: String, hostID: String, privateKey: String, counter: Int, trustedKeys: String) throws -> IncomingSite? {
|
||||||
|
let res: MobileNebulaTryUpdateResult
|
||||||
|
do {
|
||||||
|
res = try apiClient.tryUpdate(
|
||||||
|
siteName,
|
||||||
|
hostID: hostID,
|
||||||
|
privateKey: privateKey,
|
||||||
|
counter: counter,
|
||||||
|
trustedKeys: trustedKeys)
|
||||||
|
} catch {
|
||||||
|
// type information from Go is not available, use string matching instead
|
||||||
|
if (error.localizedDescription == "invalid credentials") {
|
||||||
|
throw APIClientError.invalidCredentials
|
||||||
|
}
|
||||||
|
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
|
||||||
|
if (res.fetchedUpdate) {
|
||||||
|
return try decodeIncomingSite(jsonSite: res.site)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func decodeIncomingSite(jsonSite: String) throws -> IncomingSite {
|
||||||
|
do {
|
||||||
|
return try json.decode(IncomingSite.self, from: jsonSite.data(using: .utf8)!)
|
||||||
|
} catch {
|
||||||
|
print("decodeIncomingSite: \(error)")
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -14,7 +14,11 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
|
|
||||||
@UIApplicationMain
|
@UIApplicationMain
|
||||||
@objc class AppDelegate: FlutterAppDelegate {
|
@objc class AppDelegate: FlutterAppDelegate {
|
||||||
|
private let dnUpdater = DNUpdater()
|
||||||
|
private let apiClient = APIClient()
|
||||||
private var sites: Sites?
|
private var sites: Sites?
|
||||||
|
private var ui: FlutterMethodChannel?
|
||||||
|
|
||||||
|
|
||||||
override func application(
|
override func application(
|
||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
|
@ -22,18 +26,35 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
GeneratedPluginRegistrant.register(with: self)
|
GeneratedPluginRegistrant.register(with: self)
|
||||||
|
|
||||||
|
|
||||||
|
dnUpdater.updateAllLoop { site in
|
||||||
|
// Signal the site has changed in case the current site details screen is active
|
||||||
|
let container = self.sites?.getContainer(id: site.id)
|
||||||
|
if (container != nil) {
|
||||||
|
// Update references to the site with the new site config
|
||||||
|
container!.site = site
|
||||||
|
container!.updater.update(connected: site.connected ?? false, replaceSite: site)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal to the main screen to reload
|
||||||
|
self.ui?.invokeMethod("refreshSites", arguments: nil)
|
||||||
|
}
|
||||||
|
|
||||||
guard let controller = window?.rootViewController as? FlutterViewController else {
|
guard let controller = window?.rootViewController as? FlutterViewController else {
|
||||||
fatalError("rootViewController is not type FlutterViewController")
|
fatalError("rootViewController is not type FlutterViewController")
|
||||||
}
|
}
|
||||||
|
|
||||||
sites = Sites(messenger: controller.binaryMessenger)
|
sites = Sites(messenger: controller.binaryMessenger)
|
||||||
let channel = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
|
ui = FlutterMethodChannel(name: ChannelName.vpn, binaryMessenger: controller.binaryMessenger)
|
||||||
|
|
||||||
channel.setMethodCallHandler({(call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in
|
ui!.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)
|
||||||
|
@ -47,9 +68,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
@ -71,6 +89,21 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
return result(json)
|
return result(json)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func nebulaVerifyCertAndKey(call: FlutterMethodCall, result: FlutterResult) {
|
||||||
|
guard let args = call.arguments as? Dictionary<String, String> else { return result(NoArgumentsError()) }
|
||||||
|
guard let cert = args["cert"] else { return result(MissingArgumentError(message: "cert is a required argument")) }
|
||||||
|
guard let key = args["key"] else { return result(MissingArgumentError(message: "key is a required argument")) }
|
||||||
|
|
||||||
|
var err: NSError?
|
||||||
|
var validd: ObjCBool = false
|
||||||
|
let valid = MobileNebulaVerifyCertAndKey(cert, key, &validd, &err)
|
||||||
|
if (err != nil) {
|
||||||
|
return result(CallFailedError(message: "Error while verifying certificate and private key", details: err!.localizedDescription))
|
||||||
|
}
|
||||||
|
|
||||||
|
return result(valid)
|
||||||
|
}
|
||||||
|
|
||||||
func nebulaGenerateKeyPair(result: FlutterResult) {
|
func nebulaGenerateKeyPair(result: FlutterResult) {
|
||||||
var err: NSError?
|
var err: NSError?
|
||||||
let kp = MobileNebulaGenerateKeyPair(&err)
|
let kp = MobileNebulaGenerateKeyPair(&err)
|
||||||
|
@ -93,6 +126,25 @@ func MissingArgumentError(message: String, details: Any?) -> FlutterError {
|
||||||
return result(yaml)
|
return result(yaml)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dnEnroll(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
||||||
|
guard let code = call.arguments as? String else { return result(NoArgumentsError()) }
|
||||||
|
|
||||||
|
do {
|
||||||
|
let site = try apiClient.enroll(code: code)
|
||||||
|
|
||||||
|
let oldSite = self.sites?.getSite(id: site.id)
|
||||||
|
site.save(manager: oldSite?.manager) { error in
|
||||||
|
if (error != nil) {
|
||||||
|
return result(CallFailedError(message: "Failed to enroll", details: error!.localizedDescription))
|
||||||
|
}
|
||||||
|
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return result(CallFailedError(message: "Error from DN api", details: error.localizedDescription))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func listSites(result: @escaping FlutterResult) {
|
func listSites(result: @escaping FlutterResult) {
|
||||||
self.sites?.loadSites { (sites, err) -> () in
|
self.sites?.loadSites { (sites, err) -> () in
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
|
@ -132,7 +184,9 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
result(nil)
|
self.sites?.loadSites { _, _ in
|
||||||
|
result(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="21225" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||||
|
<device id="retina6_0" orientation="portrait" appearance="light"/>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
<deployment identifier="iOS"/>
|
<deployment identifier="iOS"/>
|
||||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="21207"/>
|
||||||
|
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
<scenes>
|
<scenes>
|
||||||
<!--Flutter View Controller-->
|
<!--Flutter View Controller-->
|
||||||
|
@ -14,13 +16,14 @@
|
||||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||||
</layoutGuides>
|
</layoutGuides>
|
||||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
<rect key="frame" x="0.0" y="0.0" width="390" height="844"/>
|
||||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||||
</view>
|
</view>
|
||||||
</viewController>
|
</viewController>
|
||||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||||
</objects>
|
</objects>
|
||||||
|
<point key="canvasLocation" x="-16" y="-40"/>
|
||||||
</scene>
|
</scene>
|
||||||
</scenes>
|
</scenes>
|
||||||
</document>
|
</document>
|
||||||
|
|
|
@ -0,0 +1,136 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class DNUpdater {
|
||||||
|
private let apiClient = APIClient()
|
||||||
|
private let timer = RepeatingTimer(timeInterval: 15 * 60) // 15 * 60 is 15 minutes
|
||||||
|
|
||||||
|
func updateAll(onUpdate: @escaping (Site) -> ()) {
|
||||||
|
_ = SiteList{ (sites, _) -> () in
|
||||||
|
// NEVPN seems to force us onto the main thread and we are about to make network calls that
|
||||||
|
// could block for a while. Push ourselves onto another thread to avoid blocking the UI.
|
||||||
|
Task.detached(priority: .userInitiated) {
|
||||||
|
sites?.values.forEach { site in
|
||||||
|
if (site.connected == true) {
|
||||||
|
// The vpn service is in charge of updating the currently connected site
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.updateSite(site: site, onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateAllLoop(onUpdate: @escaping (Site) -> ()) {
|
||||||
|
timer.eventHandler = {
|
||||||
|
self.updateAll(onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSingleLoop(site: Site, onUpdate: @escaping (Site) -> ()) {
|
||||||
|
timer.eventHandler = {
|
||||||
|
self.updateSite(site: site, onUpdate: onUpdate)
|
||||||
|
}
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateSite(site: Site, onUpdate: @escaping (Site) -> ()) {
|
||||||
|
do {
|
||||||
|
if (!site.managed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let credentials = try site.getDNCredentials()
|
||||||
|
|
||||||
|
let newSite: IncomingSite?
|
||||||
|
do {
|
||||||
|
newSite = try apiClient.tryUpdate(
|
||||||
|
siteName: site.name,
|
||||||
|
hostID: credentials.hostID,
|
||||||
|
privateKey: credentials.privateKey,
|
||||||
|
counter: credentials.counter,
|
||||||
|
trustedKeys: credentials.trustedKeys
|
||||||
|
)
|
||||||
|
} catch (APIClientError.invalidCredentials) {
|
||||||
|
if (!credentials.invalid) {
|
||||||
|
try site.invalidateDNCredentials()
|
||||||
|
print("Invalidated credentials in site \(site.name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newSite?.save(manager: nil) { error in
|
||||||
|
if (error != nil) {
|
||||||
|
print("failed to save update: \(error!.localizedDescription)")
|
||||||
|
} else {
|
||||||
|
onUpdate(Site(incoming: newSite!))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (credentials.invalid) {
|
||||||
|
try site.validateDNCredentials()
|
||||||
|
print("Revalidated credentials in site \(site.name)")
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch {
|
||||||
|
print("Error while updating \(site.name): \(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// From https://medium.com/over-engineering/a-background-repeating-timer-in-swift-412cecfd2ef9
|
||||||
|
class RepeatingTimer {
|
||||||
|
|
||||||
|
let timeInterval: TimeInterval
|
||||||
|
|
||||||
|
init(timeInterval: TimeInterval) {
|
||||||
|
self.timeInterval = timeInterval
|
||||||
|
}
|
||||||
|
|
||||||
|
private lazy var timer: DispatchSourceTimer = {
|
||||||
|
let t = DispatchSource.makeTimerSource()
|
||||||
|
t.schedule(deadline: .now(), repeating: self.timeInterval)
|
||||||
|
t.setEventHandler(handler: { [weak self] in
|
||||||
|
self?.eventHandler?()
|
||||||
|
})
|
||||||
|
return t
|
||||||
|
}()
|
||||||
|
|
||||||
|
var eventHandler: (() -> Void)?
|
||||||
|
|
||||||
|
private enum State {
|
||||||
|
case suspended
|
||||||
|
case resumed
|
||||||
|
}
|
||||||
|
|
||||||
|
private var state: State = .suspended
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
timer.setEventHandler {}
|
||||||
|
timer.cancel()
|
||||||
|
/*
|
||||||
|
If the timer is suspended, calling cancel without resuming
|
||||||
|
triggers a crash. This is documented here https://forums.developer.apple.com/thread/15902
|
||||||
|
*/
|
||||||
|
resume()
|
||||||
|
eventHandler = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func resume() {
|
||||||
|
if state == .resumed {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .resumed
|
||||||
|
timer.resume()
|
||||||
|
}
|
||||||
|
|
||||||
|
func suspend() {
|
||||||
|
if state == .suspended {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
state = .suspended
|
||||||
|
timer.suspend()
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,8 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>CADisableMinimumFrameDurationOnPhone</key>
|
||||||
|
<true/>
|
||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
|
@ -18,8 +20,23 @@
|
||||||
<string>$(MARKETING_VERSION)</string>
|
<string>$(MARKETING_VERSION)</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
|
<key>CFBundleURLTypes</key>
|
||||||
|
<array>
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleTypeRole</key>
|
||||||
|
<string>Viewer</string>
|
||||||
|
<key>CFBundleURLName</key>
|
||||||
|
<string>mailto</string>
|
||||||
|
<key>CFBundleURLSchemes</key>
|
||||||
|
<array>
|
||||||
|
<string>mailto</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>$(CURRENT_PROJECT_VERSION)</string>
|
<string>$(CURRENT_PROJECT_VERSION)</string>
|
||||||
|
<key>FlutterDeepLinkingEnabled</key>
|
||||||
|
<true/>
|
||||||
<key>ITSAppUsesNonExemptEncryption</key>
|
<key>ITSAppUsesNonExemptEncryption</key>
|
||||||
<false/>
|
<false/>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
class PackageInfo {
|
||||||
|
func getVersion() -> String {
|
||||||
|
let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ??
|
||||||
|
"unknown"
|
||||||
|
let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String
|
||||||
|
|
||||||
|
if (buildNumber == nil) {
|
||||||
|
return version
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(version)-\(buildNumber!)"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getName() -> String {
|
||||||
|
return Bundle.main.infoDictionary?["CFBundleDisplayName"] as? String ??
|
||||||
|
Bundle.main.infoDictionary?["CFBundleName"] as? String ??
|
||||||
|
"Nebula"
|
||||||
|
}
|
||||||
|
|
||||||
|
func getSystemVersion() -> String {
|
||||||
|
let osVersion = ProcessInfo.processInfo.operatingSystemVersion
|
||||||
|
return "\(osVersion.majorVersion).\(osVersion.minorVersion).\(osVersion.patchVersion)"
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,6 +2,10 @@
|
||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
|
<key>com.apple.developer.associated-domains</key>
|
||||||
|
<array>
|
||||||
|
<string>applinks:api.defined.net</string>
|
||||||
|
</array>
|
||||||
<key>com.apple.developer.networking.networkextension</key>
|
<key>com.apple.developer.networking.networkextension</key>
|
||||||
<array>
|
<array>
|
||||||
<string>packet-tunnel-provider</string>
|
<string>packet-tunnel-provider</string>
|
||||||
|
|
|
@ -1,150 +0,0 @@
|
||||||
// Basis of this code comes from https://github.com/lubritto/flutter_share
|
|
||||||
|
|
||||||
import Flutter
|
|
||||||
import UIKit
|
|
||||||
|
|
||||||
public class Share {
|
|
||||||
public static func share(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
let args = call.arguments as? [String: Any?]
|
|
||||||
|
|
||||||
let title = args!["title"] as? String
|
|
||||||
let text = args!["text"] as? String
|
|
||||||
let filename = args!["filename"] as? String
|
|
||||||
let tmpDirURL = FileManager.default.temporaryDirectory
|
|
||||||
|
|
||||||
if (filename == nil || filename!.isEmpty) {
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
let tmpFile = tmpDirURL.appendingPathComponent(filename!)
|
|
||||||
do {
|
|
||||||
try text?.write(to: tmpFile, atomically: true, encoding: .utf8)
|
|
||||||
} catch {
|
|
||||||
//TODO: return error
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(title: title, file: tmpFile) { pass in
|
|
||||||
let fm = FileManager()
|
|
||||||
do {
|
|
||||||
try fm.removeItem(at: tmpFile)
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
return result(pass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public static func shareFile(call: FlutterMethodCall, result: @escaping FlutterResult) {
|
|
||||||
let args = call.arguments as? [String: Any?]
|
|
||||||
|
|
||||||
let title = args!["title"] as? String
|
|
||||||
let filePath = args!["filePath"] as? String
|
|
||||||
let filename = args!["filename"] as? String
|
|
||||||
|
|
||||||
if (filePath == nil || filePath!.isEmpty) {
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
var tmpFile: URL?
|
|
||||||
let fm = FileManager()
|
|
||||||
var realPath = URL(fileURLWithPath: filePath!)
|
|
||||||
|
|
||||||
if (filename != nil && !filename!.isEmpty) {
|
|
||||||
tmpFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename!)
|
|
||||||
|
|
||||||
do {
|
|
||||||
try fm.linkItem(at: URL(fileURLWithPath: filePath!), to: tmpFile!)
|
|
||||||
} catch {
|
|
||||||
//TODO: return error
|
|
||||||
return result(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
realPath = tmpFile!
|
|
||||||
}
|
|
||||||
|
|
||||||
pop(title: title, file: realPath) { pass in
|
|
||||||
if (tmpFile != nil) {
|
|
||||||
do {
|
|
||||||
try fm.removeItem(at: tmpFile!)
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
result(pass)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func pop(title: String?, file: URL, completion: @escaping ((Bool) -> Void)) {
|
|
||||||
if (title == nil || title!.isEmpty) {
|
|
||||||
return completion(false)
|
|
||||||
}
|
|
||||||
|
|
||||||
let activityViewController = UIActivityViewController(activityItems: [ShareCopy(file: file)], applicationActivities: nil)
|
|
||||||
|
|
||||||
activityViewController.completionWithItemsHandler = {(activityType: UIActivity.ActivityType?, completed: Bool, returnedItems: [Any]?, error: Error?) in
|
|
||||||
completion(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subject
|
|
||||||
activityViewController.setValue(title, forKeyPath: "subject")
|
|
||||||
|
|
||||||
// For iPads, fix issue where Exception is thrown by using a popup instead
|
|
||||||
if UIDevice.current.userInterfaceIdiom == .pad {
|
|
||||||
activityViewController.popoverPresentationController?.sourceView = UIApplication.topViewController()?.view
|
|
||||||
if let view = UIApplication.topViewController()?.view {
|
|
||||||
activityViewController.popoverPresentationController?.permittedArrowDirections = []
|
|
||||||
activityViewController.popoverPresentationController?.sourceRect = CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
UIApplication.topViewController()?.present(activityViewController, animated: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extension UIApplication {
|
|
||||||
class func topViewController(controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController) -> UIViewController? {
|
|
||||||
if let navigationController = controller as? UINavigationController {
|
|
||||||
return topViewController(controller: navigationController.visibleViewController)
|
|
||||||
}
|
|
||||||
if let tabController = controller as? UITabBarController {
|
|
||||||
if let selected = tabController.selectedViewController {
|
|
||||||
return topViewController(controller: selected)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if let presented = controller?.presentedViewController {
|
|
||||||
return topViewController(controller: presented)
|
|
||||||
}
|
|
||||||
return controller
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ShareCopy: UIActivityItemProvider {
|
|
||||||
private let file: URL
|
|
||||||
private let content: String
|
|
||||||
|
|
||||||
init(file: URL) {
|
|
||||||
self.file = file
|
|
||||||
do {
|
|
||||||
self.content = try String.init(contentsOf: file)
|
|
||||||
} catch {
|
|
||||||
self.content = "Error"
|
|
||||||
}
|
|
||||||
|
|
||||||
// the type of the placeholder item is used to
|
|
||||||
// display correct activity types by UIActivityControler
|
|
||||||
super.init(placeholderItem: self.content)
|
|
||||||
}
|
|
||||||
|
|
||||||
override var item: Any {
|
|
||||||
get {
|
|
||||||
guard let activityType = activityType else {
|
|
||||||
return file
|
|
||||||
}
|
|
||||||
|
|
||||||
switch activityType {
|
|
||||||
case .copyToPasteboard: return content
|
|
||||||
default: return file
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -12,7 +12,7 @@ class SiteContainer {
|
||||||
}
|
}
|
||||||
|
|
||||||
class Sites {
|
class Sites {
|
||||||
private var sites = [String: SiteContainer]()
|
private var containers = [String: SiteContainer]()
|
||||||
private var messenger: FlutterBinaryMessenger?
|
private var messenger: FlutterBinaryMessenger?
|
||||||
|
|
||||||
init(messenger: FlutterBinaryMessenger?) {
|
init(messenger: FlutterBinaryMessenger?) {
|
||||||
|
@ -20,77 +20,44 @@ class Sites {
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
func loadSites(completion: @escaping ([String: Site]?, Error?) -> ()) {
|
||||||
#if targetEnvironment(simulator)
|
_ = SiteList { (sites, err) in
|
||||||
let fileManager = FileManager.default
|
|
||||||
let documentsURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites")
|
|
||||||
var configPaths: [URL]
|
|
||||||
|
|
||||||
do {
|
|
||||||
if (!fileManager.fileExists(atPath: documentsURL.absoluteString)) {
|
|
||||||
try fileManager.createDirectory(at: documentsURL, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
configPaths = try fileManager.contentsOfDirectory(at: documentsURL, includingPropertiesForKeys: nil)
|
|
||||||
} catch {
|
|
||||||
return completion(nil, error)
|
|
||||||
}
|
|
||||||
|
|
||||||
configPaths.forEach { path in
|
|
||||||
do {
|
|
||||||
let config = try Data(contentsOf: path)
|
|
||||||
let decoder = JSONDecoder()
|
|
||||||
let incoming = try decoder.decode(IncomingSite.self, from: config)
|
|
||||||
let site = try Site(incoming: incoming)
|
|
||||||
let updater = SiteUpdater(messenger: self.messenger!, site: site)
|
|
||||||
self.sites[site.id] = SiteContainer(site: site, updater: updater)
|
|
||||||
} catch {
|
|
||||||
print(error)
|
|
||||||
// try? fileManager.removeItem(at: path)
|
|
||||||
print("Deleted non conforming site \(path)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let justSites = self.sites.mapValues {
|
|
||||||
return $0.site
|
|
||||||
}
|
|
||||||
completion(justSites, nil)
|
|
||||||
|
|
||||||
#else
|
|
||||||
NETunnelProviderManager.loadAllFromPreferences() { newManagers, err in
|
|
||||||
if (err != nil) {
|
if (err != nil) {
|
||||||
return completion(nil, err)
|
return completion(nil, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
newManagers?.forEach { manager in
|
sites?.values.forEach{ site in
|
||||||
do {
|
var updater = self.containers[site.id]?.updater
|
||||||
let site = try Site(manager: manager)
|
if (updater != nil) {
|
||||||
// Load the private key to make sure we can
|
updater!.setSite(site: site)
|
||||||
_ = try site.getKey()
|
} else {
|
||||||
let updater = SiteUpdater(messenger: self.messenger!, site: site)
|
updater = SiteUpdater(messenger: self.messenger!, site: site)
|
||||||
self.sites[site.id] = SiteContainer(site: site, updater: updater)
|
|
||||||
} catch {
|
|
||||||
//TODO: notify the user about this
|
|
||||||
print("Deleted non conforming site \(manager) \(error)")
|
|
||||||
manager.removeFromPreferences()
|
|
||||||
}
|
}
|
||||||
|
self.containers[site.id] = SiteContainer(site: site, updater: updater!)
|
||||||
}
|
}
|
||||||
|
|
||||||
let justSites = self.sites.mapValues {
|
let justSites = self.containers.mapValues {
|
||||||
return $0.site
|
return $0.site
|
||||||
}
|
}
|
||||||
completion(justSites, nil)
|
completion(justSites, nil)
|
||||||
}
|
}
|
||||||
#endif
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
|
func deleteSite(id: String, callback: @escaping (Error?) -> ()) {
|
||||||
if let site = self.sites.removeValue(forKey: id) {
|
if let site = self.containers.removeValue(forKey: id) {
|
||||||
#if targetEnvironment(simulator)
|
_ = KeyChain.delete(key: "\(site.site.id).dnCredentials")
|
||||||
let fileManager = FileManager.default
|
_ = KeyChain.delete(key: "\(site.site.id).key")
|
||||||
let sitePath = fileManager.urls(for: .documentDirectory, in: .userDomainMask)[0].appendingPathComponent("sites").appendingPathComponent(site.site.id)
|
|
||||||
try? fileManager.removeItem(at: sitePath)
|
do {
|
||||||
#else
|
let fileManager = FileManager.default
|
||||||
_ = KeyChain.delete(key: site.site.id)
|
let siteDir = try SiteList.getSiteDir(id: 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -99,15 +66,15 @@ class Sites {
|
||||||
}
|
}
|
||||||
|
|
||||||
func getSite(id: String) -> Site? {
|
func getSite(id: String) -> Site? {
|
||||||
return self.sites[id]?.site
|
return self.containers[id]?.site
|
||||||
}
|
}
|
||||||
|
|
||||||
func getUpdater(id: String) -> SiteUpdater? {
|
func getUpdater(id: String) -> SiteUpdater? {
|
||||||
return self.sites[id]?.updater
|
return self.containers[id]?.updater
|
||||||
}
|
}
|
||||||
|
|
||||||
func getContainer(id: String) -> SiteContainer? {
|
func getContainer(id: String) -> SiteContainer? {
|
||||||
return self.sites[id]
|
return self.containers[id]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -117,39 +84,72 @@ 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 connected = self.site.connected
|
let oldConnected = 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! && connected != self.site.connected && self.startFunc != nil {
|
if self.site.connected! && oldConnected != self.site.connected && self.startFunc != nil {
|
||||||
self.startFunc!()
|
self.startFunc!()
|
||||||
self.startFunc = nil
|
self.startFunc = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
let d: Dictionary<String, Any> = [
|
self.update(connected: self.site.connected!)
|
||||||
"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,11 +159,27 @@ 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) {
|
func update(connected: Bool, replaceSite: Site? = nil) {
|
||||||
let d: Dictionary<String, Any> = [
|
if (replaceSite != nil) {
|
||||||
"connected": connected,
|
site = replaceSite!
|
||||||
"status": connected ? "Connected" : "Disconnected",
|
}
|
||||||
]
|
site.connected = connected
|
||||||
self.eventSink?(d)
|
site.status = connected ? "Connected" : "Disconnected"
|
||||||
|
|
||||||
|
let encoder = JSONEncoder()
|
||||||
|
let data = try! encoder.encode(site)
|
||||||
|
self.eventSink?(String(data: data, encoding: .utf8))
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configUpdated() {
|
||||||
|
if self.site.connected != true {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let newSite = try? Site(manager: self.site.manager!) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
self.update(connected: newSite.connected ?? false, replaceSite: newSite)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
app_identifier("net.defined.mobileNebula") # The bundle identifier of your app
|
||||||
|
itc_team_id("633953") # App Store Connect Team ID
|
||||||
|
team_id("576H3XS7FP") # Developer Portal Team ID
|
||||||
|
|
||||||
|
# For more information about the Appfile, see:
|
||||||
|
# https://docs.fastlane.tools/advanced/#appfile
|
|
@ -0,0 +1,84 @@
|
||||||
|
# This file contains the fastlane.tools configuration
|
||||||
|
# You can find the documentation at https://docs.fastlane.tools
|
||||||
|
#
|
||||||
|
# For a list of all available actions, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/actions
|
||||||
|
#
|
||||||
|
# For a list of all available plugins, check out
|
||||||
|
#
|
||||||
|
# https://docs.fastlane.tools/plugins/available-plugins
|
||||||
|
#
|
||||||
|
|
||||||
|
# Uncomment the line if you want fastlane to automatically update itself
|
||||||
|
# update_fastlane
|
||||||
|
|
||||||
|
default_platform(:ios)
|
||||||
|
|
||||||
|
platform :ios do
|
||||||
|
desc "Push a new beta build to TestFlight"
|
||||||
|
|
||||||
|
lane :build do
|
||||||
|
# Do some things like setting up a temporary keystore to host secrets in CI
|
||||||
|
setup_ci
|
||||||
|
|
||||||
|
# # Authenticate with Apple app store connect
|
||||||
|
# app_store_connect_api_key
|
||||||
|
|
||||||
|
# Change signing behavior to work in CI
|
||||||
|
update_code_signing_settings(
|
||||||
|
# Automatic signing seems to be a good thing to have on in dev but will not work in CI
|
||||||
|
use_automatic_signing: false,
|
||||||
|
# The default value for this is iOS Development which is not appropriate for release
|
||||||
|
code_sign_identity: "Apple Distribution",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Find our signing certs and profiles, these come from a private repository and managed by `fastlane match`
|
||||||
|
match(type: 'appstore', app_identifier: ["net.defined.mobileNebula","net.defined.mobileNebula.NebulaNetworkExtension"], readonly: true)
|
||||||
|
|
||||||
|
# Update our main program to have the correct provisioning profile from Apple
|
||||||
|
update_project_provisioning(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
target_filter: "Runner",
|
||||||
|
# This comes from match() above
|
||||||
|
profile:ENV["sigh_net.defined.mobileNebula_appstore_profile-path"],
|
||||||
|
build_configuration: "Release"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update our network extension to have the correct provisioning profile from Apple
|
||||||
|
update_project_provisioning(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
target_filter: "NebulaNetworkExtension",
|
||||||
|
# This comes from match() above
|
||||||
|
profile:ENV["sigh_net.defined.mobileNebula.NebulaNetworkExtension_appstore_profile-path"],
|
||||||
|
build_configuration: "Release"
|
||||||
|
)
|
||||||
|
|
||||||
|
increment_build_number(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
build_number: ENV['BUILD_NUMBER']
|
||||||
|
)
|
||||||
|
|
||||||
|
increment_version_number(
|
||||||
|
xcodeproj: "Runner.xcodeproj",
|
||||||
|
version_number: ENV['BUILD_NAME']
|
||||||
|
)
|
||||||
|
|
||||||
|
build_app(
|
||||||
|
output_name: "MobileNebula.ipa",
|
||||||
|
workspace: "Runner.xcworkspace",
|
||||||
|
scheme: "Runner",
|
||||||
|
export_method: "app-store",
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
lane :release do
|
||||||
|
# Do some things like setting up a temporary keystore to host secrets in CI
|
||||||
|
setup_ci
|
||||||
|
|
||||||
|
# Authenticate with Apple app store connect
|
||||||
|
app_store_connect_api_key
|
||||||
|
|
||||||
|
upload_to_testflight(skip_waiting_for_build_processing: true)
|
||||||
|
end
|
||||||
|
end
|
|
@ -0,0 +1,14 @@
|
||||||
|
git_url("https://github.com/DefinedNet/mobile_nebula_match.git")
|
||||||
|
|
||||||
|
storage_mode("git")
|
||||||
|
|
||||||
|
type("appstore") # The default type, can be: appstore, adhoc, enterprise or development
|
||||||
|
|
||||||
|
app_identifier(["net.defined.mobileNebula", "net.defined.mobileNebula.NebulaNetworkExtension"])
|
||||||
|
|
||||||
|
# username("user@fastlane.tools") # Your Apple Developer Portal username
|
||||||
|
|
||||||
|
# For all available options run `fastlane match --help`
|
||||||
|
# Remove the # in the beginning of the line to enable the other options
|
||||||
|
|
||||||
|
# The docs are available on https://docs.fastlane.tools/actions/match
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
import 'package:mobile_nebula/models/CIDR.dart';
|
import 'package:mobile_nebula/models/CIDR.dart';
|
||||||
import '../services/utils.dart';
|
import '../services/utils.dart';
|
||||||
|
@ -10,7 +8,7 @@ import 'IPField.dart';
|
||||||
//TODO: Support initialValue
|
//TODO: Support initialValue
|
||||||
class CIDRField extends StatefulWidget {
|
class CIDRField extends StatefulWidget {
|
||||||
const CIDRField({
|
const CIDRField({
|
||||||
Key key,
|
Key? key,
|
||||||
this.ipHelp = "ip address",
|
this.ipHelp = "ip address",
|
||||||
this.autoFocus = false,
|
this.autoFocus = false,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
|
@ -23,12 +21,12 @@ class CIDRField extends StatefulWidget {
|
||||||
|
|
||||||
final String ipHelp;
|
final String ipHelp;
|
||||||
final bool autoFocus;
|
final bool autoFocus;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final ValueChanged<CIDR> onChanged;
|
final ValueChanged<CIDR>? onChanged;
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController bitsController;
|
final TextEditingController? bitsController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_CIDRFieldState createState() => _CIDRFieldState();
|
_CIDRFieldState createState() => _CIDRFieldState();
|
||||||
|
@ -46,7 +44,7 @@ class _CIDRFieldState extends State<CIDRField> {
|
||||||
void initState() {
|
void initState() {
|
||||||
//TODO: this won't track external controller changes appropriately
|
//TODO: this won't track external controller changes appropriately
|
||||||
cidr.ip = widget.ipController?.text ?? "";
|
cidr.ip = widget.ipController?.text ?? "";
|
||||||
cidr.bits = int.tryParse(widget.bitsController?.text ?? "");
|
cidr.bits = int.tryParse(widget.bitsController?.text ?? "") ?? 0;
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,42 +54,50 @@ 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) {
|
||||||
cidr.bits = int.tryParse(val ?? "");
|
if (widget.onChanged == null) {
|
||||||
widget.onChanged(cidr);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cidr.ip = val;
|
||||||
|
widget.onChanged!(cidr);
|
||||||
},
|
},
|
||||||
maxLength: 2,
|
controller: widget.ipController,
|
||||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
|
))),
|
||||||
textInputAction: widget.textInputAction ?? TextInputAction.done,
|
Text("/"),
|
||||||
placeholder: 'bits',
|
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) {
|
||||||
|
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
|
||||||
|
|
|
@ -1,21 +1,20 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/CIDRField.dart';
|
import 'package:mobile_nebula/components/CIDRField.dart';
|
||||||
import 'package:mobile_nebula/models/CIDR.dart';
|
import 'package:mobile_nebula/models/CIDR.dart';
|
||||||
import 'package:mobile_nebula/validators/ipValidator.dart';
|
import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
|
|
||||||
class CIDRFormField extends FormField<CIDR> {
|
class CIDRFormField extends FormField<CIDR> {
|
||||||
//TODO: onSaved, validator, autovalidate, enabled?
|
//TODO: onSaved, validator, auto-validate, enabled?
|
||||||
CIDRFormField({
|
CIDRFormField({
|
||||||
Key key,
|
Key? key,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
enableIPV6 = false,
|
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(
|
||||||
|
@ -31,14 +30,14 @@ class CIDRFormField extends FormField<CIDR> {
|
||||||
return 'Please enter a valid ip address';
|
return 'Please enter a valid ip address';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cidr.bits == null || cidr.bits > 32 || cidr.bits < 0) {
|
if (cidr.bits > 32 || cidr.bits < 0) {
|
||||||
return "Please enter a valid number of bits";
|
return "Please enter a valid number of bits";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<CIDR> field) {
|
builder: (FormFieldState<CIDR> field) {
|
||||||
final _CIDRFormField state = field;
|
final _CIDRFormField state = field as _CIDRFormField;
|
||||||
|
|
||||||
void onChangedHandler(CIDR value) {
|
void onChangedHandler(CIDR value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -58,50 +57,50 @@ class CIDRFormField extends FormField<CIDR> {
|
||||||
bitsController: state._effectiveBitsController,
|
bitsController: state._effectiveBitsController,
|
||||||
),
|
),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(field.errorText,
|
? Text(field.errorText ?? "Unknown error",
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
||||||
textAlign: TextAlign.end)
|
textAlign: TextAlign.end)
|
||||||
: Container(height: 0)
|
: Container(height: 0)
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController bitsController;
|
final TextEditingController? bitsController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_CIDRFormField createState() => _CIDRFormField();
|
_CIDRFormField createState() => _CIDRFormField();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CIDRFormField extends FormFieldState<CIDR> {
|
class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
TextEditingController _ipController;
|
TextEditingController? _ipController = TextEditingController();
|
||||||
TextEditingController _bitsController;
|
TextEditingController? _bitsController = TextEditingController();
|
||||||
|
|
||||||
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController;
|
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController!;
|
||||||
TextEditingController get _effectiveBitsController => widget.bitsController ?? _bitsController;
|
TextEditingController get _effectiveBitsController => widget.bitsController ?? _bitsController!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
CIDRFormField get widget => super.widget;
|
CIDRFormField get widget => super.widget as CIDRFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.ipController == null) {
|
if (widget.ipController == null) {
|
||||||
_ipController = TextEditingController(text: widget.initialValue.ip);
|
_ipController = TextEditingController(text: widget.initialValue?.ip);
|
||||||
} else {
|
} else {
|
||||||
widget.ipController.addListener(_handleControllerChanged);
|
widget.ipController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.bitsController == null) {
|
if (widget.bitsController == null) {
|
||||||
_bitsController = TextEditingController(text: widget.initialValue?.bits?.toString() ?? "");
|
_bitsController = TextEditingController(text: widget.initialValue?.bits.toString() ?? "");
|
||||||
} else {
|
} else {
|
||||||
widget.bitsController.addListener(_handleControllerChanged);
|
widget.bitsController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(CIDRFormField oldWidget) {
|
void didUpdateWidget(CIDRFormField oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
var update = CIDR(ip: widget.ipController?.text, bits: int.tryParse(widget.bitsController?.text ?? "") ?? null);
|
var update = CIDR(ip: widget.ipController?.text ?? "", bits: int.tryParse(widget.bitsController?.text ?? "") ?? 0);
|
||||||
bool shouldUpdate = false;
|
bool shouldUpdate = false;
|
||||||
|
|
||||||
if (widget.ipController != oldWidget.ipController) {
|
if (widget.ipController != oldWidget.ipController) {
|
||||||
|
@ -109,12 +108,12 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
widget.ipController?.addListener(_handleControllerChanged);
|
widget.ipController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.ipController != null && widget.ipController == null) {
|
if (oldWidget.ipController != null && widget.ipController == null) {
|
||||||
_ipController = TextEditingController.fromValue(oldWidget.ipController.value);
|
_ipController = TextEditingController.fromValue(oldWidget.ipController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.ipController != null) {
|
if (widget.ipController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.ip = widget.ipController.text;
|
update.ip = widget.ipController!.text;
|
||||||
if (oldWidget.ipController == null) _ipController = null;
|
if (oldWidget.ipController == null) _ipController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -124,12 +123,12 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
widget.bitsController?.addListener(_handleControllerChanged);
|
widget.bitsController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.bitsController != null && widget.bitsController == null) {
|
if (oldWidget.bitsController != null && widget.bitsController == null) {
|
||||||
_bitsController = TextEditingController.fromValue(oldWidget.bitsController.value);
|
_bitsController = TextEditingController.fromValue(oldWidget.bitsController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.bitsController != null) {
|
if (widget.bitsController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.bits = int.parse(widget.bitsController.text);
|
update.bits = int.parse(widget.bitsController!.text);
|
||||||
if (oldWidget.bitsController == null) _bitsController = null;
|
if (oldWidget.bitsController == null) _bitsController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -150,8 +149,8 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveIPController.text = widget.initialValue.ip;
|
_effectiveIPController.text = widget.initialValue?.ip ?? "";
|
||||||
_effectiveBitsController.text = widget.initialValue.bits.toString();
|
_effectiveBitsController.text = widget.initialValue?.bits.toString() ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -164,7 +163,11 @@ class _CIDRFormField extends FormFieldState<CIDR> {
|
||||||
// example, the reset() method. In such cases, the FormField value will
|
// example, the reset() method. In such cases, the FormField value will
|
||||||
// already have been set.
|
// already have been set.
|
||||||
final effectiveBits = int.parse(_effectiveBitsController.text);
|
final effectiveBits = int.parse(_effectiveBitsController.text);
|
||||||
if (_effectiveIPController.text != value.ip || effectiveBits != value.bits) {
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_effectiveIPController.text != value!.ip || effectiveBits != value!.bits) {
|
||||||
didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits));
|
didChange(CIDR(ip: _effectiveIPController.text, bits: effectiveBits));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,12 +8,19 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
/// SimplePage with a form and built in validation and confirmation to discard changes if any are made
|
/// SimplePage with a form and built in validation and confirmation to discard changes if any are made
|
||||||
class FormPage extends StatefulWidget {
|
class FormPage extends StatefulWidget {
|
||||||
const FormPage(
|
const FormPage(
|
||||||
{Key key, this.title, @required this.child, @required this.onSave, @required this.changed, this.hideSave = false})
|
{Key? key,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
required this.onSave,
|
||||||
|
required this.changed,
|
||||||
|
this.hideSave = false,
|
||||||
|
this.scrollController})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String title;
|
final String title;
|
||||||
final Function onSave;
|
final Function onSave;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
final ScrollController? scrollController;
|
||||||
|
|
||||||
/// If you need the page to progress to a certain point before saving, control it here
|
/// If you need the page to progress to a certain point before saving, control it here
|
||||||
final bool hideSave;
|
final bool hideSave;
|
||||||
|
@ -50,7 +57,8 @@ class _FormPageState extends State<FormPage> {
|
||||||
child: SimplePage(
|
child: SimplePage(
|
||||||
leadingAction: _buildLeader(context),
|
leadingAction: _buildLeader(context),
|
||||||
trailingActions: _buildTrailer(context),
|
trailingActions: _buildTrailer(context),
|
||||||
title: widget.title,
|
scrollController: widget.scrollController,
|
||||||
|
title: Text(widget.title),
|
||||||
child: Form(
|
child: Form(
|
||||||
key: _formKey,
|
key: _formKey,
|
||||||
onChanged: () => setState(() {
|
onChanged: () => setState(() {
|
||||||
|
@ -82,11 +90,15 @@ class _FormPageState extends State<FormPage> {
|
||||||
Utils.trailingSaveWidget(
|
Utils.trailingSaveWidget(
|
||||||
context,
|
context,
|
||||||
() {
|
() {
|
||||||
if (!_formKey.currentState.validate()) {
|
if (_formKey.currentState == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_formKey.currentState.save();
|
if (!_formKey.currentState!.validate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_formKey.currentState!.save();
|
||||||
widget.onSave();
|
widget.onSave();
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
|
@ -1,7 +1,5 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
import 'package:mobile_nebula/models/IPAndPort.dart';
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import '../services/utils.dart';
|
import '../services/utils.dart';
|
||||||
|
@ -10,13 +8,13 @@ import 'IPField.dart';
|
||||||
//TODO: Support initialValue
|
//TODO: Support initialValue
|
||||||
class IPAndPortField extends StatefulWidget {
|
class IPAndPortField extends StatefulWidget {
|
||||||
const IPAndPortField({
|
const IPAndPortField({
|
||||||
Key key,
|
Key? key,
|
||||||
this.ipOnly = false,
|
this.ipOnly = false,
|
||||||
this.ipHelp = "ip address",
|
this.ipHelp = "ip address",
|
||||||
this.autoFocus = false,
|
this.autoFocus = false,
|
||||||
this.focusNode,
|
this.focusNode,
|
||||||
this.nextFocusNode,
|
this.nextFocusNode,
|
||||||
this.onChanged,
|
required this.onChanged,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
this.noBorder = false,
|
this.noBorder = false,
|
||||||
this.ipTextAlign,
|
this.ipTextAlign,
|
||||||
|
@ -27,14 +25,14 @@ class IPAndPortField extends StatefulWidget {
|
||||||
final String ipHelp;
|
final String ipHelp;
|
||||||
final bool ipOnly;
|
final bool ipOnly;
|
||||||
final bool autoFocus;
|
final bool autoFocus;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final ValueChanged<IPAndPort> onChanged;
|
final ValueChanged<IPAndPort> onChanged;
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final bool noBorder;
|
final bool noBorder;
|
||||||
final TextAlign ipTextAlign;
|
final TextAlign? ipTextAlign;
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController portController;
|
final TextEditingController? portController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_IPAndPortFieldState createState() => _IPAndPortFieldState();
|
_IPAndPortFieldState createState() => _IPAndPortFieldState();
|
||||||
|
@ -89,7 +87,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,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/models/IPAndPort.dart';
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
||||||
import 'package:mobile_nebula/validators/ipValidator.dart';
|
import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
|
@ -7,19 +6,19 @@ import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
import 'IPAndPortField.dart';
|
import 'IPAndPortField.dart';
|
||||||
|
|
||||||
class IPAndPortFormField extends FormField<IPAndPort> {
|
class IPAndPortFormField extends FormField<IPAndPort> {
|
||||||
//TODO: onSaved, validator, autovalidate, enabled?
|
//TODO: onSaved, validator, auto-validate, enabled?
|
||||||
IPAndPortFormField({
|
IPAndPortFormField({
|
||||||
Key key,
|
Key? key,
|
||||||
ipOnly = false,
|
ipOnly = false,
|
||||||
enableIPV6 = false,
|
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,
|
||||||
|
@ -37,14 +36,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;
|
final _IPAndPortFormField state = field as _IPAndPortFormField;
|
||||||
|
|
||||||
void onChangedHandler(IPAndPort value) {
|
void onChangedHandler(IPAndPort value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -68,42 +67,42 @@ class IPAndPortFormField extends FormField<IPAndPort> {
|
||||||
ipTextAlign: ipTextAlign,
|
ipTextAlign: ipTextAlign,
|
||||||
),
|
),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(field.errorText,
|
? Text(field.errorText!,
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13))
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13))
|
||||||
: Container(height: 0)
|
: Container(height: 0)
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController ipController;
|
final TextEditingController? ipController;
|
||||||
final TextEditingController portController;
|
final TextEditingController? portController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_IPAndPortFormField createState() => _IPAndPortFormField();
|
_IPAndPortFormField createState() => _IPAndPortFormField();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
TextEditingController _ipController;
|
TextEditingController? _ipController;
|
||||||
TextEditingController _portController;
|
TextEditingController? _portController;
|
||||||
|
|
||||||
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController;
|
TextEditingController get _effectiveIPController => widget.ipController ?? _ipController!;
|
||||||
TextEditingController get _effectivePortController => widget.portController ?? _portController;
|
TextEditingController get _effectivePortController => widget.portController ?? _portController!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IPAndPortFormField get widget => super.widget;
|
IPAndPortFormField get widget => super.widget as IPAndPortFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.ipController == null) {
|
if (widget.ipController == null) {
|
||||||
_ipController = TextEditingController(text: widget.initialValue.ip);
|
_ipController = TextEditingController(text: widget.initialValue?.ip ?? "");
|
||||||
} else {
|
} else {
|
||||||
widget.ipController.addListener(_handleControllerChanged);
|
widget.ipController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.portController == null) {
|
if (widget.portController == null) {
|
||||||
_portController = TextEditingController(text: widget.initialValue?.port?.toString() ?? "");
|
_portController = TextEditingController(text: widget.initialValue?.port?.toString() ?? "");
|
||||||
} else {
|
} else {
|
||||||
widget.portController.addListener(_handleControllerChanged);
|
widget.portController!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -119,12 +118,12 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
widget.ipController?.addListener(_handleControllerChanged);
|
widget.ipController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.ipController != null && widget.ipController == null) {
|
if (oldWidget.ipController != null && widget.ipController == null) {
|
||||||
_ipController = TextEditingController.fromValue(oldWidget.ipController.value);
|
_ipController = TextEditingController.fromValue(oldWidget.ipController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.ipController != null) {
|
if (widget.ipController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.ip = widget.ipController.text;
|
update.ip = widget.ipController!.text;
|
||||||
if (oldWidget.ipController == null) _ipController = null;
|
if (oldWidget.ipController == null) _ipController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -134,12 +133,12 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
widget.portController?.addListener(_handleControllerChanged);
|
widget.portController?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.portController != null && widget.portController == null) {
|
if (oldWidget.portController != null && widget.portController == null) {
|
||||||
_portController = TextEditingController.fromValue(oldWidget.portController.value);
|
_portController = TextEditingController.fromValue(oldWidget.portController!.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.portController != null) {
|
if (widget.portController != null) {
|
||||||
shouldUpdate = true;
|
shouldUpdate = true;
|
||||||
update.port = int.parse(widget.portController.text);
|
update.port = int.parse(widget.portController!.text);
|
||||||
if (oldWidget.portController == null) _portController = null;
|
if (oldWidget.portController == null) _portController = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -160,8 +159,8 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveIPController.text = widget.initialValue.ip;
|
_effectiveIPController.text = widget.initialValue?.ip ?? "";
|
||||||
_effectivePortController.text = widget.initialValue.port.toString();
|
_effectivePortController.text = widget.initialValue?.port?.toString() ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -174,7 +173,11 @@ class _IPAndPortFormField extends FormFieldState<IPAndPort> {
|
||||||
// example, the reset() method. In such cases, the FormField value will
|
// example, the reset() method. In such cases, the FormField value will
|
||||||
// already have been set.
|
// already have been set.
|
||||||
final effectivePort = int.parse(_effectivePortController.text);
|
final effectivePort = int.parse(_effectivePortController.text);
|
||||||
if (_effectiveIPController.text != value.ip || effectivePort != value.port) {
|
if (value == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_effectiveIPController.text != value!.ip || effectivePort != value!.port) {
|
||||||
didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort));
|
didChange(IPAndPort(ip: _effectiveIPController.text, port: effectivePort));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,24 +1,23 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package: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,
|
||||||
|
@ -34,12 +33,12 @@ class IPField extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
|
var textStyle = CupertinoTheme.of(context).textTheme.textStyle;
|
||||||
final double ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null;
|
final double? ipWidth = ipOnly ? Utils.textSize("000000000000000", textStyle).width + 12 : null;
|
||||||
|
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: ipWidth,
|
width: ipWidth,
|
||||||
child: SpecialTextField(
|
child: SpecialTextField(
|
||||||
keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true) : null,
|
keyboardType: ipOnly ? TextInputType.numberWithOptions(decimal: true, signed: true) : null,
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
autofocus: autoFocus,
|
autofocus: autoFocus,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
|
@ -47,10 +46,8 @@ class IPField extends StatelessWidget {
|
||||||
controller: controller,
|
controller: controller,
|
||||||
onChanged: onChanged,
|
onChanged: onChanged,
|
||||||
maxLength: ipOnly ? 15 : null,
|
maxLength: ipOnly ? 15 : null,
|
||||||
maxLengthEnforced: ipOnly ? true : false,
|
maxLengthEnforcement: ipOnly ? MaxLengthEnforcement.enforced : MaxLengthEnforcement.none,
|
||||||
inputFormatters: ipOnly
|
inputFormatters: ipOnly ? [IPTextInputFormatter()] : [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))],
|
||||||
? [IPTextInputFormatter()]
|
|
||||||
: [FilteringTextInputFormatter.allow(RegExp(r'[^\s]+'))],
|
|
||||||
textInputAction: this.textInputAction,
|
textInputAction: this.textInputAction,
|
||||||
placeholder: help,
|
placeholder: help,
|
||||||
));
|
));
|
||||||
|
@ -67,33 +64,28 @@ class IPTextInputFormatter extends TextInputFormatter {
|
||||||
(String substring) {
|
(String substring) {
|
||||||
return whitelistedPattern
|
return whitelistedPattern
|
||||||
.allMatches(substring)
|
.allMatches(substring)
|
||||||
.map<String>((Match match) => match.group(0))
|
.map<String>((Match match) => match.group(0)!)
|
||||||
.join().replaceAll(RegExp(r','), '.');
|
.join()
|
||||||
|
.replaceAll(RegExp(r','), '.');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
final String beforeSelection = substringManipulation(value.text.substring(0, selectionStartIndex));
|
||||||
value.text.substring(0, selectionStartIndex)
|
final String inSelection = substringManipulation(value.text.substring(selectionStartIndex, selectionEndIndex));
|
||||||
);
|
final String afterSelection = substringManipulation(value.text.substring(selectionEndIndex));
|
||||||
final String inSelection = substringManipulation(
|
|
||||||
value.text.substring(selectionStartIndex, selectionEndIndex)
|
|
||||||
);
|
|
||||||
final String afterSelection = substringManipulation(
|
|
||||||
value.text.substring(selectionEndIndex)
|
|
||||||
);
|
|
||||||
manipulatedText = beforeSelection + inSelection + afterSelection;
|
manipulatedText = beforeSelection + inSelection + afterSelection;
|
||||||
if (value.selection.baseOffset > value.selection.extentOffset) {
|
if (value.selection.baseOffset > value.selection.extentOffset) {
|
||||||
manipulatedSelection = value.selection.copyWith(
|
manipulatedSelection = value.selection.copyWith(
|
||||||
|
@ -110,8 +102,6 @@ TextEditingValue _selectionAwareTextManipulation(
|
||||||
return TextEditingValue(
|
return TextEditingValue(
|
||||||
text: manipulatedText,
|
text: manipulatedText,
|
||||||
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
|
selection: manipulatedSelection ?? const TextSelection.collapsed(offset: -1),
|
||||||
composing: manipulatedText == value.text
|
composing: manipulatedText == value.text ? value.composing : TextRange.empty,
|
||||||
? value.composing
|
|
||||||
: TextRange.empty,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,4 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
import 'package:mobile_nebula/validators/dnsValidator.dart';
|
||||||
import 'package:mobile_nebula/validators/ipValidator.dart';
|
import 'package:mobile_nebula/validators/ipValidator.dart';
|
||||||
|
|
||||||
|
@ -9,17 +7,17 @@ import 'IPField.dart';
|
||||||
//TODO: reset doesn't update the ui but clears the field
|
//TODO: reset doesn't update the ui but clears the field
|
||||||
|
|
||||||
class IPFormField extends FormField<String> {
|
class IPFormField extends FormField<String> {
|
||||||
//TODO: validator, autovalidate, enabled?
|
//TODO: validator, auto-validate, enabled?
|
||||||
IPFormField({
|
IPFormField({
|
||||||
Key key,
|
Key? key,
|
||||||
ipOnly = false,
|
ipOnly = false,
|
||||||
enableIPV6 = false,
|
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,
|
||||||
|
@ -43,7 +41,7 @@ class IPFormField extends FormField<String> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<String> field) {
|
builder: (FormFieldState<String> field) {
|
||||||
final _IPFormField state = field;
|
final _IPFormField state = field as _IPFormField;
|
||||||
|
|
||||||
void onChangedHandler(String value) {
|
void onChangedHandler(String value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -66,7 +64,7 @@ class IPFormField extends FormField<String> {
|
||||||
textAlign: textAlign),
|
textAlign: textAlign),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(
|
? Text(
|
||||||
field.errorText,
|
field.errorText!,
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
)
|
)
|
||||||
|
@ -74,19 +72,19 @@ class IPFormField extends FormField<String> {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_IPFormField createState() => _IPFormField();
|
_IPFormField createState() => _IPFormField();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _IPFormField extends FormFieldState<String> {
|
class _IPFormField extends FormFieldState<String> {
|
||||||
TextEditingController _controller;
|
TextEditingController? _controller;
|
||||||
|
|
||||||
TextEditingController get _effectiveController => widget.controller ?? _controller;
|
TextEditingController get _effectiveController => widget.controller ?? _controller!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
IPFormField get widget => super.widget;
|
IPFormField get widget => super.widget as IPFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -94,7 +92,7 @@ class _IPFormField extends FormFieldState<String> {
|
||||||
if (widget.controller == null) {
|
if (widget.controller == null) {
|
||||||
_controller = TextEditingController(text: widget.initialValue);
|
_controller = TextEditingController(text: widget.initialValue);
|
||||||
} else {
|
} else {
|
||||||
widget.controller.addListener(_handleControllerChanged);
|
widget.controller!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -106,9 +104,9 @@ class _IPFormField extends FormFieldState<String> {
|
||||||
widget.controller?.addListener(_handleControllerChanged);
|
widget.controller?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.controller != null && widget.controller == null)
|
if (oldWidget.controller != null && widget.controller == null)
|
||||||
_controller = TextEditingController.fromValue(oldWidget.controller.value);
|
_controller = TextEditingController.fromValue(oldWidget.controller!.value);
|
||||||
if (widget.controller != null) {
|
if (widget.controller != null) {
|
||||||
setValue(widget.controller.text);
|
setValue(widget.controller!.text);
|
||||||
if (oldWidget.controller == null) _controller = null;
|
if (oldWidget.controller == null) _controller = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -124,7 +122,7 @@ class _IPFormField extends FormFieldState<String> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveController.text = widget.initialValue;
|
_effectiveController.text = widget.initialValue ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,36 +1,34 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
//TODO: reset doesn't update the ui but clears the field
|
//TODO: reset doesn't update the ui but clears the field
|
||||||
|
|
||||||
class PlatformTextFormField extends FormField<String> {
|
class PlatformTextFormField extends FormField<String> {
|
||||||
//TODO: autovalidate, enabled?
|
//TODO: auto-validate, enabled?
|
||||||
PlatformTextFormField(
|
PlatformTextFormField(
|
||||||
{Key key,
|
{Key? key,
|
||||||
widgetKey,
|
widgetKey,
|
||||||
this.controller,
|
this.controller,
|
||||||
focusNode,
|
focusNode,
|
||||||
nextFocusNode,
|
nextFocusNode,
|
||||||
TextInputType keyboardType,
|
TextInputType? keyboardType,
|
||||||
textInputAction,
|
textInputAction,
|
||||||
List<TextInputFormatter> inputFormatters,
|
List<TextInputFormatter>? inputFormatters,
|
||||||
textAlign,
|
textAlign,
|
||||||
autofocus,
|
autofocus,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
maxLength,
|
maxLength,
|
||||||
maxLengthEnforced,
|
maxLengthEnforcement,
|
||||||
onChanged,
|
onChanged,
|
||||||
keyboardAppearance,
|
keyboardAppearance,
|
||||||
minLines,
|
minLines,
|
||||||
expands,
|
expands,
|
||||||
suffix,
|
suffix,
|
||||||
textAlignVertical,
|
textAlignVertical,
|
||||||
String initialValue,
|
String? initialValue,
|
||||||
String placeholder,
|
String? placeholder,
|
||||||
FormFieldValidator<String> validator,
|
FormFieldValidator<String>? validator,
|
||||||
ValueChanged<String> onSaved})
|
ValueChanged<String?>? onSaved})
|
||||||
: super(
|
: super(
|
||||||
key: key,
|
key: key,
|
||||||
initialValue: controller != null ? controller.text : (initialValue ?? ''),
|
initialValue: controller != null ? controller.text : (initialValue ?? ''),
|
||||||
|
@ -43,7 +41,7 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
builder: (FormFieldState<String> field) {
|
builder: (FormFieldState<String> field) {
|
||||||
final _PlatformTextFormFieldState state = field;
|
final _PlatformTextFormFieldState state = field as _PlatformTextFormFieldState;
|
||||||
|
|
||||||
void onChangedHandler(String value) {
|
void onChangedHandler(String value) {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
|
@ -64,7 +62,7 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
autofocus: autofocus,
|
autofocus: autofocus,
|
||||||
maxLines: maxLines,
|
maxLines: maxLines,
|
||||||
maxLength: maxLength,
|
maxLength: maxLength,
|
||||||
maxLengthEnforced: maxLengthEnforced,
|
maxLengthEnforcement: maxLengthEnforcement,
|
||||||
onChanged: onChangedHandler,
|
onChanged: onChangedHandler,
|
||||||
keyboardAppearance: keyboardAppearance,
|
keyboardAppearance: keyboardAppearance,
|
||||||
minLines: minLines,
|
minLines: minLines,
|
||||||
|
@ -75,7 +73,7 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
suffix: suffix),
|
suffix: suffix),
|
||||||
field.hasError
|
field.hasError
|
||||||
? Text(
|
? Text(
|
||||||
field.errorText,
|
field.errorText!,
|
||||||
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
style: TextStyle(color: CupertinoColors.systemRed.resolveFrom(field.context), fontSize: 13),
|
||||||
textAlign: textAlign,
|
textAlign: textAlign,
|
||||||
)
|
)
|
||||||
|
@ -83,19 +81,19 @@ class PlatformTextFormField extends FormField<String> {
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_PlatformTextFormFieldState createState() => _PlatformTextFormFieldState();
|
_PlatformTextFormFieldState createState() => _PlatformTextFormFieldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlatformTextFormFieldState extends FormFieldState<String> {
|
class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
TextEditingController _controller;
|
TextEditingController? _controller;
|
||||||
|
|
||||||
TextEditingController get _effectiveController => widget.controller ?? _controller;
|
TextEditingController get _effectiveController => widget.controller ?? _controller!;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
PlatformTextFormField get widget => super.widget;
|
PlatformTextFormField get widget => super.widget as PlatformTextFormField;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -103,7 +101,7 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
if (widget.controller == null) {
|
if (widget.controller == null) {
|
||||||
_controller = TextEditingController(text: widget.initialValue);
|
_controller = TextEditingController(text: widget.initialValue);
|
||||||
} else {
|
} else {
|
||||||
widget.controller.addListener(_handleControllerChanged);
|
widget.controller!.addListener(_handleControllerChanged);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -115,9 +113,9 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
widget.controller?.addListener(_handleControllerChanged);
|
widget.controller?.addListener(_handleControllerChanged);
|
||||||
|
|
||||||
if (oldWidget.controller != null && widget.controller == null)
|
if (oldWidget.controller != null && widget.controller == null)
|
||||||
_controller = TextEditingController.fromValue(oldWidget.controller.value);
|
_controller = TextEditingController.fromValue(oldWidget.controller!.value);
|
||||||
if (widget.controller != null) {
|
if (widget.controller != null) {
|
||||||
setValue(widget.controller.text);
|
setValue(widget.controller!.text);
|
||||||
if (oldWidget.controller == null) _controller = null;
|
if (oldWidget.controller == null) _controller = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -133,7 +131,7 @@ class _PlatformTextFormFieldState extends FormFieldState<String> {
|
||||||
void reset() {
|
void reset() {
|
||||||
super.reset();
|
super.reset();
|
||||||
setState(() {
|
setState(() {
|
||||||
_effectiveController.text = widget.initialValue;
|
_effectiveController.text = widget.initialValue ?? "";
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -13,9 +13,9 @@ enum SimpleScrollable {
|
||||||
|
|
||||||
class SimplePage extends StatelessWidget {
|
class SimplePage extends StatelessWidget {
|
||||||
const SimplePage(
|
const SimplePage(
|
||||||
{Key key,
|
{Key? key,
|
||||||
this.title,
|
required this.title,
|
||||||
@required this.child,
|
required this.child,
|
||||||
this.leadingAction,
|
this.leadingAction,
|
||||||
this.trailingActions = const [],
|
this.trailingActions = const [],
|
||||||
this.scrollable = SimpleScrollable.vertical,
|
this.scrollable = SimpleScrollable.vertical,
|
||||||
|
@ -24,27 +24,28 @@ class SimplePage extends StatelessWidget {
|
||||||
this.bottomBar,
|
this.bottomBar,
|
||||||
this.onRefresh,
|
this.onRefresh,
|
||||||
this.onLoading,
|
this.onLoading,
|
||||||
|
this.alignment,
|
||||||
this.refreshController})
|
this.refreshController})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String title;
|
final Widget title;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final SimpleScrollable scrollable;
|
final SimpleScrollable scrollable;
|
||||||
final ScrollController scrollController;
|
final ScrollController? scrollController;
|
||||||
|
final AlignmentGeometry? alignment;
|
||||||
|
|
||||||
/// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorderable listviews
|
/// Set this to true to force draw a scrollbar without a scroll view, this is helpful for pages with Reorder-able listviews
|
||||||
/// This is set to true if you have any scrollable other than none
|
/// This is set to true if you have any scrollable other than none
|
||||||
final bool scrollbar;
|
final bool scrollbar;
|
||||||
final Widget bottomBar;
|
final Widget? bottomBar;
|
||||||
|
|
||||||
/// If no leading action is provided then a default "Back" widget than pops the page will be provided
|
/// If no leading action is provided then a default "Back" widget than pops the page will be provided
|
||||||
final Widget leadingAction;
|
final Widget? leadingAction;
|
||||||
final List<Widget> trailingActions;
|
final List<Widget> trailingActions;
|
||||||
|
|
||||||
final VoidCallback onRefresh;
|
final VoidCallback? onRefresh;
|
||||||
final VoidCallback onLoading;
|
final VoidCallback? onLoading;
|
||||||
final RefreshController refreshController;
|
final RefreshController? refreshController;
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -52,7 +53,10 @@ class SimplePage extends StatelessWidget {
|
||||||
var addScrollbar = this.scrollbar;
|
var addScrollbar = this.scrollbar;
|
||||||
|
|
||||||
if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) {
|
if (scrollable == SimpleScrollable.vertical || scrollable == SimpleScrollable.both) {
|
||||||
realChild = SingleChildScrollView(scrollDirection: Axis.vertical, child: realChild, controller: refreshController == null ? scrollController : null);
|
realChild = SingleChildScrollView(
|
||||||
|
scrollDirection: Axis.vertical,
|
||||||
|
child: realChild,
|
||||||
|
controller: refreshController == null ? scrollController : null);
|
||||||
addScrollbar = true;
|
addScrollbar = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -67,15 +71,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -83,17 +87,21 @@ class SimplePage extends StatelessWidget {
|
||||||
realChild = Scrollbar(child: realChild);
|
realChild = Scrollbar(child: realChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (alignment != null) {
|
||||||
|
realChild = Align(alignment: this.alignment!, child: realChild);
|
||||||
|
}
|
||||||
|
|
||||||
if (bottomBar != null) {
|
if (bottomBar != null) {
|
||||||
realChild = Column(children: [
|
realChild = Column(children: [
|
||||||
Expanded(child: realChild),
|
Expanded(child: realChild),
|
||||||
bottomBar,
|
bottomBar!,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PlatformScaffold(
|
return PlatformScaffold(
|
||||||
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context),
|
backgroundColor: cupertino.CupertinoColors.systemGroupedBackground.resolveFrom(context),
|
||||||
appBar: PlatformAppBar(
|
appBar: PlatformAppBar(
|
||||||
title: Text(title),
|
title: title,
|
||||||
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
|
leading: leadingAction != null ? leadingAction : Utils.leadingBackWidget(context),
|
||||||
trailingActions: trailingActions,
|
trailingActions: trailingActions,
|
||||||
cupertino: (_, __) => CupertinoNavigationBarData(
|
cupertino: (_, __) => CupertinoNavigationBarData(
|
||||||
|
|
|
@ -1,11 +1,12 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialButton.dart';
|
import 'package:mobile_nebula/components/SpecialButton.dart';
|
||||||
import 'package:mobile_nebula/models/Site.dart';
|
import 'package:mobile_nebula/models/Site.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class SiteItem extends StatelessWidget {
|
class SiteItem extends StatelessWidget {
|
||||||
const SiteItem({Key key, this.site, this.onPressed}) : super(key: key);
|
const SiteItem({Key? key, required this.site, this.onPressed}) : super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
final onPressed;
|
final onPressed;
|
||||||
|
@ -27,10 +28,7 @@ class SiteItem extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context) {
|
Widget _buildContent(BuildContext context) {
|
||||||
final border = BorderSide(color: Utils.configSectionBorder(context));
|
final border = BorderSide(color: Utils.configSectionBorder(context));
|
||||||
var ip = "Error";
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
if (site.primaryCertInfo.cert != null && site.primaryCertInfo.cert.details.ips.length > 0) {
|
|
||||||
ip = site.primaryCertInfo.cert.details.ips[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return SpecialButton(
|
return SpecialButton(
|
||||||
decoration:
|
decoration:
|
||||||
|
@ -41,8 +39,10 @@ class SiteItem extends StatelessWidget {
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)),
|
site.managed ?
|
||||||
Expanded(child: Text(ip, textAlign: TextAlign.end)),
|
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) :
|
||||||
|
Container(),
|
||||||
|
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold))),
|
||||||
Padding(padding: EdgeInsets.only(right: 10)),
|
Padding(padding: EdgeInsets.only(right: 10)),
|
||||||
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
||||||
],
|
],
|
||||||
|
|
|
@ -5,14 +5,15 @@ import 'package:flutter/material.dart';
|
||||||
|
|
||||||
// This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to
|
// This is a button that pushes the bare minimum onto you, it doesn't even respect button themes - unless you tell it to
|
||||||
class SpecialButton extends StatefulWidget {
|
class SpecialButton extends StatefulWidget {
|
||||||
const SpecialButton({Key key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration}) : super(key: key);
|
const SpecialButton({Key? key, this.child, this.color, this.onPressed, this.useButtonTheme = false, this.decoration})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final Widget child;
|
final Widget? child;
|
||||||
final Color color;
|
final Color? color;
|
||||||
final bool useButtonTheme;
|
final bool useButtonTheme;
|
||||||
final BoxDecoration decoration;
|
final BoxDecoration? decoration;
|
||||||
|
|
||||||
final Function onPressed;
|
final GestureTapCallback? onPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SpecialButtonState createState() => _SpecialButtonState();
|
_SpecialButtonState createState() => _SpecialButtonState();
|
||||||
|
@ -33,12 +34,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() {
|
||||||
|
@ -48,22 +49,21 @@ 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,8 +98,7 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_animationController.dispose();
|
_animationController?.dispose();
|
||||||
_animationController = null;
|
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -127,14 +126,14 @@ class _SpecialButtonState extends State<SpecialButton> with SingleTickerProvider
|
||||||
}
|
}
|
||||||
|
|
||||||
void _animate() {
|
void _animate() {
|
||||||
if (_animationController.isAnimating) {
|
if (_animationController == null || _animationController!.isAnimating) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final bool wasHeldDown = _buttonHeldDown;
|
final bool wasHeldDown = _buttonHeldDown;
|
||||||
final TickerFuture ticker = _buttonHeldDown
|
final TickerFuture ticker = _buttonHeldDown
|
||||||
? _animationController.animateTo(1.0, duration: kFadeOutDuration)
|
? _animationController!.animateTo(1.0, duration: kFadeOutDuration)
|
||||||
: _animationController.animateTo(0.0, duration: kFadeInDuration);
|
: _animationController!.animateTo(0.0, duration: kFadeInDuration);
|
||||||
|
|
||||||
ticker.then<void>((void value) {
|
ticker.then<void>((void value) {
|
||||||
if (mounted && wasHeldDown != _buttonHeldDown) {
|
if (mounted && wasHeldDown != _buttonHeldDown) {
|
||||||
|
|
|
@ -1,684 +0,0 @@
|
||||||
// Copyright 2014 The Flutter Authors. All rights reserved.
|
|
||||||
// Use of this source code is governed by a BSD-style license that can be
|
|
||||||
// found in the LICENSE file.
|
|
||||||
|
|
||||||
// @dart = 2.8
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/rendering.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
|
|
||||||
//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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,14 +1,11 @@
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
|
||||||
//TODO: please let us delete this file
|
/// A normal TextField or CupertinoTextField that looks the same on all platforms
|
||||||
|
|
||||||
/// 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,
|
||||||
|
@ -18,7 +15,7 @@ class SpecialTextField extends StatefulWidget {
|
||||||
this.minLines,
|
this.minLines,
|
||||||
this.maxLines,
|
this.maxLines,
|
||||||
this.maxLength,
|
this.maxLength,
|
||||||
this.maxLengthEnforced,
|
this.maxLengthEnforcement,
|
||||||
this.style,
|
this.style,
|
||||||
this.keyboardType,
|
this.keyboardType,
|
||||||
this.textInputAction,
|
this.textInputAction,
|
||||||
|
@ -33,50 +30,44 @@ class SpecialTextField extends StatefulWidget {
|
||||||
this.inputFormatters})
|
this.inputFormatters})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final String placeholder;
|
final String? placeholder;
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
final FocusNode focusNode;
|
final FocusNode? focusNode;
|
||||||
final FocusNode nextFocusNode;
|
final FocusNode? nextFocusNode;
|
||||||
final bool autocorrect;
|
final bool? autocorrect;
|
||||||
final int minLines;
|
final int? minLines;
|
||||||
final int maxLines;
|
final int? maxLines;
|
||||||
final int maxLength;
|
final int? maxLength;
|
||||||
final bool maxLengthEnforced;
|
final MaxLengthEnforcement? maxLengthEnforcement;
|
||||||
final Widget suffix;
|
final Widget? suffix;
|
||||||
final TextStyle style;
|
final TextStyle? style;
|
||||||
final TextInputType keyboardType;
|
final TextInputType? keyboardType;
|
||||||
final Brightness keyboardAppearance;
|
final Brightness? keyboardAppearance;
|
||||||
|
|
||||||
final TextInputAction textInputAction;
|
final TextInputAction? textInputAction;
|
||||||
final TextCapitalization textCapitalization;
|
final TextCapitalization? textCapitalization;
|
||||||
final TextAlign textAlign;
|
final TextAlign? textAlign;
|
||||||
final TextAlignVertical textAlignVertical;
|
final TextAlignVertical? textAlignVertical;
|
||||||
|
|
||||||
final bool autofocus;
|
final bool? autofocus;
|
||||||
final ValueChanged<String> onChanged;
|
final ValueChanged<String>? onChanged;
|
||||||
final bool enabled;
|
final bool? enabled;
|
||||||
final List<TextInputFormatter> inputFormatters;
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
final bool expands;
|
final bool? expands;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SpecialTextFieldState createState() => _SpecialTextFieldState();
|
_SpecialTextFieldState createState() => _SpecialTextFieldState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SpecialTextFieldState extends State<SpecialTextField> {
|
class _SpecialTextFieldState extends State<SpecialTextField> {
|
||||||
FocusNode _focusNode = FocusNode();
|
List<TextInputFormatter> formatters = [];
|
||||||
List<TextInputFormatter> formatters;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_focusNode.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
formatters = widget.inputFormatters;
|
if (widget.inputFormatters == null || formatters.length == 0) {
|
||||||
if (formatters == null || formatters.length == 0) {
|
|
||||||
formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))];
|
formatters = [FilteringTextInputFormatter.allow(RegExp(r'[^\t]'))];
|
||||||
|
} else {
|
||||||
|
formatters = widget.inputFormatters!;
|
||||||
}
|
}
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
|
@ -84,121 +75,43 @@ class _SpecialTextFieldState extends State<SpecialTextField> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return RawKeyboardListener(
|
return PlatformTextField(
|
||||||
focusNode: _focusNode,
|
autocorrect: widget.autocorrect,
|
||||||
onKey: _onKey,
|
minLines: widget.minLines,
|
||||||
child: PlatformTextField(
|
maxLines: widget.maxLines,
|
||||||
autocorrect: widget.autocorrect,
|
maxLength: widget.maxLength,
|
||||||
minLines: widget.minLines,
|
maxLengthEnforcement: widget.maxLengthEnforcement,
|
||||||
maxLines: widget.maxLines,
|
keyboardType: widget.keyboardType,
|
||||||
maxLength: widget.maxLength,
|
keyboardAppearance: widget.keyboardAppearance,
|
||||||
maxLengthEnforced: widget.maxLengthEnforced,
|
textInputAction: widget.textInputAction,
|
||||||
keyboardType: widget.keyboardType,
|
textCapitalization: widget.textCapitalization,
|
||||||
keyboardAppearance: widget.keyboardAppearance,
|
textAlign: widget.textAlign,
|
||||||
textInputAction: widget.textInputAction,
|
textAlignVertical: widget.textAlignVertical,
|
||||||
textCapitalization: widget.textCapitalization,
|
autofocus: widget.autofocus,
|
||||||
textAlign: widget.textAlign,
|
focusNode: widget.focusNode,
|
||||||
textAlignVertical: widget.textAlignVertical,
|
onChanged: widget.onChanged,
|
||||||
autofocus: widget.autofocus,
|
enabled: widget.enabled,
|
||||||
focusNode: widget.focusNode,
|
onSubmitted: (_) {
|
||||||
onChanged: widget.onChanged,
|
if (widget.nextFocusNode != null) {
|
||||||
enabled: widget.enabled,
|
FocusScope.of(context).requestFocus(widget.nextFocusNode);
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
// If maxLength took us to 0 then bail
|
expands: widget.expands,
|
||||||
if (text.length == 0) {
|
inputFormatters: formatters,
|
||||||
return;
|
material: (_, __) => MaterialTextFieldData(
|
||||||
}
|
decoration: InputDecoration(
|
||||||
|
border: InputBorder.none,
|
||||||
var end = widget.controller.selection.end;
|
contentPadding: EdgeInsets.zero,
|
||||||
var start = widget.controller.selection.start;
|
isDense: true,
|
||||||
|
hintText: widget.placeholder,
|
||||||
// Insert our paste buffer into the selection, which can be 0 selected text (normal caret)
|
counterText: '',
|
||||||
widget.controller.text = widget.controller.selection.textBefore(widget.controller.text) +
|
suffix: widget.suffix)),
|
||||||
text +
|
cupertino: (_, __) => CupertinoTextFieldData(
|
||||||
widget.controller.selection.textAfter(widget.controller.text);
|
decoration: BoxDecoration(),
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
// Adjust our caret to be at the end of the pasted contents, need to take into account the size of the selection
|
placeholder: widget.placeholder,
|
||||||
// We may want runes instead of
|
suffix: widget.suffix),
|
||||||
end += text.length - (end - start);
|
style: widget.style,
|
||||||
widget.controller.selection = TextSelection(baseOffset: end, extentOffset: end);
|
controller: widget.controller);
|
||||||
});
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:mobile_nebula/components/SpecialButton.dart';
|
import 'package:mobile_nebula/components/SpecialButton.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class ConfigCheckboxItem extends StatelessWidget {
|
class ConfigCheckboxItem extends StatelessWidget {
|
||||||
const ConfigCheckboxItem({Key key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked})
|
const ConfigCheckboxItem(
|
||||||
|
{Key? key, this.label, this.content, this.labelWidth = 100, this.onChanged, this.checked = false})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final Widget label;
|
final Widget? label;
|
||||||
final Widget content;
|
final Widget? content;
|
||||||
final double labelWidth;
|
final double labelWidth;
|
||||||
final bool checked;
|
final bool checked;
|
||||||
final Function onChanged;
|
final Function? onChanged;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -35,7 +35,7 @@ class ConfigCheckboxItem extends StatelessWidget {
|
||||||
child: item,
|
child: item,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
if (onChanged != null) {
|
if (onChanged != null) {
|
||||||
onChanged();
|
onChanged!();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -4,15 +4,15 @@ import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
TextStyle basicTextStyle(BuildContext context) =>
|
TextStyle basicTextStyle(BuildContext context) =>
|
||||||
Platform.isIOS ? CupertinoTheme.of(context).textTheme.textStyle : Theme.of(context).textTheme.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, this.label, this.color}) : super(key: key);
|
const ConfigHeader({Key? key, required this.label, this.color}) : super(key: key);
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final Color color;
|
final Color? color;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|
|
@ -4,10 +4,14 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class ConfigItem extends StatelessWidget {
|
class ConfigItem extends StatelessWidget {
|
||||||
const ConfigItem(
|
const ConfigItem(
|
||||||
{Key key, this.label, this.content, this.labelWidth = 100, this.crossAxisAlignment = CrossAxisAlignment.center})
|
{Key? key,
|
||||||
|
this.label,
|
||||||
|
required this.content,
|
||||||
|
this.labelWidth = 100,
|
||||||
|
this.crossAxisAlignment = CrossAxisAlignment.center})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final Widget label;
|
final Widget? label;
|
||||||
final Widget content;
|
final Widget content;
|
||||||
final double labelWidth;
|
final double labelWidth;
|
||||||
final CrossAxisAlignment crossAxisAlignment;
|
final CrossAxisAlignment crossAxisAlignment;
|
||||||
|
|
|
@ -7,19 +7,21 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
class ConfigPageItem extends StatelessWidget {
|
class ConfigPageItem extends StatelessWidget {
|
||||||
const ConfigPageItem(
|
const ConfigPageItem(
|
||||||
{Key key,
|
{Key? key,
|
||||||
this.label,
|
this.label,
|
||||||
this.content,
|
this.content,
|
||||||
this.labelWidth = 100,
|
this.labelWidth = 100,
|
||||||
this.onPressed,
|
this.onPressed,
|
||||||
|
this.disabled = false,
|
||||||
this.crossAxisAlignment = CrossAxisAlignment.center})
|
this.crossAxisAlignment = CrossAxisAlignment.center})
|
||||||
: super(key: key);
|
: super(key: key);
|
||||||
|
|
||||||
final Widget label;
|
final Widget? label;
|
||||||
final Widget content;
|
final Widget? content;
|
||||||
final double labelWidth;
|
final double labelWidth;
|
||||||
final CrossAxisAlignment crossAxisAlignment;
|
final CrossAxisAlignment crossAxisAlignment;
|
||||||
final onPressed;
|
final onPressed;
|
||||||
|
final bool disabled;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -28,8 +30,8 @@ class ConfigPageItem extends StatelessWidget {
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final origTheme = Theme.of(context);
|
final origTheme = Theme.of(context);
|
||||||
theme = origTheme.copyWith(
|
theme = origTheme.copyWith(
|
||||||
textTheme:
|
textTheme: origTheme.textTheme
|
||||||
origTheme.textTheme.copyWith(button: origTheme.textTheme.button.copyWith(fontWeight: FontWeight.normal)));
|
.copyWith(button: origTheme.textTheme.button!.copyWith(fontWeight: FontWeight.normal)));
|
||||||
return Theme(data: theme, child: _buildContent(context));
|
return Theme(data: theme, child: _buildContent(context));
|
||||||
} else {
|
} else {
|
||||||
final origTheme = CupertinoTheme.of(context);
|
final origTheme = CupertinoTheme.of(context);
|
||||||
|
@ -40,7 +42,7 @@ class ConfigPageItem extends StatelessWidget {
|
||||||
|
|
||||||
Widget _buildContent(BuildContext context) {
|
Widget _buildContent(BuildContext context) {
|
||||||
return SpecialButton(
|
return SpecialButton(
|
||||||
onPressed: onPressed,
|
onPressed: this.disabled ? null : onPressed,
|
||||||
color: Utils.configItemBackground(context),
|
color: Utils.configItemBackground(context),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: EdgeInsets.only(left: 15),
|
padding: EdgeInsets.only(left: 15),
|
||||||
|
@ -50,7 +52,7 @@ class ConfigPageItem extends StatelessWidget {
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
label != null ? Container(width: labelWidth, child: label) : Container(),
|
label != null ? Container(width: labelWidth, child: label) : Container(),
|
||||||
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))),
|
Expanded(child: Container(child: content, padding: EdgeInsets.only(right: 10))),
|
||||||
Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
this.disabled ? Container() : Icon(CupertinoIcons.forward, color: CupertinoColors.placeholderText.resolveFrom(context), size: 18)
|
||||||
],
|
],
|
||||||
)),
|
)),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,16 +1,16 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
import 'ConfigHeader.dart';
|
import 'ConfigHeader.dart';
|
||||||
|
|
||||||
class ConfigSection extends StatelessWidget {
|
class ConfigSection extends StatelessWidget {
|
||||||
const ConfigSection({Key key, this.label, this.children, this.borderColor, this.labelColor}) : super(key: key);
|
const ConfigSection({Key? key, this.label, required this.children, this.borderColor, this.labelColor})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
final String label;
|
final String? label;
|
||||||
final Color borderColor;
|
final Color? borderColor;
|
||||||
final Color labelColor;
|
final Color? labelColor;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -33,7 +33,7 @@ class ConfigSection extends StatelessWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
label != null ? ConfigHeader(label: label, color: labelColor) : Container(height: 20),
|
label != null ? ConfigHeader(label: label!, color: labelColor) : Container(height: 20),
|
||||||
Container(
|
Container(
|
||||||
decoration:
|
decoration:
|
||||||
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)),
|
BoxDecoration(border: Border(top: border, bottom: border), color: Utils.configItemBackground(context)),
|
||||||
|
|
|
@ -5,10 +5,13 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
import 'package:mobile_nebula/components/SpecialTextField.dart';
|
||||||
|
|
||||||
class ConfigTextItem extends StatelessWidget {
|
class ConfigTextItem extends StatelessWidget {
|
||||||
const ConfigTextItem({Key key, this.placeholder, this.controller}) : super(key: key);
|
const ConfigTextItem(
|
||||||
|
{Key? key, this.placeholder, this.controller, this.style = const TextStyle(fontFamily: 'RobotoMono')})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final String placeholder;
|
final String? placeholder;
|
||||||
final TextEditingController controller;
|
final TextEditingController? controller;
|
||||||
|
final TextStyle style;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -19,7 +22,7 @@ class ConfigTextItem extends StatelessWidget {
|
||||||
minLines: 3,
|
minLines: 3,
|
||||||
maxLines: 10,
|
maxLines: 10,
|
||||||
placeholder: placeholder,
|
placeholder: placeholder,
|
||||||
style: TextStyle(fontFamily: 'RobotoMono'),
|
style: style,
|
||||||
controller: controller));
|
controller: controller));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
|
import 'package:flutter/cupertino.dart' show CupertinoThemeData, DefaultCupertinoLocalizations;
|
||||||
import 'package:flutter/material.dart'
|
import 'package:flutter/material.dart'
|
||||||
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode;
|
show BottomSheetThemeData, Colors, DefaultMaterialLocalizations, Theme, ThemeData, ThemeMode;
|
||||||
|
@ -6,11 +8,16 @@ import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/screens/MainScreen.dart';
|
import 'package:mobile_nebula/screens/MainScreen.dart';
|
||||||
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
||||||
import 'package:mobile_nebula/services/settings.dart';
|
import 'package:mobile_nebula/services/settings.dart';
|
||||||
|
import 'package:flutter_web_plugins/url_strategy.dart';
|
||||||
|
|
||||||
//TODO: EventChannel might be better than the streamcontroller we are using now
|
//TODO: EventChannel might be better than the stream controller we are using now
|
||||||
|
|
||||||
void main() => runApp(Main());
|
void main() {
|
||||||
|
usePathUrlStrategy();
|
||||||
|
runApp(Main());
|
||||||
|
}
|
||||||
|
|
||||||
class Main extends StatelessWidget {
|
class Main extends StatelessWidget {
|
||||||
// This widget is the root of your application.
|
// This widget is the root of your application.
|
||||||
|
@ -26,6 +33,7 @@ class App extends StatefulWidget {
|
||||||
class _AppState extends State<App> {
|
class _AppState extends State<App> {
|
||||||
final settings = Settings();
|
final settings = Settings();
|
||||||
Brightness brightness = SchedulerBinding.instance.window.platformBrightness;
|
Brightness brightness = SchedulerBinding.instance.window.platformBrightness;
|
||||||
|
StreamController dnEnrolled = StreamController.broadcast();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -41,6 +49,12 @@ class _AppState extends State<App> {
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
dnEnrolled.close();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final ThemeData lightTheme = ThemeData(
|
final ThemeData lightTheme = ThemeData(
|
||||||
|
@ -93,7 +107,25 @@ class _AppState extends State<App> {
|
||||||
cupertino: (_, __) => CupertinoAppData(
|
cupertino: (_, __) => CupertinoAppData(
|
||||||
theme: CupertinoThemeData(brightness: brightness),
|
theme: CupertinoThemeData(brightness: brightness),
|
||||||
),
|
),
|
||||||
home: MainScreen(),
|
onGenerateRoute: (settings) {
|
||||||
|
if (settings.name == '/') {
|
||||||
|
return platformPageRoute(context: context, builder: (context) => MainScreen(this.dnEnrolled));
|
||||||
|
}
|
||||||
|
|
||||||
|
final uri = Uri.parse(settings.name!);
|
||||||
|
if (uri.path == EnrollmentScreen.routeName) {
|
||||||
|
// TODO: maybe implement this as a dialog instead of a page, you can stack multiple enrollment screens which is annoying in dev
|
||||||
|
return platformPageRoute(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => EnrollmentScreen(
|
||||||
|
code: EnrollmentScreen.parseCode(settings.name!),
|
||||||
|
stream: this.dnEnrolled
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
class CIDR {
|
class CIDR {
|
||||||
CIDR({this.ip, this.bits});
|
CIDR({this.ip = '', this.bits = 0});
|
||||||
|
|
||||||
String ip;
|
String ip;
|
||||||
int bits;
|
int bits;
|
||||||
|
@ -13,13 +13,15 @@ class CIDR {
|
||||||
return toString();
|
return toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
CIDR.fromString(String val) {
|
factory CIDR.fromString(String val) {
|
||||||
final parts = val.split('/');
|
final parts = val.split('/');
|
||||||
if (parts.length != 2) {
|
if (parts.length != 2) {
|
||||||
throw 'Invalid CIDR string';
|
throw 'Invalid CIDR string';
|
||||||
}
|
}
|
||||||
|
|
||||||
ip = parts[0];
|
return CIDR(
|
||||||
bits = int.parse(parts[1]);
|
ip: parts[0],
|
||||||
|
bits: int.parse(parts[1]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,11 +1,7 @@
|
||||||
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(),
|
||||||
|
@ -14,22 +10,12 @@ 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({this.cert, this.rawCert, this.validity});
|
CertificateInfo({required this.cert, this.rawCert, this.validity});
|
||||||
|
|
||||||
static List<CertificateInfo> fromJsonList(List<dynamic> list) {
|
static List<CertificateInfo> fromJsonList(List<dynamic> list) {
|
||||||
return list.map((v) => CertificateInfo.fromJson(v));
|
return list.map((v) => CertificateInfo.fromJson(v)).toList();
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
|
||||||
return {
|
|
||||||
'cert': rawCert,
|
|
||||||
'key': key,
|
|
||||||
'primary': primary,
|
|
||||||
'fingerprint': cert.fingerprint
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -73,8 +59,8 @@ class CertificateDetails {
|
||||||
|
|
||||||
CertificateDetails.fromJson(Map<String, dynamic> json)
|
CertificateDetails.fromJson(Map<String, dynamic> json)
|
||||||
: name = json['name'],
|
: name = json['name'],
|
||||||
notBefore = DateTime.tryParse(json['notBefore']),
|
notBefore = DateTime.parse(json['notBefore']),
|
||||||
notAfter = DateTime.tryParse(json['notAfter']),
|
notAfter = DateTime.parse(json['notAfter']),
|
||||||
publicKey = json['publicKey'],
|
publicKey = json['publicKey'],
|
||||||
groups = List<String>.from(json['groups']),
|
groups = List<String>.from(json['groups']),
|
||||||
ips = List<String>.from(json['ips']),
|
ips = List<String>.from(json['ips']),
|
||||||
|
@ -94,4 +80,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'];
|
||||||
}
|
}
|
||||||
|
|
|
@ -6,31 +6,48 @@ class HostInfo {
|
||||||
int remoteIndex;
|
int remoteIndex;
|
||||||
List<UDPAddress> remoteAddresses;
|
List<UDPAddress> remoteAddresses;
|
||||||
int cachedPackets;
|
int cachedPackets;
|
||||||
Certificate cert;
|
Certificate? cert;
|
||||||
UDPAddress currentRemote;
|
UDPAddress? currentRemote;
|
||||||
int messageCounter;
|
int messageCounter;
|
||||||
|
|
||||||
HostInfo.fromJson(Map<String, dynamic> json) {
|
HostInfo({
|
||||||
vpnIp = json['vpnIp'];
|
required this.vpnIp,
|
||||||
localIndex = json['localIndex'];
|
required this.localIndex,
|
||||||
remoteIndex = json['remoteIndex'];
|
required this.remoteIndex,
|
||||||
cachedPackets = json['cachedPackets'];
|
required this.remoteAddresses,
|
||||||
|
required this.cachedPackets,
|
||||||
|
required this.messageCounter,
|
||||||
|
this.cert,
|
||||||
|
this.currentRemote,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory HostInfo.fromJson(Map<String, dynamic> json) {
|
||||||
|
UDPAddress? currentRemote;
|
||||||
if (json['currentRemote'] != null) {
|
if (json['currentRemote'] != null) {
|
||||||
currentRemote = UDPAddress.fromJson(json['currentRemote']);
|
currentRemote = UDPAddress.fromJson(json['currentRemote']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Certificate? cert;
|
||||||
if (json['cert'] != null) {
|
if (json['cert'] != null) {
|
||||||
cert = Certificate.fromJson(json['cert']);
|
cert = Certificate.fromJson(json['cert']);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<dynamic> addrs = json['remoteAddrs'];
|
List<dynamic>? addrs = json['remoteAddrs'];
|
||||||
remoteAddresses = [];
|
List<UDPAddress> remoteAddresses = [];
|
||||||
addrs?.forEach((val) {
|
addrs?.forEach((val) {
|
||||||
remoteAddresses.add(UDPAddress.fromJson(val));
|
remoteAddresses.add(UDPAddress.fromJson(val));
|
||||||
});
|
});
|
||||||
|
|
||||||
messageCounter = json['messageCounter'];
|
return HostInfo(
|
||||||
|
vpnIp: json['vpnIp'],
|
||||||
|
localIndex: json['localIndex'],
|
||||||
|
remoteIndex: json['remoteIndex'],
|
||||||
|
remoteAddresses: remoteAddresses,
|
||||||
|
cachedPackets: json['cachedPackets'],
|
||||||
|
messageCounter: json['messageCounter'],
|
||||||
|
cert: cert,
|
||||||
|
currentRemote: currentRemote,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -5,5 +5,5 @@ class Hostmap {
|
||||||
List<IPAndPort> destinations;
|
List<IPAndPort> destinations;
|
||||||
bool lighthouse;
|
bool lighthouse;
|
||||||
|
|
||||||
Hostmap({this.nebulaIp, this.destinations, this.lighthouse});
|
Hostmap({required this.nebulaIp, required this.destinations, required this.lighthouse});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,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.contains(':')) {
|
if (ip != null && ip!.contains(':')) {
|
||||||
return '[$ip]:$port';
|
return '[$ip]:$port';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -17,10 +17,13 @@ class IPAndPort {
|
||||||
return toString();
|
return toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
IPAndPort.fromString(String val) {
|
factory 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;
|
|
||||||
this.port = uri.port;
|
return IPAndPort(
|
||||||
|
ip: uri.host,
|
||||||
|
port: uri.port,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,7 @@ import 'dart:convert';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:mobile_nebula/models/HostInfo.dart';
|
import 'package:mobile_nebula/models/HostInfo.dart';
|
||||||
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
||||||
|
import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import 'Certificate.dart';
|
import 'Certificate.dart';
|
||||||
import 'StaticHosts.dart';
|
import 'StaticHosts.dart';
|
||||||
|
@ -12,133 +13,217 @@ var uuid = Uuid();
|
||||||
|
|
||||||
class Site {
|
class Site {
|
||||||
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
EventChannel _updates;
|
late EventChannel _updates;
|
||||||
|
|
||||||
/// Signals that something about this site has changed. onError is called with an error string if there was an error
|
/// Signals that something about this site has changed. onError is called with an error string if there was an error
|
||||||
StreamController _change = StreamController.broadcast();
|
StreamController _change = StreamController.broadcast();
|
||||||
|
|
||||||
// Identifiers
|
// Identifiers
|
||||||
String name;
|
late String name;
|
||||||
String id;
|
late String id;
|
||||||
|
|
||||||
// static_host_map
|
// static_host_map
|
||||||
Map<String, StaticHost> staticHostmap;
|
late Map<String, StaticHost> staticHostmap;
|
||||||
List<UnsafeRoute> unsafeRoutes;
|
late List<UnsafeRoute> unsafeRoutes;
|
||||||
|
late List<String> dnsResolvers;
|
||||||
|
|
||||||
// pki fields
|
// pki fields
|
||||||
List<CertificateInfo> caInfos;
|
late List<CertificateInfo> ca;
|
||||||
List<CertificateInfo> certInfos;
|
String? key;
|
||||||
CertificateInfo primaryCertInfo;
|
late CertificateInfo? certInfo;
|
||||||
|
|
||||||
// lighthouse options
|
// lighthouse options
|
||||||
int lhDuration; // in seconds
|
late int lhDuration; // in seconds
|
||||||
|
|
||||||
// listen settings
|
// listen settings
|
||||||
int port;
|
late int port;
|
||||||
int mtu;
|
late int mtu;
|
||||||
|
|
||||||
String cipher;
|
late String cipher;
|
||||||
int sortKey;
|
late int sortKey;
|
||||||
bool connected;
|
late bool connected;
|
||||||
String status;
|
late String status;
|
||||||
String logFile;
|
late String logFile;
|
||||||
String logVerbosity;
|
late String logVerbosity;
|
||||||
|
|
||||||
|
late bool managed;
|
||||||
|
// The following fields are present when managed = true
|
||||||
|
late String? rawConfig;
|
||||||
|
late DateTime? lastManagedUpdate;
|
||||||
|
|
||||||
// A list of errors encountered while loading the site
|
// A list of errors encountered while loading the site
|
||||||
List<String> errors;
|
late List<String> errors;
|
||||||
|
|
||||||
Site(
|
Site({
|
||||||
{this.name,
|
String name = '',
|
||||||
id,
|
String? id,
|
||||||
staticHostmap,
|
Map<String, StaticHost>? staticHostmap,
|
||||||
caInfos,
|
List<CertificateInfo>? ca,
|
||||||
certInfos,
|
CertificateInfo? certInfo,
|
||||||
this.lhDuration = 0,
|
int lhDuration = 0,
|
||||||
this.port = 0,
|
int port = 0,
|
||||||
this.cipher = "aes",
|
String cipher = "aes",
|
||||||
this.sortKey,
|
int sortKey = 0,
|
||||||
this.mtu = 1300,
|
int mtu = 1300,
|
||||||
this.connected,
|
bool connected = false,
|
||||||
this.status,
|
String status = '',
|
||||||
this.logFile,
|
String logFile = '',
|
||||||
this.logVerbosity = 'info',
|
String logVerbosity = 'info',
|
||||||
errors,
|
List<String>? errors,
|
||||||
unsafeRoutes})
|
List<UnsafeRoute>? unsafeRoutes,
|
||||||
: staticHostmap = staticHostmap ?? {},
|
List<String>? dnsResolvers,
|
||||||
unsafeRoutes = unsafeRoutes ?? [],
|
bool managed = false,
|
||||||
errors = errors ?? [],
|
String? rawConfig,
|
||||||
caInfos = caInfos ?? [],
|
DateTime? lastManagedUpdate,
|
||||||
certInfos = certInfos ?? [],
|
}) {
|
||||||
id = id ?? uuid.v4();
|
this.name = name;
|
||||||
|
this.id = id ?? uuid.v4();
|
||||||
Site.fromJson(Map<String, dynamic> json) {
|
this.staticHostmap = staticHostmap ?? {};
|
||||||
name = json['name'];
|
this.ca = ca ?? [];
|
||||||
id = json['id'];
|
this.certInfo = certInfo;
|
||||||
|
this.lhDuration = lhDuration;
|
||||||
Map<String, dynamic> rawHostmap = json['staticHostmap'];
|
this.port = port;
|
||||||
staticHostmap = {};
|
this.cipher = cipher;
|
||||||
rawHostmap.forEach((key, val) {
|
this.sortKey = sortKey;
|
||||||
staticHostmap[key] = StaticHost.fromJson(val);
|
this.mtu = mtu;
|
||||||
});
|
this.connected = connected;
|
||||||
|
this.status = status;
|
||||||
List<dynamic> rawUnsafeRoutes = json['unsafeRoutes'];
|
this.logFile = logFile;
|
||||||
unsafeRoutes = [];
|
this.logVerbosity = logVerbosity;
|
||||||
if (rawUnsafeRoutes != null) {
|
this.errors = errors ?? [];
|
||||||
rawUnsafeRoutes.forEach((val) {
|
this.unsafeRoutes = unsafeRoutes ?? [];
|
||||||
unsafeRoutes.add(UnsafeRoute.fromJson(val));
|
this.dnsResolvers = dnsResolvers ?? [];
|
||||||
});
|
this.managed = managed;
|
||||||
}
|
this.rawConfig = rawConfig;
|
||||||
|
this.lastManagedUpdate = lastManagedUpdate;
|
||||||
List<dynamic> rawCA = json['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 {
|
||||||
this.status = d['status'];
|
_updateFromJson(d);
|
||||||
this.connected = d['connected'];
|
|
||||||
_change.add(null);
|
_change.add(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//TODO: handle the error
|
//TODO: handle the error
|
||||||
print(err);
|
print(err);
|
||||||
}
|
}
|
||||||
}, onError: (err) {
|
}, onError: (err) {
|
||||||
|
_updateFromJson(err.details);
|
||||||
var error = err as PlatformException;
|
var error = err as PlatformException;
|
||||||
this.status = error.details['status'];
|
_change.addError(error.message ?? 'An unexpected error occurred');
|
||||||
this.connected = error.details['connected'];
|
|
||||||
_change.addError(error.message);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
factory Site.fromJson(Map<String, dynamic> json) {
|
||||||
|
var decoded = Site._fromJson(json);
|
||||||
|
return Site(
|
||||||
|
name: decoded["name"],
|
||||||
|
id: decoded['id'],
|
||||||
|
staticHostmap: decoded['staticHostmap'],
|
||||||
|
ca: decoded['ca'],
|
||||||
|
certInfo: decoded['certInfo'],
|
||||||
|
lhDuration: decoded['lhDuration'],
|
||||||
|
port: decoded['port'],
|
||||||
|
cipher: decoded['cipher'],
|
||||||
|
sortKey: decoded['sortKey'],
|
||||||
|
mtu: decoded['mtu'],
|
||||||
|
connected: decoded['connected'],
|
||||||
|
status: decoded['status'],
|
||||||
|
logFile: decoded['logFile'],
|
||||||
|
logVerbosity: decoded['logVerbosity'],
|
||||||
|
errors: decoded['errors'],
|
||||||
|
unsafeRoutes: decoded['unsafeRoutes'],
|
||||||
|
dnsResolvers: decoded['dnsResolvers'],
|
||||||
|
managed: decoded['managed'],
|
||||||
|
rawConfig: decoded['rawConfig'],
|
||||||
|
lastManagedUpdate: decoded['lastManagedUpdate'],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateFromJson(String json) {
|
||||||
|
var decoded = Site._fromJson(jsonDecode(json));
|
||||||
|
this.name = decoded["name"];
|
||||||
|
this.id = decoded['id']; // TODO update EventChannel
|
||||||
|
this.staticHostmap = decoded['staticHostmap'];
|
||||||
|
this.ca = decoded['ca'];
|
||||||
|
this.certInfo = decoded['certInfo'];
|
||||||
|
this.lhDuration = decoded['lhDuration'];
|
||||||
|
this.port = decoded['port'];
|
||||||
|
this.cipher = decoded['cipher'];
|
||||||
|
this.sortKey = decoded['sortKey'];
|
||||||
|
this.mtu = decoded['mtu'];
|
||||||
|
this.connected = decoded['connected'];
|
||||||
|
this.status = decoded['status'];
|
||||||
|
this.logFile = decoded['logFile'];
|
||||||
|
this.logVerbosity = decoded['logVerbosity'];
|
||||||
|
this.errors = decoded['errors'];
|
||||||
|
this.unsafeRoutes = decoded['unsafeRoutes'];
|
||||||
|
this.dnsResolvers = decoded['dnsResolvers'];
|
||||||
|
this.managed = decoded['managed'];
|
||||||
|
this.rawConfig = decoded['rawConfig'];
|
||||||
|
this.lastManagedUpdate = decoded['lastManagedUpdate'];
|
||||||
|
}
|
||||||
|
|
||||||
|
static _fromJson(Map<String, dynamic> json) {
|
||||||
|
Map<String, dynamic> rawHostmap = json['staticHostmap'];
|
||||||
|
Map<String, StaticHost> staticHostmap = {};
|
||||||
|
rawHostmap.forEach((key, val) {
|
||||||
|
staticHostmap[key] = StaticHost.fromJson(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<dynamic> rawUnsafeRoutes = json['unsafeRoutes'];
|
||||||
|
List<UnsafeRoute> unsafeRoutes = [];
|
||||||
|
rawUnsafeRoutes.forEach((val) {
|
||||||
|
unsafeRoutes.add(UnsafeRoute.fromJson(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
List<dynamic> rawDNSResolvers = json['dnsResolvers'];
|
||||||
|
List<String> dnsResolvers = [];
|
||||||
|
rawDNSResolvers.forEach((val) {
|
||||||
|
dnsResolvers.add(val);
|
||||||
|
});
|
||||||
|
|
||||||
|
List<dynamic> rawCA = json['ca'];
|
||||||
|
List<CertificateInfo> ca = [];
|
||||||
|
rawCA.forEach((val) {
|
||||||
|
ca.add(CertificateInfo.fromJson(val));
|
||||||
|
});
|
||||||
|
|
||||||
|
CertificateInfo? certInfo;
|
||||||
|
if (json['cert'] != null) {
|
||||||
|
certInfo = CertificateInfo.fromJson(json['cert']);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<dynamic> rawErrors = json["errors"];
|
||||||
|
List<String> errors = [];
|
||||||
|
rawErrors.forEach((error) {
|
||||||
|
errors.add(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": json["name"],
|
||||||
|
"id": json['id'],
|
||||||
|
"staticHostmap": staticHostmap,
|
||||||
|
"ca": ca,
|
||||||
|
"certInfo": certInfo,
|
||||||
|
"lhDuration": json['lhDuration'],
|
||||||
|
"port": json['port'],
|
||||||
|
"cipher": json['cipher'],
|
||||||
|
"sortKey": json['sortKey'],
|
||||||
|
"mtu": json['mtu'],
|
||||||
|
"connected": json['connected'] ?? false,
|
||||||
|
"status": json['status'] ?? "",
|
||||||
|
"logFile": json['logFile'],
|
||||||
|
"logVerbosity": json['logVerbosity'],
|
||||||
|
"errors": errors,
|
||||||
|
"unsafeRoutes": unsafeRoutes,
|
||||||
|
"dnsResolvers": dnsResolvers,
|
||||||
|
"managed": json['managed'] ?? false,
|
||||||
|
"rawConfig": json['rawConfig'],
|
||||||
|
"lastManagedUpdate": json["lastManagedUpdate"] == null ?
|
||||||
|
null : DateTime.parse(json["lastManagedUpdate"]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
Stream onChange() {
|
Stream onChange() {
|
||||||
return _change.stream;
|
return _change.stream;
|
||||||
}
|
}
|
||||||
|
@ -149,17 +234,20 @@ class Site {
|
||||||
'id': id,
|
'id': id,
|
||||||
'staticHostmap': staticHostmap,
|
'staticHostmap': staticHostmap,
|
||||||
'unsafeRoutes': unsafeRoutes,
|
'unsafeRoutes': unsafeRoutes,
|
||||||
'ca': caInfos?.map((cert) {
|
'dnsResolvers': dnsResolvers,
|
||||||
return cert.rawCert;
|
'ca': ca.map((cert) {
|
||||||
})?.join('\n') ??
|
return cert.rawCert;
|
||||||
"",
|
}).join('\n'),
|
||||||
'certs': certInfos,
|
'cert': certInfo?.rawCert,
|
||||||
|
'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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,7 +299,7 @@ class Site {
|
||||||
Future<List<HostInfo>> listHostmap() async {
|
Future<List<HostInfo>> listHostmap() async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.listHostmap", <String, String>{"id": id});
|
var ret = await platform.invokeMethod("active.listHostmap", <String, String>{"id": id});
|
||||||
if (ret == null) {
|
if (ret == null || ret == "null") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -222,7 +310,6 @@ class Site {
|
||||||
});
|
});
|
||||||
|
|
||||||
return hosts;
|
return hosts;
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
//TODO: fix this message
|
//TODO: fix this message
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
|
@ -234,7 +321,7 @@ class Site {
|
||||||
Future<List<HostInfo>> listPendingHostmap() async {
|
Future<List<HostInfo>> listPendingHostmap() async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.listPendingHostmap", <String, String>{"id": id});
|
var ret = await platform.invokeMethod("active.listPendingHostmap", <String, String>{"id": id});
|
||||||
if (ret == null) {
|
if (ret == null || ret == "null") {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,7 +332,6 @@ class Site {
|
||||||
});
|
});
|
||||||
|
|
||||||
return hosts;
|
return hosts;
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -257,7 +343,6 @@ class Site {
|
||||||
try {
|
try {
|
||||||
var res = await Future.wait([this.listHostmap(), this.listPendingHostmap()]);
|
var res = await Future.wait([this.listHostmap(), this.listPendingHostmap()]);
|
||||||
return {"active": res[0], "pending": res[1]};
|
return {"active": res[0], "pending": res[1]};
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -269,16 +354,16 @@ class Site {
|
||||||
_change.close();
|
_change.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<HostInfo> getHostInfo(String vpnIp, bool pending) async {
|
Future<HostInfo?> getHostInfo(String vpnIp, bool pending) async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.getHostInfo", <String, dynamic>{"id": id, "vpnIp": vpnIp, "pending": pending});
|
var ret = await platform
|
||||||
|
.invokeMethod("active.getHostInfo", <String, dynamic>{"id": id, "vpnIp": vpnIp, "pending": pending});
|
||||||
final h = jsonDecode(ret);
|
final h = jsonDecode(ret);
|
||||||
if (h == null) {
|
if (h == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return HostInfo.fromJson(h);
|
return HostInfo.fromJson(h);
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -286,16 +371,16 @@ class Site {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<HostInfo> setRemoteForTunnel(String vpnIp, String addr) async {
|
Future<HostInfo?> setRemoteForTunnel(String vpnIp, String addr) async {
|
||||||
try {
|
try {
|
||||||
var ret = await platform.invokeMethod("active.setRemoteForTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp, "addr": addr});
|
var ret = await platform
|
||||||
|
.invokeMethod("active.setRemoteForTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp, "addr": addr});
|
||||||
final h = jsonDecode(ret);
|
final h = jsonDecode(ret);
|
||||||
if (h == null) {
|
if (h == null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return HostInfo.fromJson(h);
|
return HostInfo.fromJson(h);
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
@ -306,7 +391,6 @@ class Site {
|
||||||
Future<bool> closeTunnel(String vpnIp) async {
|
Future<bool> closeTunnel(String vpnIp) async {
|
||||||
try {
|
try {
|
||||||
return await platform.invokeMethod("active.closeTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp});
|
return await platform.invokeMethod("active.closeTunnel", <String, dynamic>{"id": id, "vpnIp": vpnIp});
|
||||||
|
|
||||||
} on PlatformException catch (err) {
|
} on PlatformException catch (err) {
|
||||||
throw err.details ?? err.message ?? err.toString();
|
throw err.details ?? err.message ?? err.toString();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
|
@ -4,11 +4,9 @@ class StaticHost {
|
||||||
bool lighthouse;
|
bool lighthouse;
|
||||||
List<IPAndPort> destinations;
|
List<IPAndPort> destinations;
|
||||||
|
|
||||||
StaticHost({this.lighthouse, this.destinations});
|
StaticHost({required this.lighthouse, required this.destinations});
|
||||||
|
|
||||||
StaticHost.fromJson(Map<String, dynamic> json) {
|
|
||||||
lighthouse = json['lighthouse'];
|
|
||||||
|
|
||||||
|
factory StaticHost.fromJson(Map<String, dynamic> json) {
|
||||||
var list = json['destinations'] as List<dynamic>;
|
var list = json['destinations'] as List<dynamic>;
|
||||||
var result = <IPAndPort>[];
|
var result = <IPAndPort>[];
|
||||||
|
|
||||||
|
@ -16,7 +14,10 @@ class StaticHost {
|
||||||
result.add(IPAndPort.fromString(item));
|
result.add(IPAndPort.fromString(item));
|
||||||
});
|
});
|
||||||
|
|
||||||
destinations = result;
|
return StaticHost(
|
||||||
|
lighthouse: json['lighthouse'],
|
||||||
|
destinations: result,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
|
|
@ -1,12 +1,14 @@
|
||||||
class UnsafeRoute {
|
class UnsafeRoute {
|
||||||
String route;
|
String? route;
|
||||||
String via;
|
String? via;
|
||||||
|
|
||||||
UnsafeRoute({this.route, this.via});
|
UnsafeRoute({this.route, this.via});
|
||||||
|
|
||||||
UnsafeRoute.fromJson(Map<String, dynamic> json) {
|
factory UnsafeRoute.fromJson(Map<String, dynamic> json) {
|
||||||
route = json['route'];
|
return UnsafeRoute(
|
||||||
via = json['via'];
|
route: json['route'],
|
||||||
|
via: json['via'],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() {
|
||||||
|
@ -15,4 +17,4 @@ class UnsafeRoute {
|
||||||
'via': via,
|
'via': via,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
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';
|
||||||
|
@ -12,7 +9,7 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:package_info/package_info.dart';
|
import 'package:package_info/package_info.dart';
|
||||||
|
|
||||||
class AboutScreen extends StatefulWidget {
|
class AboutScreen extends StatefulWidget {
|
||||||
const AboutScreen({Key key}) : super(key: key);
|
const AboutScreen({Key? key}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_AboutScreenState createState() => _AboutScreenState();
|
_AboutScreenState createState() => _AboutScreenState();
|
||||||
|
@ -20,7 +17,7 @@ class AboutScreen extends StatefulWidget {
|
||||||
|
|
||||||
class _AboutScreenState extends State<AboutScreen> {
|
class _AboutScreenState extends State<AboutScreen> {
|
||||||
bool ready = false;
|
bool ready = false;
|
||||||
PackageInfo packageInfo;
|
PackageInfo? packageInfo;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -36,6 +33,7 @@ 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: (_, __) {
|
||||||
|
@ -45,26 +43,44 @@ class _AboutScreenState extends State<AboutScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: 'About',
|
title: Text('About'),
|
||||||
child: Column(children: [
|
child: Column(children: [
|
||||||
ConfigSection(children: <Widget>[
|
ConfigSection(children: <Widget>[
|
||||||
ConfigItem(label: Text('App version'), labelWidth: 150, content: _buildText('${packageInfo.version}-${packageInfo.buildNumber} (sha: $gitSha)')),
|
ConfigItem(
|
||||||
ConfigItem(label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')),
|
label: Text('App version'),
|
||||||
ConfigItem(label: Text('Flutter version'), labelWidth: 150, content: _buildText(flutterVersion['frameworkVersion'])),
|
labelWidth: 150,
|
||||||
ConfigItem(label: Text('Dart version'), labelWidth: 150, content: _buildText(flutterVersion['dartSdkVersion'])),
|
content: _buildText('${packageInfo!.version}-${packageInfo!.buildNumber} (sha: $gitSha)')),
|
||||||
|
ConfigItem(
|
||||||
|
label: Text('Nebula version'), labelWidth: 150, content: _buildText('$nebulaVersion ($goVersion)')),
|
||||||
|
ConfigItem(
|
||||||
|
label: Text('Flutter version'),
|
||||||
|
labelWidth: 150,
|
||||||
|
content: _buildText(flutterVersion['frameworkVersion'] ?? 'Unknown')),
|
||||||
|
ConfigItem(
|
||||||
|
label: Text('Dart version'),
|
||||||
|
labelWidth: 150,
|
||||||
|
content: _buildText(flutterVersion['dartSdkVersion'] ?? 'Unknown')),
|
||||||
]),
|
]),
|
||||||
ConfigSection(children: <Widget>[
|
ConfigSection(children: <Widget>[
|
||||||
//TODO: wire up these other pages
|
//TODO: wire up these other pages
|
||||||
// ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)),
|
// ConfigPageItem(label: Text('Changelog'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/changelog', context)),
|
||||||
ConfigPageItem(label: Text('Privacy policy'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/privacy-policy', context)),
|
ConfigPageItem(
|
||||||
|
label: Text('Privacy policy'),
|
||||||
|
labelWidth: 300,
|
||||||
|
onPressed: () => Utils.launchUrl('https://www.defined.net/privacy/', context)),
|
||||||
// ConfigPageItem(label: Text('Licenses'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/license', context)),
|
// ConfigPageItem(label: Text('Licenses'), labelWidth: 300, onPressed: () => Utils.launchUrl('https://defined.net/mobile/license', context)),
|
||||||
]),
|
]),
|
||||||
Padding(padding: EdgeInsets.only(top: 20), child: Text('Copyright © 2020 Defined Networking, Inc', textAlign: TextAlign.center,)),
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 20),
|
||||||
|
child: Text(
|
||||||
|
'Copyright © 2022 Defined Networking, Inc',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)),
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_buildText(String str) {
|
_buildText(String str) {
|
||||||
return Align(alignment: AlignmentDirectional.centerEnd, child: SpecialSelectableText(str));
|
return Align(alignment: AlignmentDirectional.centerEnd, child: SelectableText(str));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,186 @@
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
|
|
||||||
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
|
|
||||||
|
class EnrollmentScreen extends StatefulWidget {
|
||||||
|
final String? code;
|
||||||
|
final StreamController? stream;
|
||||||
|
final bool allowCodeEntry;
|
||||||
|
|
||||||
|
static const routeName = '/v1/mobile-enrollment';
|
||||||
|
|
||||||
|
// Attempts to find an enrollment code in the provided url. If one is not found then assume the input was
|
||||||
|
// an enrollment code. Primarily to support manual dn enrollment where the user can input a code or a url.
|
||||||
|
static String parseCode(String url) {
|
||||||
|
final uri = Uri.parse(url);
|
||||||
|
if (uri.path != EnrollmentScreen.routeName) {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uri.hasFragment) {
|
||||||
|
final qp = Uri.splitQueryString(uri.fragment);
|
||||||
|
return qp["code"] ?? "";
|
||||||
|
}
|
||||||
|
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EnrollmentScreen({super.key, this.code, this.stream, this.allowCodeEntry = false});
|
||||||
|
|
||||||
|
@override
|
||||||
|
_EnrollmentScreenState createState() => _EnrollmentScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EnrollmentScreenState extends State<EnrollmentScreen> {
|
||||||
|
String? error;
|
||||||
|
var enrolled = false;
|
||||||
|
var enrollInput = TextEditingController();
|
||||||
|
String? code;
|
||||||
|
|
||||||
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
|
|
||||||
|
void initState() {
|
||||||
|
code = widget.code;
|
||||||
|
super.initState();
|
||||||
|
_enroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
enrollInput.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
_enroll() async {
|
||||||
|
try {
|
||||||
|
await platform.invokeMethod("dn.enroll", code);
|
||||||
|
setState(() {
|
||||||
|
enrolled = true;
|
||||||
|
if (widget.stream != null) {
|
||||||
|
// Signal a new site has been added
|
||||||
|
widget.stream!.add(null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} on PlatformException catch (err) {
|
||||||
|
setState(() {
|
||||||
|
error = err.details ?? err.message;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
final textTheme = Theme.of(context).textTheme;
|
||||||
|
final bodyTextStyle = textTheme.bodyLarge!.apply(color: colorScheme.onPrimary);
|
||||||
|
final contactUri = Uri.parse('mailto:support@defined.net');
|
||||||
|
|
||||||
|
Widget child;
|
||||||
|
AlignmentGeometry? alignment;
|
||||||
|
|
||||||
|
if (code == null) {
|
||||||
|
if (widget.allowCodeEntry) {
|
||||||
|
child = _codeEntry();
|
||||||
|
} else {
|
||||||
|
// No code, show the error
|
||||||
|
child = Padding(
|
||||||
|
child: Center(child: Text(
|
||||||
|
'No valid enrollment code was found.\n\nContact your administrator to obtain a new enrollment code.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
|
||||||
|
)),
|
||||||
|
padding: EdgeInsets.only(top: 20)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (this.error != null) {
|
||||||
|
// Error while enrolling, display it
|
||||||
|
child = Center(child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
child: SelectableText('There was an issue while attempting to enroll this device. Contact your administrator to obtain a new enrollment code.'),
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 20)
|
||||||
|
),
|
||||||
|
Padding(child: SelectableText.rich(TextSpan(children: [
|
||||||
|
TextSpan(text: 'If the problem persists, please let us know at '),
|
||||||
|
TextSpan(
|
||||||
|
text: 'support@defined.net',
|
||||||
|
style: bodyTextStyle.apply(color: colorScheme.primary),
|
||||||
|
recognizer: TapGestureRecognizer()
|
||||||
|
..onTap = () async {
|
||||||
|
if (await canLaunchUrl(contactUri)) {
|
||||||
|
print(await launchUrl(contactUri));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextSpan(text: ' and provide the following error:'),
|
||||||
|
])), padding: EdgeInsets.only(bottom: 10)),
|
||||||
|
Container(
|
||||||
|
child: Padding(child: SelectableText(this.error!), padding: EdgeInsets.all(10)),
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
));
|
||||||
|
|
||||||
|
} else if (this.enrolled) {
|
||||||
|
// Enrollment complete!
|
||||||
|
child = Padding(
|
||||||
|
child: Center(child: Text(
|
||||||
|
'Enrollment complete! 🎉',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
)),
|
||||||
|
padding: EdgeInsets.only(top: 20)
|
||||||
|
);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// Have a code and actively enrolling
|
||||||
|
alignment = Alignment.center;
|
||||||
|
child = Center(child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(child: Text('Contacting DN for enrollment'), padding: EdgeInsets.only(bottom: 25)),
|
||||||
|
PlatformCircularProgressIndicator(cupertino: (_, __) {
|
||||||
|
return CupertinoProgressIndicatorData(radius: 50);
|
||||||
|
})
|
||||||
|
]
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
return SimplePage(
|
||||||
|
title: Text('Enroll with Managed Nebula', style: TextStyle(fontWeight: FontWeight.bold)),
|
||||||
|
child: Padding(child: child, padding: EdgeInsets.symmetric(horizontal: 10)),
|
||||||
|
alignment: alignment
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _codeEntry() {
|
||||||
|
return Column(children: [
|
||||||
|
Padding(
|
||||||
|
padding: EdgeInsets.only(top: 20),
|
||||||
|
child: PlatformTextField(
|
||||||
|
hintText: 'defined.net enrollment code or link',
|
||||||
|
controller: enrollInput,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
PlatformTextButton(
|
||||||
|
child: Text('Submit'),
|
||||||
|
onPressed: () {
|
||||||
|
setState(() {
|
||||||
|
code = EnrollmentScreen.parseCode(enrollInput.text);
|
||||||
|
error = null;
|
||||||
|
_enroll();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,9 +1,7 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialSelectableText.dart';
|
|
||||||
import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigCheckboxItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
|
@ -16,15 +14,24 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
class HostInfoScreen extends StatefulWidget {
|
class HostInfoScreen extends StatefulWidget {
|
||||||
const HostInfoScreen({Key key, this.hostInfo, this.isLighthouse, this.pending, this.onChanged, this.site})
|
const HostInfoScreen({
|
||||||
: super(key: key);
|
Key? key,
|
||||||
|
required this.hostInfo,
|
||||||
|
required this.isLighthouse,
|
||||||
|
required this.pending,
|
||||||
|
this.onChanged,
|
||||||
|
required this.site,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
final bool isLighthouse;
|
final bool isLighthouse;
|
||||||
final bool pending;
|
final bool pending;
|
||||||
final HostInfo hostInfo;
|
final HostInfo hostInfo;
|
||||||
final Function onChanged;
|
final Function? onChanged;
|
||||||
final Site site;
|
final Site site;
|
||||||
|
|
||||||
|
final bool supportsQRScanning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_HostInfoScreenState createState() => _HostInfoScreenState();
|
_HostInfoScreenState createState() => _HostInfoScreenState();
|
||||||
}
|
}
|
||||||
|
@ -32,7 +39,7 @@ class HostInfoScreen extends StatefulWidget {
|
||||||
//TODO: have a config option to refresh hostmaps on a cadence (applies to 3 screens so far)
|
//TODO: have a config option to refresh hostmaps on a cadence (applies to 3 screens so far)
|
||||||
|
|
||||||
class _HostInfoScreenState extends State<HostInfoScreen> {
|
class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
HostInfo hostInfo;
|
late HostInfo hostInfo;
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -46,7 +53,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
final title = widget.pending ? 'Pending' : 'Active';
|
final title = widget.pending ? 'Pending' : 'Active';
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: '$title Host Info',
|
title: Text('$title Host Info'),
|
||||||
refreshController: refreshController,
|
refreshController: refreshController,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await _getHostInfo();
|
await _getHostInfo();
|
||||||
|
@ -61,14 +68,17 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
|
|
||||||
Widget _buildMain() {
|
Widget _buildMain() {
|
||||||
return ConfigSection(children: [
|
return ConfigSection(children: [
|
||||||
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SpecialSelectableText(hostInfo.vpnIp)),
|
ConfigItem(label: Text('VPN IP'), labelWidth: 150, content: SelectableText(hostInfo.vpnIp)),
|
||||||
hostInfo.cert != null
|
hostInfo.cert != null
|
||||||
? ConfigPageItem(
|
? ConfigPageItem(
|
||||||
label: Text('Certificate'),
|
label: Text('Certificate'),
|
||||||
labelWidth: 150,
|
labelWidth: 150,
|
||||||
content: Text(hostInfo.cert.details.name),
|
content: Text(hostInfo.cert!.details.name),
|
||||||
onPressed: () => Utils.openPage(
|
onPressed: () => Utils.openPage(
|
||||||
context, (context) => CertificateDetailsScreen(CertificateInfo(cert: hostInfo.cert))))
|
context, (context) => CertificateDetailsScreen(
|
||||||
|
certInfo: CertificateInfo(cert: hostInfo.cert!),
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
)))
|
||||||
: Container(),
|
: Container(),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
@ -76,18 +86,12 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
Widget _buildDetails() {
|
Widget _buildDetails() {
|
||||||
return ConfigSection(children: <Widget>[
|
return ConfigSection(children: <Widget>[
|
||||||
ConfigItem(
|
ConfigItem(
|
||||||
label: Text('Lighthouse'),
|
label: Text('Lighthouse'), labelWidth: 150, content: SelectableText(widget.isLighthouse ? 'Yes' : 'No')),
|
||||||
labelWidth: 150,
|
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SelectableText('${hostInfo.localIndex}')),
|
||||||
content: SpecialSelectableText(widget.isLighthouse ? 'Yes' : 'No')),
|
ConfigItem(label: Text('Remote Index'), labelWidth: 150, content: SelectableText('${hostInfo.remoteIndex}')),
|
||||||
ConfigItem(label: Text('Local Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.localIndex}')),
|
|
||||||
ConfigItem(
|
ConfigItem(
|
||||||
label: Text('Remote Index'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.remoteIndex}')),
|
label: Text('Message Counter'), labelWidth: 150, content: SelectableText('${hostInfo.messageCounter}')),
|
||||||
ConfigItem(
|
ConfigItem(label: Text('Cached Packets'), labelWidth: 150, content: SelectableText('${hostInfo.cachedPackets}')),
|
||||||
label: Text('Message Counter'),
|
|
||||||
labelWidth: 150,
|
|
||||||
content: SpecialSelectableText('${hostInfo.messageCounter}')),
|
|
||||||
ConfigItem(
|
|
||||||
label: Text('Cached Packets'), labelWidth: 150, content: SpecialSelectableText('${hostInfo.cachedPackets}')),
|
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -124,7 +128,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
_setHostInfo(h);
|
_setHostInfo(h);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while changing the remote', err);
|
Utils.popError(context, 'Error while changing the remote', err.toString());
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
|
@ -157,20 +161,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: PlatformButton(
|
child: PlatformElevatedButton(
|
||||||
child: Text('Close Tunnel'),
|
child: Text('Close Tunnel'),
|
||||||
color: CupertinoColors.systemRed.resolveFrom(context),
|
color: CupertinoColors.systemRed.resolveFrom(context),
|
||||||
onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async {
|
onPressed: () => Utils.confirmDelete(context, 'Close Tunnel?', () async {
|
||||||
try {
|
try {
|
||||||
await widget.site.closeTunnel(hostInfo.vpnIp);
|
await widget.site.closeTunnel(hostInfo.vpnIp);
|
||||||
if (widget.onChanged != null) {
|
if (widget.onChanged != null) {
|
||||||
widget.onChanged();
|
widget.onChanged!();
|
||||||
}
|
}
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while trying to close the tunnel', err);
|
Utils.popError(context, 'Error while trying to close the tunnel', err.toString());
|
||||||
}
|
}
|
||||||
}, deleteLabel: 'Close'))));
|
}, deleteLabel: 'Close'))));
|
||||||
}
|
}
|
||||||
|
|
||||||
_getHostInfo() async {
|
_getHostInfo() async {
|
||||||
|
@ -182,7 +186,7 @@ class _HostInfoScreenState extends State<HostInfoScreen> {
|
||||||
|
|
||||||
_setHostInfo(h);
|
_setHostInfo(h);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Failed to refresh host info', err);
|
Utils.popError(context, 'Failed to refresh host info', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,11 @@
|
||||||
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
import 'dart:math';
|
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SiteItem.dart';
|
import 'package:mobile_nebula/components/SiteItem.dart';
|
||||||
|
@ -15,71 +14,167 @@ import 'package:mobile_nebula/models/IPAndPort.dart';
|
||||||
import 'package:mobile_nebula/models/Site.dart';
|
import 'package:mobile_nebula/models/Site.dart';
|
||||||
import 'package:mobile_nebula/models/StaticHosts.dart';
|
import 'package:mobile_nebula/models/StaticHosts.dart';
|
||||||
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
import 'package:mobile_nebula/models/UnsafeRoute.dart';
|
||||||
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
||||||
import 'package:mobile_nebula/screens/SettingsScreen.dart';
|
import 'package:mobile_nebula/screens/SettingsScreen.dart';
|
||||||
import 'package:mobile_nebula/screens/SiteDetailScreen.dart';
|
import 'package:mobile_nebula/screens/SiteDetailScreen.dart';
|
||||||
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
|
import 'package:mobile_nebula/screens/siteConfig/SiteConfigScreen.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
|
|
||||||
//TODO: add refresh
|
/// Contains an expired CA and certificate
|
||||||
|
const badDebugSave = {
|
||||||
|
'name': 'Bad Site',
|
||||||
|
'cert': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa
|
||||||
|
A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp
|
||||||
|
VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU
|
||||||
|
Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ==
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
||||||
|
rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
|
-----END NEBULA X25519 PRIVATE KEY-----''',
|
||||||
|
'ca': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
|
||||||
|
4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv
|
||||||
|
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Contains an expired CA and certificate
|
||||||
|
const goodDebugSave = {
|
||||||
|
'name': 'Good Site',
|
||||||
|
'cert': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CmcKCmRlYnVnIGhvc3QSCYKAhFCA/v//DyiX0ZaaBjDjjPf5ETogyYzKdlRh7pW6
|
||||||
|
yOd8+aMQAFPha2wuYixuq53ru9+qXC9KIJd3ow6qIiaHInT1dgJvy+122WK7g86+
|
||||||
|
Z8qYtTZnox1cEkBYpC0SySrCp6jd/zeAFEJM6naPYgc6rmy/H/qveyQ6WAtbgLpK
|
||||||
|
tM3EXbbOE9+fV/Ma6Oilf1SixO3ZBo30nRYL
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
'key': '''-----BEGIN NEBULA X25519 PRIVATE KEY-----
|
||||||
|
vu9t0mNy8cD5x3CMVpQ/cdKpjdz46NBlcRqvJAQpO44=
|
||||||
|
-----END NEBULA X25519 PRIVATE KEY-----''',
|
||||||
|
'ca': '''-----BEGIN NEBULA CERTIFICATE-----
|
||||||
|
CjcKBWRlYnVnKOTQlpoGMOSM9/kROiCWNJUs7c4ZRzUn2LbeAEQrz2PVswnu9dcL
|
||||||
|
Sn/2VNNu30ABEkCQtWxmCJqBr5Yd9vtDWCPo/T1JQmD3stBozcM6aUl1hP3zjURv
|
||||||
|
MAIH7gzreMGgrH/yR6rZpIHR3DxJ3E0aHtEI
|
||||||
|
-----END NEBULA CERTIFICATE-----''',
|
||||||
|
};
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
const MainScreen({Key key}) : super(key: key);
|
const MainScreen(this.dnEnrollStream, {Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
final StreamController dnEnrollStream;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_MainScreenState createState() => _MainScreenState();
|
_MainScreenState createState() => _MainScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MainScreenState extends State<MainScreen> {
|
class _MainScreenState extends State<MainScreen> {
|
||||||
bool ready = false;
|
List<Site>? sites;
|
||||||
List<Site> sites;
|
// A set of widgets to display in a column that represents an error blocking us from moving forward entirely
|
||||||
|
List<Widget>? error;
|
||||||
|
|
||||||
|
bool supportsQRScanning = false;
|
||||||
|
|
||||||
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
|
RefreshController refreshController = RefreshController();
|
||||||
|
ScrollController scrollController = ScrollController();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
_loadSites();
|
_loadSites();
|
||||||
|
|
||||||
|
widget.dnEnrollStream.stream.listen((_) {
|
||||||
|
_loadSites();
|
||||||
|
});
|
||||||
|
|
||||||
|
platform.setMethodCallHandler(handleMethodCall);
|
||||||
|
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
scrollController.dispose();
|
||||||
|
refreshController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<dynamic> handleMethodCall(MethodCall call) async {
|
||||||
|
switch (call.method) {
|
||||||
|
case "refreshSites":
|
||||||
|
_loadSites();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
print("ERR: Unexpected method call ${call.method}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
Widget? debugSite;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
debugSite = Row(
|
||||||
|
children: [
|
||||||
|
_debugSave(badDebugSave),
|
||||||
|
_debugSave(goodDebugSave),
|
||||||
|
_debugClearKeys(),
|
||||||
|
],
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whether the device supports QR scanning. For example, some
|
||||||
|
// Chromebooks do not have camera support.
|
||||||
|
if (Platform.isAndroid) {
|
||||||
|
platform.invokeMethod("android.deviceHasCamera").then(
|
||||||
|
(hasCamera) => setState(() => supportsQRScanning = hasCamera)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
supportsQRScanning = true;
|
||||||
|
}
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: 'Nebula',
|
title: Text('Nebula'),
|
||||||
scrollable: SimpleScrollable.none,
|
scrollable: SimpleScrollable.vertical,
|
||||||
|
scrollController: scrollController,
|
||||||
leadingAction: PlatformIconButton(
|
leadingAction: PlatformIconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon: Icon(Icons.add, size: 28.0),
|
icon: Icon(Icons.add, size: 28.0),
|
||||||
onPressed: () => Utils.openPage(context, (context) {
|
onPressed: () => Utils.openPage(context, (context) {
|
||||||
return SiteConfigScreen(onSave: (_) {
|
return SiteConfigScreen(onSave: (_) {
|
||||||
_loadSites();
|
_loadSites();
|
||||||
});
|
}, supportsQRScanning: supportsQRScanning);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
refreshController: refreshController,
|
||||||
|
onRefresh: () {
|
||||||
|
_loadSites();
|
||||||
|
refreshController.refreshCompleted();
|
||||||
|
},
|
||||||
trailingActions: <Widget>[
|
trailingActions: <Widget>[
|
||||||
PlatformIconButton(
|
PlatformIconButton(
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
icon: Icon(Icons.menu, size: 28.0),
|
icon: Icon(Icons.menu, size: 28.0),
|
||||||
onPressed: () => Utils.openPage(context, (_) => SettingsScreen()),
|
onPressed: () => Utils.openPage(context, (_) => SettingsScreen(widget.dnEnrollStream)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
bottomBar: kDebugMode ? _debugSave() : null,
|
bottomBar: debugSite,
|
||||||
child: _buildBody(),
|
child: _buildBody(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody() {
|
Widget _buildBody() {
|
||||||
if (!ready) {
|
if (error != null) {
|
||||||
return Center(
|
return Center(
|
||||||
child: PlatformCircularProgressIndicator(cupertino: (_, __) {
|
child: Padding(
|
||||||
return CupertinoProgressIndicatorData(radius: 500);
|
child: Column(
|
||||||
}),
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
);
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
}
|
children: error!,
|
||||||
|
),
|
||||||
if (sites == null || sites.length == 0) {
|
padding: EdgeInsets.symmetric(vertical: 0, horizontal: 10)));
|
||||||
return _buildNoSites();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return _buildSites();
|
return _buildSites();
|
||||||
|
@ -87,34 +182,45 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
|
|
||||||
Widget _buildNoSites() {
|
Widget _buildNoSites() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Center(child: Column(
|
child: Center(
|
||||||
children: <Widget>[
|
child: Column(
|
||||||
Padding(
|
children: <Widget>[
|
||||||
padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0),
|
Padding(
|
||||||
child: Text('Welcome to Nebula!', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
|
padding: const EdgeInsets.fromLTRB(0, 8.0, 0, 8.0),
|
||||||
|
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(site: site, onChanged: () => _loadSites());
|
return SiteDetailScreen(
|
||||||
|
site: site,
|
||||||
|
onChanged: () => _loadSites(),
|
||||||
|
supportsQRScanning: supportsQRScanning,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
Widget child = ReorderableListView(
|
Widget child = ReorderableListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
scrollController: scrollController,
|
||||||
padding: EdgeInsets.symmetric(vertical: 5),
|
padding: EdgeInsets.symmetric(vertical: 5),
|
||||||
children: items,
|
children: items,
|
||||||
onReorder: (oldI, newI) async {
|
onReorder: (oldI, newI) async {
|
||||||
|
@ -124,17 +230,21 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final Site moved = sites.removeAt(oldI);
|
final Site moved = sites!.removeAt(oldI);
|
||||||
sites.insert(newI, moved);
|
sites!.insert(newI, moved);
|
||||||
});
|
});
|
||||||
|
|
||||||
for (var i = min(oldI, newI); i <= max(oldI, newI); i++) {
|
for (var i = 0; i < sites!.length; i++) {
|
||||||
sites[i].sortKey = i;
|
if (sites![i].sortKey == i) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sites![i].sortKey = i;
|
||||||
try {
|
try {
|
||||||
await sites[i].save();
|
await sites![i].save();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//TODO: display error at the end
|
//TODO: display error at the end
|
||||||
print('ERR ${sites[i].name} - $err');
|
print('ERR ${sites![i].name} - $err');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,48 +256,28 @@ class _MainScreenState extends State<MainScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
// The theme here is to remove the hardcoded canvas border reordering forces on us
|
// The theme here is to remove the hardcoded canvas border reordering forces on us
|
||||||
return Theme(
|
return Theme(data: Theme.of(context).copyWith(canvasColor: Colors.transparent), child: child);
|
||||||
data: Theme.of(context).copyWith(canvasColor: Colors.transparent),
|
|
||||||
child: child
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _debugSave() {
|
Widget _debugSave(Map<String, String> siteConfig) {
|
||||||
return CupertinoButton(
|
return CupertinoButton(
|
||||||
key: Key('debug-save'),
|
child: Text(siteConfig['name']!),
|
||||||
child: Text("DEBUG SAVE"),
|
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
var uuid = Uuid();
|
var uuid = Uuid();
|
||||||
|
|
||||||
var cert = '''-----BEGIN NEBULA CERTIFICATE-----
|
|
||||||
CmIKBHRlc3QSCoKUoIUMgP7//w8ourrS+QUwjre3iAY6IDbmIX5cwd+UYVhLADLa
|
|
||||||
A5PwucZPVrNtP0P9NJE0boM2SiBSGzy8bcuFWWK5aVArJGA9VDtLg1HuujBu8lOp
|
|
||||||
VTgklxJAgbI1Xb1C9JC3a1Cnc6NPqWhnw+3VLoDXE9poBav09+zhw5DPDtgvQmxU
|
|
||||||
Sbw6cAF4gPS4e/tZ5Kjc8QEvjk3HDQ==
|
|
||||||
-----END NEBULA CERTIFICATE-----''';
|
|
||||||
|
|
||||||
var ca = '''-----BEGIN NEBULA CERTIFICATE-----
|
|
||||||
CjkKB3Rlc3QgY2EopYyK9wUwpfOOhgY6IHj4yrtHbq+rt4hXTYGrxuQOS0412uKT
|
|
||||||
4wi5wL503+SAQAESQPhWXuVGjauHS1Qqd3aNA3DY+X8CnAweXNEoJKAN/kjH+BBv
|
|
||||||
mUOcsdFcCZiXrj7ryQIG1+WfqA46w71A/lV4nAc=
|
|
||||||
-----END NEBULA CERTIFICATE-----''';
|
|
||||||
|
|
||||||
var 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(
|
var s = Site(
|
||||||
name: "DEBUG TEST",
|
name: siteConfig['name']!,
|
||||||
id: uuid.v4(),
|
id: uuid.v4(),
|
||||||
staticHostmap: {
|
staticHostmap: {
|
||||||
"10.1.0.1": StaticHost(lighthouse: true, destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
|
"10.1.0.1": StaticHost(
|
||||||
},
|
lighthouse: true,
|
||||||
caInfos: [CertificateInfo.debug(rawCert: ca)],
|
destinations: [IPAndPort(ip: '10.1.1.53', port: 4242), IPAndPort(ip: '1::1', port: 4242)])
|
||||||
certInfos: [certInfo],
|
},
|
||||||
unsafeRoutes: [UnsafeRoute(route: '10.3.3.3/32', via: '10.1.0.1')]
|
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 err = await s.save();
|
var err = await s.save();
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
|
@ -199,11 +289,17 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
_loadSites() async {
|
Widget _debugClearKeys() {
|
||||||
if (Platform.isAndroid) {
|
return CupertinoButton(
|
||||||
await platform.invokeMethod("android.requestPermissions");
|
child: Text("Clear Keys"),
|
||||||
}
|
onPressed: () async {
|
||||||
|
await platform.invokeMethod("debug.clearKeys", null);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
_loadSites() async {
|
||||||
//TODO: This can throw, we need to show an error dialog
|
//TODO: This can throw, we need to show an error dialog
|
||||||
Map<String, dynamic> rawSites = jsonDecode(await platform.invokeMethod('listSites'));
|
Map<String, dynamic> rawSites = jsonDecode(await platform.invokeMethod('listSites'));
|
||||||
bool hasErrors = false;
|
bool hasErrors = false;
|
||||||
|
@ -221,12 +317,13 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}, onError: (err) {
|
}, onError: (err) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
if (ModalRoute.of(context).isCurrent) {
|
if (ModalRoute.of(context)!.isCurrent) {
|
||||||
Utils.popError(context, "${site.name} Error", err);
|
Utils.popError(context, "${site.name} Error", err);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
sites.add(site);
|
|
||||||
|
sites!.add(site);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
//TODO: handle error
|
//TODO: handle error
|
||||||
print("$err site config: $rawSite");
|
print("$err site config: $rawSite");
|
||||||
|
@ -235,16 +332,19 @@ rmXnR1yvDZi1VPVmnNVY8NMsQpEpbbYlq7rul+ByQvg=
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (hasErrors) {
|
if (Platform.isAndroid) {
|
||||||
Utils.popError(context, "Site Error(s)", "1 or more sites have errors and need your attention, problem sites have a red border.");
|
// Android suffers from a race to discover the active site and attach site specific event listeners
|
||||||
|
platform.invokeMethod("android.registerActiveSite");
|
||||||
}
|
}
|
||||||
|
|
||||||
sites.sort((a, b) {
|
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;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,20 +1,24 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
import 'package:mobile_nebula/screens/EnrollmentScreen.dart';
|
||||||
import 'package:mobile_nebula/services/settings.dart';
|
import 'package:mobile_nebula/services/settings.dart';
|
||||||
import 'package:mobile_nebula/services/utils.dart';
|
import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
import 'AboutScreen.dart';
|
import 'AboutScreen.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
|
final StreamController stream;
|
||||||
|
|
||||||
|
const SettingsScreen(this.stream, {super.key});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SettingsScreenState createState() {
|
_SettingsScreenState createState() => _SettingsScreenState();
|
||||||
return _SettingsScreenState();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsScreenState extends State<SettingsScreen> {
|
class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
|
@ -81,6 +85,16 @@ 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'),
|
||||||
|
@ -89,7 +103,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||||
]));
|
]));
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: 'Settings',
|
title: Text('Settings'),
|
||||||
child: Column(children: items),
|
child: Column(children: items),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,10 +3,9 @@ import 'dart:async';
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
import 'package:flutter_platform_widgets/flutter_platform_widgets.dart';
|
||||||
|
import 'package:flutter_svg/svg.dart';
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/SpecialSelectableText.dart';
|
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
@ -22,47 +21,48 @@ import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
|
//TODO: ios is now the problem with connecting screwing our ability to query the hostmap (its a race)
|
||||||
|
|
||||||
class SiteDetailScreen extends StatefulWidget {
|
class SiteDetailScreen extends StatefulWidget {
|
||||||
const SiteDetailScreen({Key key, this.site, this.onChanged}) : super(key: key);
|
const SiteDetailScreen({
|
||||||
|
Key? key,
|
||||||
|
required this.site,
|
||||||
|
this.onChanged,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
final Function onChanged;
|
final Function? onChanged;
|
||||||
|
final bool supportsQRScanning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SiteDetailScreenState createState() => _SiteDetailScreenState();
|
_SiteDetailScreenState createState() => _SiteDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
Site site;
|
late Site site;
|
||||||
StreamSubscription onChange;
|
late StreamSubscription onChange;
|
||||||
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
static const platform = MethodChannel('net.defined.mobileNebula/NebulaVpnService');
|
||||||
bool changed = false;
|
bool changed = false;
|
||||||
List<HostInfo> activeHosts;
|
List<HostInfo>? activeHosts;
|
||||||
List<HostInfo> pendingHosts;
|
List<HostInfo>? pendingHosts;
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
bool lastState;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
site = widget.site;
|
site = widget.site;
|
||||||
lastState = site.connected;
|
|
||||||
if (site.connected) {
|
if (site.connected) {
|
||||||
_listHostmap();
|
_listHostmap();
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange = site.onChange().listen((_) {
|
onChange = site.onChange().listen((_) {
|
||||||
if (lastState != site.connected) {
|
// TODO: Gross hack... we get site.connected = true to trigger the toggle before the VPN service has started.
|
||||||
//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 we fetch the hostmap now we'll never get a response. Wait until Nebula is running.
|
||||||
if (site.status == 'Connected') {
|
if (site.status == 'Connected') {
|
||||||
lastState = true;
|
_listHostmap();
|
||||||
_listHostmap();
|
} else {
|
||||||
} else {
|
activeHosts = null;
|
||||||
lastState = false;
|
pendingHosts = null;
|
||||||
activeHosts = null;
|
|
||||||
pendingHosts = null;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setState(() {});
|
|
||||||
|
|
||||||
|
setState(() {});
|
||||||
}, onError: (err) {
|
}, onError: (err) {
|
||||||
setState(() {});
|
setState(() {});
|
||||||
Utils.popError(context, "Error", err);
|
Utils.popError(context, "Error", err);
|
||||||
|
@ -79,11 +79,19 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
final title = Row(children: [
|
||||||
|
site.managed ?
|
||||||
|
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) :
|
||||||
|
Container(),
|
||||||
|
Expanded(child: Text(site.name, style: TextStyle(fontWeight: FontWeight.bold)))
|
||||||
|
]);
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: site.name,
|
title: title,
|
||||||
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
||||||
if (changed && widget.onChanged != null) {
|
if (changed && widget.onChanged != null) {
|
||||||
widget.onChanged();
|
widget.onChanged!();
|
||||||
}
|
}
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}),
|
}),
|
||||||
|
@ -111,7 +119,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
List<Widget> items = [];
|
List<Widget> items = [];
|
||||||
site.errors.forEach((error) {
|
site.errors.forEach((error) {
|
||||||
items.add(ConfigItem(
|
items.add(ConfigItem(
|
||||||
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SpecialSelectableText(error))));
|
labelWidth: 0, content: Padding(padding: EdgeInsets.symmetric(vertical: 10), child: SelectableText(error))));
|
||||||
});
|
});
|
||||||
|
|
||||||
return ConfigSection(
|
return ConfigSection(
|
||||||
|
@ -165,13 +173,13 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
if (activeHosts == null) {
|
if (activeHosts == null) {
|
||||||
active = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
active = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
||||||
} else {
|
} else {
|
||||||
active = Text(Utils.itemCountFormat(activeHosts.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
active = Text(Utils.itemCountFormat(activeHosts!.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pendingHosts == null) {
|
if (pendingHosts == null) {
|
||||||
pending = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
pending = SizedBox(height: 20, width: 20, child: PlatformCircularProgressIndicator());
|
||||||
} else {
|
} else {
|
||||||
pending = Text(Utils.itemCountFormat(pendingHosts.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
pending = Text(Utils.itemCountFormat(pendingHosts!.length, singleSuffix: "tunnel", multiSuffix: "tunnels"));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ConfigSection(
|
return ConfigSection(
|
||||||
|
@ -179,33 +187,41 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
children: <Widget>[
|
children: <Widget>[
|
||||||
ConfigPageItem(
|
ConfigPageItem(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (activeHosts == null) return;
|
||||||
|
|
||||||
Utils.openPage(
|
Utils.openPage(
|
||||||
context,
|
context,
|
||||||
(context) => SiteTunnelsScreen(
|
(context) => SiteTunnelsScreen(
|
||||||
pending: false,
|
pending: false,
|
||||||
tunnels: activeHosts,
|
tunnels: activeHosts!,
|
||||||
site: site,
|
site: site,
|
||||||
onChanged: (hosts) {
|
onChanged: (hosts) {
|
||||||
setState(() {
|
setState(() {
|
||||||
activeHosts = hosts;
|
activeHosts = hosts;
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
));
|
||||||
},
|
},
|
||||||
label: Text("Active"),
|
label: Text("Active"),
|
||||||
content: Container(alignment: Alignment.centerRight, child: active)),
|
content: Container(alignment: Alignment.centerRight, child: active)),
|
||||||
ConfigPageItem(
|
ConfigPageItem(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
|
if (pendingHosts == null) return;
|
||||||
|
|
||||||
Utils.openPage(
|
Utils.openPage(
|
||||||
context,
|
context,
|
||||||
(context) => SiteTunnelsScreen(
|
(context) => SiteTunnelsScreen(
|
||||||
pending: true,
|
pending: true,
|
||||||
tunnels: pendingHosts,
|
tunnels: pendingHosts!,
|
||||||
site: site,
|
site: site,
|
||||||
onChanged: (hosts) {
|
onChanged: (hosts) {
|
||||||
setState(() {
|
setState(() {
|
||||||
pendingHosts = hosts;
|
pendingHosts = hosts;
|
||||||
});
|
});
|
||||||
}));
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
));
|
||||||
},
|
},
|
||||||
label: Text("Pending"),
|
label: Text("Pending"),
|
||||||
content: Container(alignment: Alignment.centerRight, child: pending))
|
content: Container(alignment: Alignment.centerRight, child: pending))
|
||||||
|
@ -224,7 +240,10 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
site: widget.site,
|
site: widget.site,
|
||||||
onSave: (site) async {
|
onSave: (site) async {
|
||||||
changed = true;
|
changed = true;
|
||||||
});
|
setState(() {});
|
||||||
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
@ -236,7 +255,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
|
padding: EdgeInsets.only(top: 50, bottom: 10, left: 10, right: 10),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
child: PlatformButton(
|
child: PlatformElevatedButton(
|
||||||
child: Text('Delete'),
|
child: Text('Delete'),
|
||||||
color: CupertinoColors.systemRed.resolveFrom(context),
|
color: CupertinoColors.systemRed.resolveFrom(context),
|
||||||
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async {
|
onPressed: () => Utils.confirmDelete(context, 'Delete Site?', () async {
|
||||||
|
@ -253,7 +272,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
pendingHosts = maps["pending"];
|
pendingHosts = maps["pending"];
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while fetching hostmaps', err);
|
Utils.popError(context, 'Error while fetching hostmaps', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -270,7 +289,7 @@ class _SiteDetailScreenState extends State<SiteDetailScreen> {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (widget.onChanged != null) {
|
if (widget.onChanged != null) {
|
||||||
widget.onChanged();
|
widget.onChanged!();
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,10 +2,9 @@ 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';
|
||||||
|
@ -13,7 +12,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, this.site}) : super(key: key);
|
const SiteLogsScreen({Key? key, required this.site}) : super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
|
|
||||||
|
@ -41,8 +40,16 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final dnIcon = Theme.of(context).brightness == Brightness.dark ? 'images/dn-logo-dark.svg' : 'images/dn-logo-light.svg';
|
||||||
|
final title = Row(children: [
|
||||||
|
widget.site.managed ?
|
||||||
|
Padding(padding: EdgeInsets.only(right: 10), child: SvgPicture.asset(dnIcon, width: 12)) :
|
||||||
|
Container(),
|
||||||
|
Expanded(child: Text(widget.site.name, style: TextStyle(fontWeight: FontWeight.bold)))
|
||||||
|
]);
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: widget.site.name,
|
title: title,
|
||||||
scrollable: SimpleScrollable.both,
|
scrollable: SimpleScrollable.both,
|
||||||
scrollController: controller,
|
scrollController: controller,
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
|
@ -54,7 +61,10 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
refreshController.loadComplete();
|
refreshController.loadComplete();
|
||||||
},
|
},
|
||||||
refreshController: refreshController,
|
refreshController: refreshController,
|
||||||
child: Container(padding: EdgeInsets.all(5), constraints: logBoxConstraints(context), child: SpecialSelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
|
child: Container(
|
||||||
|
padding: EdgeInsets.all(5),
|
||||||
|
constraints: logBoxConstraints(context),
|
||||||
|
child: SelectableText(logs.trim(), style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14))),
|
||||||
bottomBar: _buildBottomBar(),
|
bottomBar: _buildBottomBar(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -74,28 +84,28 @@ class _SiteLogsScreenState extends State<SiteLogsScreen> {
|
||||||
),
|
),
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: <Widget>[
|
||||||
Expanded(
|
Expanded(
|
||||||
child: PlatformIconButton(
|
child: Builder(
|
||||||
padding: padding,
|
builder: (BuildContext context) {
|
||||||
icon: Icon(context.platformIcons.share, size: 30),
|
return PlatformIconButton(
|
||||||
onPressed: () {
|
padding: padding,
|
||||||
Share.shareFile(title: '${widget.site.name} logs', filePath: widget.site.logFile, filename: '${widget.site.name}.log');
|
icon: Icon(context.platformIcons.share, size: 30),
|
||||||
},
|
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.delete, size: Platform.isIOS ? 38 : 30),
|
icon: Icon(context.platformIcons.downArrow, size: 30),
|
||||||
onPressed: () {
|
onPressed: () async {
|
||||||
Utils.confirmDelete(context, 'Are you sure you want to clear all logs?', () => deleteLogs());
|
controller.animateTo(controller.position.maxScrollExtent,
|
||||||
},
|
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);
|
|
||||||
},
|
|
||||||
)),
|
)),
|
||||||
]));
|
]));
|
||||||
}
|
}
|
||||||
|
@ -108,6 +118,8 @@ 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());
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
|
||||||
import 'package:mobile_nebula/components/SimplePage.dart';
|
import 'package:mobile_nebula/components/SimplePage.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
import 'package:mobile_nebula/components/config/ConfigPageItem.dart';
|
||||||
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
import 'package:mobile_nebula/components/config/ConfigSection.dart';
|
||||||
|
@ -11,32 +10,43 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
import 'package:pull_to_refresh/pull_to_refresh.dart';
|
||||||
|
|
||||||
class SiteTunnelsScreen extends StatefulWidget {
|
class SiteTunnelsScreen extends StatefulWidget {
|
||||||
const SiteTunnelsScreen({Key key, this.site, this.tunnels, this.pending, this.onChanged}) : super(key: key);
|
const SiteTunnelsScreen({
|
||||||
|
Key? key,
|
||||||
|
required this.site,
|
||||||
|
required this.tunnels,
|
||||||
|
required this.pending,
|
||||||
|
required this.onChanged,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
})
|
||||||
|
: super(key: key);
|
||||||
|
|
||||||
final Site site;
|
final Site site;
|
||||||
final List<HostInfo> tunnels;
|
final List<HostInfo> tunnels;
|
||||||
final bool pending;
|
final bool pending;
|
||||||
final Function(List<HostInfo>) onChanged;
|
final Function(List<HostInfo>)? onChanged;
|
||||||
|
|
||||||
|
final bool supportsQRScanning;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
_SiteTunnelsScreenState createState() => _SiteTunnelsScreenState();
|
_SiteTunnelsScreenState createState() => _SiteTunnelsScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
Site site;
|
late Site site;
|
||||||
List<HostInfo> tunnels;
|
late List<HostInfo> tunnels;
|
||||||
RefreshController refreshController = RefreshController(initialRefresh: false);
|
RefreshController refreshController = RefreshController(initialRefresh: false);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
site = widget.site;
|
site = widget.site;
|
||||||
tunnels = widget.tunnels ?? [];
|
tunnels = widget.tunnels;
|
||||||
_sortTunnels();
|
_sortTunnels();
|
||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
refreshController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -65,10 +75,13 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
site: widget.site,
|
site: widget.site,
|
||||||
onChanged: () {
|
onChanged: () {
|
||||||
_listHostmap();
|
_listHostmap();
|
||||||
})),
|
},
|
||||||
|
supportsQRScanning: widget.supportsQRScanning,
|
||||||
|
),
|
||||||
|
),
|
||||||
label: Row(children: <Widget>[Padding(child: icon, padding: EdgeInsets.only(right: 10)), Text(hostInfo.vpnIp)]),
|
label: Row(children: <Widget>[Padding(child: icon, padding: EdgeInsets.only(right: 10)), Text(hostInfo.vpnIp)]),
|
||||||
labelWidth: ipWidth,
|
labelWidth: ipWidth,
|
||||||
content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details?.name ?? "")),
|
content: Container(alignment: Alignment.centerRight, child: Text(hostInfo.cert?.details.name ?? "")),
|
||||||
));
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -82,7 +95,7 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
final title = widget.pending ? 'Pending' : 'Active';
|
final title = widget.pending ? 'Pending' : 'Active';
|
||||||
|
|
||||||
return SimplePage(
|
return SimplePage(
|
||||||
title: "$title Tunnels",
|
title: Text('$title Tunnels'),
|
||||||
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
leadingAction: Utils.leadingBackWidget(context, onPressed: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
}),
|
}),
|
||||||
|
@ -122,11 +135,11 @@ class _SiteTunnelsScreenState extends State<SiteTunnelsScreen> {
|
||||||
|
|
||||||
_sortTunnels();
|
_sortTunnels();
|
||||||
if (widget.onChanged != null) {
|
if (widget.onChanged != null) {
|
||||||
widget.onChanged(tunnels);
|
widget.onChanged!(tunnels);
|
||||||
}
|
}
|
||||||
setState(() {});
|
setState(() {});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
Utils.popError(context, 'Error while fetching hostmap', err);
|
Utils.popError(context, 'Error while fetching hostmap', err.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
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/widgets.dart';
|
import 'package:flutter_barcode_scanner/flutter_barcode_scanner.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';
|
||||||
|
@ -18,71 +16,71 @@ import 'package:mobile_nebula/services/utils.dart';
|
||||||
|
|
||||||
import 'CertificateDetailsScreen.dart';
|
import 'CertificateDetailsScreen.dart';
|
||||||
|
|
||||||
class AddCertificateScreen extends StatefulWidget {
|
class CertificateResult {
|
||||||
const AddCertificateScreen({Key key, this.onSave, this.choosePrimary = false}) : super(key: key);
|
CertificateInfo certInfo;
|
||||||
|
String key;
|
||||||
|
|
||||||
final ValueChanged<CertificateInfo> onSave;
|
CertificateResult({required this.certInfo, required this.key});
|
||||||
final choosePrimary;
|
}
|
||||||
|
|
||||||
|
class AddCertificateScreen extends StatefulWidget {
|
||||||
|
const AddCertificateScreen({
|
||||||
|
Key? key,
|
||||||
|
this.onSave,
|
||||||
|
this.onReplace,
|
||||||
|
required this.pubKey,
|
||||||
|
required this.privKey,
|
||||||
|
required this.supportsQRScanning,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
|
// onSave will pop a new CertificateDetailsScreen.
|
||||||
|
// If onSave is null, onReplace must be set.
|
||||||
|
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> {
|
||||||
String pubKey;
|
late String pubKey;
|
||||||
String privKey;
|
bool showKey = false;
|
||||||
|
|
||||||
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() {
|
||||||
_generateKeys();
|
pubKey = widget.pubKey;
|
||||||
|
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(
|
return SimplePage(title: Text('Certificate'), child: Column(children: items));
|
||||||
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() {
|
||||||
|
@ -92,34 +90,49 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
|
||||||
children: [
|
children: [
|
||||||
ConfigItem(
|
ConfigItem(
|
||||||
labelWidth: 0,
|
labelWidth: 0,
|
||||||
content: SpecialSelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
|
content: SelectableText(pubKey, style: TextStyle(fontFamily: 'RobotoMono', fontSize: 14)),
|
||||||
),
|
),
|
||||||
ConfigButtonItem(
|
Builder(
|
||||||
content: Text('Share Public Key'),
|
builder: (BuildContext context) {
|
||||||
onPressed: () async {
|
return ConfigButtonItem(
|
||||||
await Share.share(title: 'Please sign and return a certificate', text: pubKey, filename: 'device.pub');
|
content: Text('Share Public Key'),
|
||||||
},
|
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) {
|
||||||
setState(() {
|
if (v != null) {
|
||||||
inputType = v;
|
setState(() {
|
||||||
});
|
inputType = v;
|
||||||
},
|
});
|
||||||
children: {
|
}
|
||||||
'paste': Text('Copy/Paste'),
|
|
||||||
'file': Text('File'),
|
|
||||||
'qr': Text('QR Code'),
|
|
||||||
},
|
},
|
||||||
|
children: children,
|
||||||
))
|
))
|
||||||
];
|
];
|
||||||
|
|
||||||
|
@ -127,13 +140,37 @@ 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 {
|
} else if (inputType == 'qr') {
|
||||||
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(
|
||||||
|
@ -182,13 +219,13 @@ class _AddCertificateScreenState extends State<AddCertificateScreen> {
|
||||||
ConfigButtonItem(
|
ConfigButtonItem(
|
||||||
content: Text('Scan a QR code'),
|
content: Text('Scan a QR code'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
var options = ScanOptions(
|
try {
|
||||||
restrictFormat: [BarcodeFormat.qr],
|
var result = await FlutterBarcodeScanner.scanBarcode('#ff6666', 'Cancel', true, ScanMode.QR);
|
||||||
);
|
if (result != "") {
|
||||||
|
_addCertEntry(result);
|
||||||
var result = await BarcodeScanner.scan(options: options);
|
}
|
||||||
if (result.rawContent != "") {
|
} catch (err) {
|
||||||
_addCertEntry(result.rawContent);
|
return Utils.popError(context, 'Error scanning QR code', err.toString());
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
@ -199,38 +236,52 @@ 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) {
|
||||||
privKey = _testKey;
|
keyController.text = _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', 'A certificate authority is not appropriate for a client certificate.');
|
return Utils.popError(context, 'Error loading certificate content',
|
||||||
} else if (!tryCertInfo.validity.valid) {
|
'A certificate authority is not appropriate for a client certificate.');
|
||||||
return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity.reason);
|
} else if (!tryCertInfo.validity!.valid) {
|
||||||
|
return Utils.popError(context, 'Certificate was invalid', tryCertInfo.validity!.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
//TODO: test that the pubkey we generated equals the pub key in the cert
|
var certMatch = await platform
|
||||||
certInfo = tryCertInfo;
|
.invokeMethod("nebula.verifyCertAndKey", <String, String>{"cert": rawCert, "key": keyController.text});
|
||||||
certInfo.primary = !widget.choosePrimary;
|
if (!certMatch) {
|
||||||
certInfo.key = privKey;
|
// The method above will throw if there is a mismatch, this is just here in case we introduce a bug in the future
|
||||||
|
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,4 +296,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
Loading…
Reference in New Issue